├── .github ├── FUNDING.yml └── ISSUE_TEMPLATE │ ├── config.yml │ ├── question.md │ ├── feature_request.md │ └── bug_report.md ├── browser-demo ├── build.sh ├── package.json ├── README.md ├── index.html └── main.js ├── src ├── lib │ ├── alias │ │ ├── add.ts │ │ ├── set.ts │ │ ├── remove.ts │ │ └── action.ts │ ├── label │ │ └── set.ts │ ├── description │ │ └── set.ts │ ├── get_instance_wikibase_sdk.ts │ ├── types │ │ ├── common.ts │ │ ├── snaks.ts │ │ └── config.ts │ ├── validate_parameters.ts │ ├── claim │ │ ├── claim_parsers.ts │ │ ├── special_snaktype.ts │ │ ├── find_snak.ts │ │ ├── format_claim_value.ts │ │ ├── parse_calendar.ts │ │ ├── helpers.ts │ │ ├── set.ts │ │ ├── is_matching_claim.ts │ │ ├── quantity.ts │ │ ├── remove.ts │ │ ├── create.ts │ │ ├── snak.ts │ │ ├── get_time_object.ts │ │ ├── move_commons.ts │ │ ├── builders.ts │ │ └── update.ts │ ├── request │ │ ├── get_json.ts │ │ ├── check_known_issues.ts │ │ ├── get_token.ts │ │ ├── get_auth_data.ts │ │ ├── parse_session_cookies.ts │ │ ├── initialize_config_auth.ts │ │ ├── oauth.ts │ │ ├── parse_response_body.ts │ │ ├── login.ts │ │ ├── get_final_token.ts │ │ ├── fetch.ts │ │ └── request.ts │ ├── entity │ │ ├── delete.ts │ │ ├── merge.ts │ │ ├── create.ts │ │ ├── id_alias.ts │ │ └── validate_reconciliation_object.ts │ ├── reference │ │ ├── remove.ts │ │ └── set.ts │ ├── parse_instance.ts │ ├── qualifier │ │ ├── remove.ts │ │ ├── set.ts │ │ └── update.ts │ ├── debug.ts │ ├── issues.ts │ ├── properties │ │ ├── datatypes_to_builder_datatypes.ts │ │ ├── find_snaks_properties.ts │ │ ├── fetch_used_properties_datatypes.ts │ │ └── fetch_properties_datatypes.ts │ ├── label_or_description │ │ └── set.ts │ ├── sitelink │ │ └── set.ts │ ├── error.ts │ ├── badge │ │ ├── add.ts │ │ └── remove.ts │ ├── get_entity.ts │ ├── resolve_title.ts │ ├── bundle_wrapper.ts │ ├── validate_and_enrich_config.ts │ ├── datatype_tests.ts │ ├── request_wrapper.ts │ └── utils.ts └── assets │ └── metadata.ts ├── .gitignore ├── scripts ├── update_toc ├── githooks │ └── pre-commit └── postversion ├── .mocharc.cjs ├── tests ├── integration │ ├── utils │ │ ├── wait_for_instance.ts │ │ ├── get_property.ts │ │ ├── sandbox_snaks.ts │ │ └── utils.ts │ ├── alias │ │ ├── set.ts │ │ ├── remove.ts │ │ └── add.ts │ ├── claim │ │ ├── set.ts │ │ ├── reconciliation_skip_on_any_value.ts │ │ ├── reconciliation_remove.ts │ │ ├── reconciliation_skip_on_value_match_mode.ts │ │ └── reconciliation.ts │ ├── tags.ts │ ├── fetch_properties_datatypes.ts │ ├── anonymous_edit.ts │ ├── entity │ │ ├── merge.ts │ │ ├── delete.ts │ │ └── create.ts │ ├── qualifier │ │ ├── remove.ts │ │ └── set.ts │ ├── reference │ │ ├── remove.ts │ │ └── set.ts │ ├── sitelink │ │ └── set.ts │ ├── badge │ │ ├── remove.ts │ │ └── add.ts │ ├── maxlag.ts │ ├── multi_users.ts │ ├── label │ │ └── set.ts │ ├── token_expiration.ts │ ├── description │ │ └── set.ts │ ├── get_auth_data.ts │ ├── get_token.ts │ └── credentials.ts └── unit │ ├── entity │ ├── delete.ts │ ├── merge.ts │ └── create.ts │ ├── request.ts │ ├── qualifier │ └── remove.ts │ ├── reference │ ├── remove.ts │ └── set.ts │ ├── claim │ ├── remove.ts │ └── set.ts │ ├── label │ └── set.ts │ ├── utils.ts │ ├── description │ └── set.ts │ ├── parse_instance.ts │ ├── alias │ ├── set.ts │ ├── add.ts │ └── remove.ts │ ├── general.ts │ └── sitelink │ └── set.ts ├── config ├── default.cjs └── local.cjs ├── tsconfig.json ├── package.json ├── README.md └── docs └── development_setup.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | liberapay: Association_Inventaire 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | -------------------------------------------------------------------------------- /browser-demo/build.sh: -------------------------------------------------------------------------------- 1 | esbuild ../lib/index.js --bundle --outfile=./wikibase-edit.js --format=esm -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Ask anything about this tool 4 | 5 | --- 6 | -------------------------------------------------------------------------------- /src/lib/alias/add.ts: -------------------------------------------------------------------------------- 1 | import { actionFactory } from './action.js' 2 | 3 | export const addAlias = actionFactory('add') 4 | -------------------------------------------------------------------------------- /src/lib/alias/set.ts: -------------------------------------------------------------------------------- 1 | import { actionFactory } from './action.js' 2 | 3 | export const setAlias = actionFactory('set') 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | config/local.js 3 | src/assets 4 | pnpm-lock.yaml 5 | browser-demo/wikibase-edit.js 6 | dist/ 7 | -------------------------------------------------------------------------------- /src/lib/alias/remove.ts: -------------------------------------------------------------------------------- 1 | import { actionFactory } from './action.js' 2 | 3 | export const removeAlias = actionFactory('remove') 4 | -------------------------------------------------------------------------------- /src/lib/label/set.ts: -------------------------------------------------------------------------------- 1 | import { setLabelOrDescriptionFactory } from '../label_or_description/set.js' 2 | 3 | export const setLabel = setLabelOrDescriptionFactory('label') 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this tool 4 | 5 | --- 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/lib/description/set.ts: -------------------------------------------------------------------------------- 1 | import { setLabelOrDescriptionFactory } from '../label_or_description/set.js' 2 | 3 | export const setDescription = setLabelOrDescriptionFactory('description') 4 | -------------------------------------------------------------------------------- /src/lib/get_instance_wikibase_sdk.ts: -------------------------------------------------------------------------------- 1 | import { WBK } from 'wikibase-sdk' 2 | 3 | const wdks = {} 4 | 5 | export default instance => { 6 | if (!wdks[instance]) wdks[instance] = WBK({ instance }) 7 | return wdks[instance] 8 | } 9 | -------------------------------------------------------------------------------- /src/lib/types/common.ts: -------------------------------------------------------------------------------- 1 | import type { RevisionId } from 'wikibase-sdk' 2 | 3 | export type AbsoluteUrl = `http${string}` 4 | 5 | export type BaseRevId = RevisionId | number 6 | 7 | export type MaxLag = number 8 | 9 | export type Tags = string[] 10 | -------------------------------------------------------------------------------- /src/assets/metadata.ts: -------------------------------------------------------------------------------- 1 | // Generated by scripts/postversion 2 | export const name = 'wikibase-edit' 3 | export const version = '8.0.6' 4 | export const homepage = 'https://github.com/maxlath/wikibase-edit' 5 | export const issues = 'https://github.com/maxlath/wikibase-edit/issues' 6 | -------------------------------------------------------------------------------- /scripts/update_toc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | which doctoc && { 3 | doctoc docs/*.md 4 | } || { 5 | echo "requires to have https://www.npmjs.com/package/doctoc installed, either globally or just in this repo" 6 | echo "(it is not installed as a dev dependency as the use made of it is not worth the subdependencies maintainance)" 7 | exit 1 8 | } 9 | -------------------------------------------------------------------------------- /browser-demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "browser-demo", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "main.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "devDependencies": { 13 | "esbuild": "^0.20.1" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /browser-demo/README.md: -------------------------------------------------------------------------------- 1 | # wikibase-edit browser demo 2 | 3 | To run wikibase-edit in the browser, you need a bundler. In this demo we use `esbuild` (see [`build.sh`](./build.sh)). 4 | ```sh 5 | cd browser-demo 6 | ./build.sh 7 | ``` 8 | 9 | You can then use a file server to serve the files in this directory and test wikibase-edit in the browser. Note that the edit will only work if the page is served under HTTPS. -------------------------------------------------------------------------------- /src/lib/validate_parameters.ts: -------------------------------------------------------------------------------- 1 | import { newError } from './error.js' 2 | 3 | export default params => { 4 | if (params == null) { 5 | const err = newError('missing parameters object', { params }) 6 | // Expected by wikibase-cli 7 | err.code = 'EMPTY_PARAMS' 8 | throw err 9 | } 10 | 11 | if (!(params.id || params.guid || params.hash || params.labels || (params.from && params.to))) { 12 | throw newError('invalid params object', { params }) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /scripts/githooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eu 3 | 4 | # keep A (added) and M (modified) files 5 | # only if staged (0 space before/2 space after the letter) 6 | staged=$(git status --porcelain | grep --extended-regexp "^(A|M)" | grep --extended-regexp '.ts$' | grep -v 'dist/' | sed --regexp-extended 's/^\w+\s+//') 7 | 8 | if [ -z "$staged" ] 9 | then 10 | echo 'No staged file requires to be linted' 11 | else 12 | npm run lint $staged && npm run test:unit 13 | fi 14 | -------------------------------------------------------------------------------- /src/lib/claim/claim_parsers.ts: -------------------------------------------------------------------------------- 1 | import { simplifyClaim } from 'wikibase-sdk' 2 | import { hasSpecialSnaktype } from './special_snaktype.js' 3 | 4 | export const matchClaim = value => claim => { 5 | if (typeof value === 'object') { 6 | if (hasSpecialSnaktype(value)) { 7 | if (claim.mainsnak.snaktype === value.snaktype) return true 8 | } 9 | value = value.value 10 | } 11 | return value === simplifyClaim(claim) 12 | } 13 | 14 | export const getGuid = claim => claim.id 15 | -------------------------------------------------------------------------------- /src/lib/request/get_json.ts: -------------------------------------------------------------------------------- 1 | import { request, type RequestParams } from './request.js' 2 | import type { AbsoluteUrl } from '../types/common.js' 3 | 4 | export function getJson (url: AbsoluteUrl, params: Partial = {}) { 5 | // Ignore cases were a map function passed an index as second argument 6 | // ex: Promise.all(urls.map(getJson)) 7 | if (typeof params !== 'object') params = {} 8 | 9 | params.url = url 10 | return request('get', params as RequestParams) 11 | } 12 | -------------------------------------------------------------------------------- /.mocharc.cjs: -------------------------------------------------------------------------------- 1 | const nodeOptionsBeforeV20 = [ 2 | 'loader=tsx/esm', 3 | // Mute node error: (node:29544) ExperimentalWarning: `--experimental-loader` may be removed in the future; instead use `register()` 4 | 'no-warnings', 5 | ] 6 | 7 | const nodeOptionsFromV20 = [ 8 | 'import=tsx/esm', 9 | ] 10 | 11 | const nodeVersion = parseInt(process.version.split('.')[0].slice(1)) 12 | 13 | module.exports = { 14 | extension: 'ts', 15 | 'node-option': nodeVersion >= 20 ? nodeOptionsFromV20 : nodeOptionsBeforeV20, 16 | } 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a bug report 4 | --- 5 | 6 | 7 | 8 | 9 | - wikibase-edit version: -------------------------------------------------------------------------------- /src/lib/claim/special_snaktype.ts: -------------------------------------------------------------------------------- 1 | import { newError } from '../error.js' 2 | 3 | export interface SpecialSnak { 4 | snaktype: 'novalue' | 'somevalue' 5 | } 6 | 7 | export function hasSpecialSnaktype (value: unknown): value is SpecialSnak { 8 | if (typeof value !== 'object') return false 9 | if (!('snaktype' in value)) return false 10 | const { snaktype } = value 11 | if (snaktype == null || snaktype === 'value') return false 12 | if (snaktype === 'novalue' || snaktype === 'somevalue') return true 13 | else throw newError('invalid snaktype', { snaktype }) 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/request/check_known_issues.ts: -------------------------------------------------------------------------------- 1 | const knownIssues = { 2 | wbmergeitems: { 3 | internal_api_error_TypeError: 'https://phabricator.wikimedia.org/T232925', 4 | }, 5 | } 6 | 7 | export default (url, err) => { 8 | if (!url) return 9 | const actionMatch = url.match(/action=(\w+)/) 10 | if (!actionMatch) return 11 | const action = actionMatch[1] 12 | if (knownIssues[action]?.[err.name]) { 13 | const ticketUrl = knownIssues[action][err.name] 14 | console.error(`this is a known issue, please help documenting it at ${ticketUrl}`) 15 | throw err 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /scripts/postversion: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eu 4 | 5 | cat package.json | jq '" 6 | // Generated by scripts/postversion 7 | export const name = \"\(.name)\" 8 | export const version = \"\(.version)\" 9 | export const homepage = \"\(.homepage)\" 10 | export const issues = \"\(.bugs.url)\" 11 | "' -cr > ./src/assets/metadata.ts 12 | 13 | eslint --fix ./src/assets/metadata.ts 14 | 15 | git add --force ./src/assets/metadata.ts 16 | git commit --amend --no-edit 17 | 18 | version=$(cat package.json | jq .version -cr) 19 | tag="v${version}" 20 | git tag -d "$tag" 21 | git tag "$tag" 22 | -------------------------------------------------------------------------------- /tests/integration/utils/wait_for_instance.ts: -------------------------------------------------------------------------------- 1 | import config from 'config' 2 | import { yellow, grey } from 'tiny-chalk' 3 | import { customFetch } from '#lib/request/fetch' 4 | import { wait } from '#tests/integration/utils/utils' 5 | 6 | const { instance } = config 7 | 8 | export function waitForInstance () { 9 | const check = async () => { 10 | return customFetch(instance, { timeout: 2000 }) 11 | .catch(err => { 12 | console.warn(yellow(`waiting for instance at ${instance}`, grey(err.code))) 13 | return wait(1000).then(check) 14 | }) 15 | } 16 | 17 | return check() 18 | } 19 | -------------------------------------------------------------------------------- /src/lib/entity/delete.ts: -------------------------------------------------------------------------------- 1 | import { isEntityId, type EntityId, type EntityPageTitle } from 'wikibase-sdk' 2 | import { newError } from '../error.js' 3 | 4 | export interface DeleteEntityParams { 5 | id: EntityId 6 | } 7 | 8 | export function deleteEntity (params) { 9 | const { id } = params 10 | 11 | if (!isEntityId(id)) throw newError('invalid entity id', params) 12 | 13 | return { 14 | action: 'delete', 15 | data: { 16 | // The title will be prefixified if needed by ./lib/resolve_title.js 17 | title: id, 18 | }, 19 | } 20 | } 21 | 22 | export interface DeleteEntityResponse { 23 | title: EntityPageTitle 24 | reason: string 25 | logid: number 26 | } 27 | -------------------------------------------------------------------------------- /browser-demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Test wikibase-edit in the browser 6 | 7 | 13 | 14 | 15 |

Test wikibase-edit in the browser

16 | 21 | 22 |

23 | 
24 | 


--------------------------------------------------------------------------------
/tests/unit/entity/delete.ts:
--------------------------------------------------------------------------------
 1 | import 'should'
 2 | import { deleteEntity } from '#lib/entity/delete'
 3 | import { someEntityId as id } from '#tests/unit/utils'
 4 | 
 5 | describe('entity delete', () => {
 6 |   it('should set the action to delete', () => {
 7 |     deleteEntity({ id }).action.should.equal('delete')
 8 |   })
 9 | 
10 |   it('should set the title to the entity id', () => {
11 |     deleteEntity({ id }).data.title.should.equal(id)
12 |   })
13 | 
14 |   it('should reject invalid entity ids', () => {
15 |     deleteEntity.bind(null, { id: 'bla' }).should.throw()
16 |     deleteEntity.bind(null, { id: 'Item:Q1' }).should.throw()
17 |     deleteEntity.bind(null, { id: 'Property:P1' }).should.throw()
18 |   })
19 | })
20 | 


--------------------------------------------------------------------------------
/tests/unit/entity/merge.ts:
--------------------------------------------------------------------------------
 1 | import 'should'
 2 | import { mergeEntity } from '#lib/entity/merge'
 3 | 
 4 | describe('entity merge', () => {
 5 |   describe('items', () => {
 6 |     it('should set the action to wbmergeitems', () => {
 7 |       mergeEntity({ from: 'Q1', to: 'Q2' }).action.should.equal('wbmergeitems')
 8 |     })
 9 | 
10 |     it('should reject invalid item ids', () => {
11 |       mergeEntity.bind(null, { from: 'Q1', to: 'P' }).should.throw()
12 |       mergeEntity.bind(null, { from: '1', to: 'Q2' }).should.throw()
13 |     })
14 |   })
15 | 
16 |   describe('properties', () => {
17 |     it('should reject properties', () => {
18 |       mergeEntity.bind(null, { from: 'P1', to: 'P2' }).should.throw()
19 |     })
20 |   })
21 | })
22 | 


--------------------------------------------------------------------------------
/src/lib/claim/find_snak.ts:
--------------------------------------------------------------------------------
 1 | import { newError } from '../error.js'
 2 | import { isMatchingSnak, type SearchedValue } from './is_matching_snak.js'
 3 | import type { Claim, PropertyId, SnakBase } from 'wikibase-sdk'
 4 | 
 5 | export function findSnak  (property: PropertyId, propSnaks: T[], searchedValue: SearchedValue): T | void {
 6 |   if (!propSnaks) return
 7 | 
 8 |   const matchingSnaks = propSnaks.filter(snak => isMatchingSnak(snak, searchedValue))
 9 | 
10 |   if (matchingSnaks.length === 0) return
11 |   if (matchingSnaks.length === 1) return matchingSnaks[0]
12 | 
13 |   const context = { property, propSnaks, searchedValue }
14 |   throw newError('snak not found: too many matching snaks', 400, context)
15 | }
16 | 


--------------------------------------------------------------------------------
/src/lib/claim/format_claim_value.ts:
--------------------------------------------------------------------------------
 1 | import { parseQuantity } from './quantity.js'
 2 | import { hasSpecialSnaktype } from './special_snaktype.js'
 3 | import type { AbsoluteUrl } from '../types/common.js'
 4 | import type { Datatype, QuantitySnakDataValue, SimplifiedClaim } from 'wikibase-sdk'
 5 | 
 6 | export function formatClaimValue (datatype: Datatype, value: SimplifiedClaim, instance: AbsoluteUrl) {
 7 |   if (hasSpecialSnaktype(value)) return value
 8 |   // Try to recover data passed in a different type than the one expected:
 9 |   // - Quantities should be of type number
10 |   if (datatype === 'quantity') {
11 |     return parseQuantity(value as (number | string | QuantitySnakDataValue['value']), instance)
12 |   }
13 |   return value
14 | }
15 | 


--------------------------------------------------------------------------------
/src/lib/request/get_token.ts:
--------------------------------------------------------------------------------
 1 | import { getFinalTokenFactory, type ParsedTokenInfo } from './get_final_token.js'
 2 | import login from './login.js'
 3 | import type { SerializedConfig } from '../types/config.js'
 4 | 
 5 | export function getTokenFactory (config: SerializedConfig): () => Promise {
 6 |   const getFinalToken = getFinalTokenFactory(config)
 7 |   const { credentials } = config
 8 |   if (('oauth' in credentials && credentials.oauth) || ('browserSession' in credentials && credentials.browserSession)) {
 9 |     // @ts-expect-error
10 |     return getFinalToken
11 |   } else {
12 |     return async () => {
13 |       const loginCookies = await login(config)
14 |       return getFinalToken(loginCookies)
15 |     }
16 |   }
17 | }
18 | 


--------------------------------------------------------------------------------
/tests/integration/alias/set.ts:
--------------------------------------------------------------------------------
 1 | import 'should'
 2 | import config from 'config'
 3 | import { getSandboxItemId } from '#tests/integration/utils/sandbox_entities'
 4 | import { waitForInstance } from '#tests/integration/utils/wait_for_instance'
 5 | import { randomString } from '#tests/unit/utils'
 6 | import WBEdit from '#root'
 7 | 
 8 | const wbEdit = WBEdit(config)
 9 | const language = 'fr'
10 | 
11 | describe('alias set', function () {
12 |   this.timeout(20 * 1000)
13 |   before('wait for instance', waitForInstance)
14 | 
15 |   it('should set an alias', async () => {
16 |     const id = await getSandboxItemId()
17 |     const value = randomString()
18 |     const res = await wbEdit.alias.set({ id, language, value })
19 |     res.success.should.equal(1)
20 |   })
21 | })
22 | 


--------------------------------------------------------------------------------
/tests/integration/alias/remove.ts:
--------------------------------------------------------------------------------
 1 | import 'should'
 2 | import config from 'config'
 3 | import { getSandboxItemId } from '#tests/integration/utils/sandbox_entities'
 4 | import { waitForInstance } from '#tests/integration/utils/wait_for_instance'
 5 | import { randomString } from '#tests/unit/utils'
 6 | import WBEdit from '#root'
 7 | 
 8 | const wbEdit = WBEdit(config)
 9 | const language = 'fr'
10 | 
11 | describe('alias remove', function () {
12 |   this.timeout(20 * 1000)
13 |   before('wait for instance', waitForInstance)
14 | 
15 |   it('should remove an alias', async () => {
16 |     const id = await getSandboxItemId()
17 |     const value = randomString()
18 |     const res = await wbEdit.alias.remove({ id, language, value })
19 |     res.success.should.equal(1)
20 |   })
21 | })
22 | 


--------------------------------------------------------------------------------
/browser-demo/main.js:
--------------------------------------------------------------------------------
 1 | import WbEdit from './wikibase-edit.js'
 2 | 
 3 | console.log('hillo')
 4 | 
 5 | const wbEdit = WbEdit({
 6 |   instance: 'https://www.wikidata.org',
 7 |   credentials: {
 8 |     browserSession: true,
 9 |   },
10 | })
11 | 
12 | document.addEventListener('click', async e => {
13 |   if (e.target.tagName === 'BUTTON' && document.getElementById('text').value) {
14 |     try {
15 |       const res = await wbEdit.alias.add({
16 |         id: 'Q112795079',
17 |         language: 'fr',
18 |         value: document.getElementById('text').value,
19 |       })
20 |       document.getElementById('response').innerText = JSON.stringify(res, null, 2)
21 |     } catch (err) {
22 |       document.getElementById('response').innerText = err.stack || err.toString()
23 |     }
24 |   }
25 | })
26 | 


--------------------------------------------------------------------------------
/tests/unit/request.ts:
--------------------------------------------------------------------------------
 1 | import 'should'
 2 | import nock from 'nock'
 3 | import { request } from '#lib/request/request'
 4 | import { shouldNotBeCalled, rethrowShouldNotBeCalledErrors } from '../integration/utils/utils.js'
 5 | 
 6 | describe('request', () => {
 7 |   beforeEach(() => {
 8 |     nock('https://example.org')
 9 |     .get('/')
10 |     .reply(200, '')
11 |   })
12 | 
13 |   it('should throw a proper error', async () => {
14 |     try {
15 |       await request('get', { url: 'https://example.org', autoRetry: false }).then(shouldNotBeCalled)
16 |     } catch (err) {
17 |       rethrowShouldNotBeCalledErrors(err)
18 |       err.message.should.startWith('Could not parse response: ')
19 |       err.name.should.equal('wrong response format')
20 |     }
21 |   })
22 | })
23 | 


--------------------------------------------------------------------------------
/config/default.cjs:
--------------------------------------------------------------------------------
 1 | // Log full objects
 2 | require('util').inspect.defaultOptions.depth = null
 3 | 
 4 | module.exports = {
 5 |   // initConfig
 6 |   instance: 'http://localhost:8181',
 7 |   statementsKey: 'claims',
 8 |   credentials: {
 9 |     oauth: {
10 |       consumer_key: 'some-consumer-token',
11 |       consumer_secret: 'some-secret-token',
12 |       token: 'some-user-token',
13 |       token_secret: 'some-secret-token'
14 |     }
15 |   },
16 |   // Used for testing that both means of login work
17 |   // but can be inverted or disabled if you can't get owner-only oauth tokens
18 |   credentialsAlt: {
19 |     username: 'some-username',
20 |     password: 'some-password'
21 |   },
22 |   secondUserCredentials: {
23 |     username: 'another-username',
24 |     password: 'another-password'
25 |   }
26 | }
27 | 


--------------------------------------------------------------------------------
/tests/unit/qualifier/remove.ts:
--------------------------------------------------------------------------------
 1 | import 'should'
 2 | import { removeQualifier } from '#lib/qualifier/remove'
 3 | import { guid, hash } from '#tests/unit/utils'
 4 | 
 5 | describe('qualifier remove', () => {
 6 |   it('should set the action to wbremoveclaims', () => {
 7 |     removeQualifier({ guid, hash }).action.should.equal('wbremovequalifiers')
 8 |   })
 9 | 
10 |   it('should return formatted data for one qualifier', () => {
11 |     removeQualifier({ guid, hash }).data.should.deepEqual({
12 |       claim: guid,
13 |       qualifiers: hash,
14 |     })
15 |   })
16 | 
17 |   it('should return formatted data for several qualifiers', () => {
18 |     removeQualifier({ guid, hash: [ hash, hash ] }).data.should.deepEqual({
19 |       claim: guid,
20 |       qualifiers: `${hash}|${hash}`,
21 |     })
22 |   })
23 | })
24 | 


--------------------------------------------------------------------------------
/src/lib/reference/remove.ts:
--------------------------------------------------------------------------------
 1 | import { isArray } from '../utils.js'
 2 | import { validateGuid, validateHash } from '../validate.js'
 3 | import type { Guid, Hash } from 'wikibase-sdk'
 4 | 
 5 | export interface RemoveReferenceParams {
 6 |   guid: Guid
 7 |   hash: Hash | Hash[]
 8 | }
 9 | 
10 | export function removeReference (params) {
11 |   let { guid, hash } = params
12 |   validateGuid(guid)
13 | 
14 |   if (isArray(hash)) {
15 |     hash.forEach(validateHash)
16 |     hash = hash.join('|')
17 |   } else {
18 |     validateHash(hash)
19 |   }
20 | 
21 |   return {
22 |     action: 'wbremovereferences',
23 |     data: {
24 |       statement: guid,
25 |       references: hash,
26 |     },
27 |   }
28 | }
29 | 
30 | export interface RemoveReferenceResponse {
31 |   pageinfo: { lastrevid: number }
32 |   success: 1
33 | }
34 | 


--------------------------------------------------------------------------------
/tests/unit/reference/remove.ts:
--------------------------------------------------------------------------------
 1 | import 'should'
 2 | import { removeReference } from '#lib/reference/remove'
 3 | import { guid, hash } from '#tests/unit/utils'
 4 | 
 5 | describe('reference remove', () => {
 6 |   it('should set the action to wbremovereferences', () => {
 7 |     removeReference({ guid, hash }).action.should.equal('wbremovereferences')
 8 |   })
 9 | 
10 |   it('should return formatted data for one reference', () => {
11 |     removeReference({ guid, hash }).data.should.deepEqual({
12 |       statement: guid,
13 |       references: hash,
14 |     })
15 |   })
16 | 
17 |   it('should return formatted data for several references', () => {
18 |     removeReference({ guid, hash: [ hash, hash ] }).data.should.deepEqual({
19 |       statement: guid,
20 |       references: `${hash}|${hash}`,
21 |     })
22 |   })
23 | })
24 | 


--------------------------------------------------------------------------------
/src/lib/parse_instance.ts:
--------------------------------------------------------------------------------
 1 | import { getStatementsKey } from 'wikibase-sdk'
 2 | import { newError } from './error.js'
 3 | 
 4 | export default config => {
 5 |   if (!config) throw newError('missing config object')
 6 | 
 7 |   let { instance, wikibaseInstance } = config
 8 |   // Accept config.wikibaseInstance for legacy support
 9 |   instance = instance || wikibaseInstance
10 | 
11 |   if (!instance) throw newError('missing config parameter: instance', { config })
12 | 
13 |   let { wgScriptPath = 'w' } = config
14 | 
15 |   wgScriptPath = wgScriptPath.replace(/^\//, '')
16 | 
17 |   config.instance = instance
18 |     .replace(/\/$/, '')
19 |     .replace(`/${wgScriptPath}/api.php`, '')
20 | 
21 |   config.instanceApiEndpoint = `${config.instance}/${wgScriptPath}/api.php`
22 |   config.statementsKey = getStatementsKey(instance)
23 | }
24 | 


--------------------------------------------------------------------------------
/src/lib/qualifier/remove.ts:
--------------------------------------------------------------------------------
 1 | import { isArray } from '../utils.js'
 2 | import { validateGuid, validateHash } from '../validate.js'
 3 | import type { Guid, Hash } from 'wikibase-sdk'
 4 | 
 5 | export interface RemoveQualifierParams {
 6 |   guid: Guid
 7 |   hash?: Hash | Hash[]
 8 | }
 9 | 
10 | export function removeQualifier (params: RemoveQualifierParams) {
11 |   let { guid, hash } = params
12 |   validateGuid(guid)
13 | 
14 |   if (isArray(hash)) {
15 |     hash.forEach(validateHash)
16 |     hash = hash.join('|')
17 |   } else {
18 |     validateHash(hash)
19 |   }
20 | 
21 |   return {
22 |     action: 'wbremovequalifiers',
23 |     data: {
24 |       claim: guid,
25 |       qualifiers: hash,
26 |     },
27 |   }
28 | }
29 | 
30 | export interface RemoveQualifierResponse {
31 |   pageinfo: { lastrevid: number }
32 |   success: 1
33 | }
34 | 


--------------------------------------------------------------------------------
/src/lib/debug.ts:
--------------------------------------------------------------------------------
 1 | const { DEBUG = '' } = (globalThis.process?.env || {})
 2 | const namespace = 'wikibase-edit'
 3 | export const debugMode = DEBUG === '*' || (DEBUG.length > 0 && namespace.startsWith(DEBUG.replace(/\*$/, ''))) || DEBUG.startsWith(`${namespace}:`)
 4 | const debugSection = DEBUG.split(':').slice(1).join(':').replace(/\*$/, '')
 5 | 
 6 | export function debug (section, ...args) {
 7 |   if (!debugMode) return
 8 |   if (debugSection && !section.startsWith(debugSection)) return
 9 |   const timestamp = new Date().toISOString()
10 |   console.error(`\x1B[90m${timestamp}\x1B[39m \x1B[35m${namespace}:${section}\x1B[39m`, ...args.map(stringify))
11 | }
12 | 
13 | function stringify (data) {
14 |   if (typeof data === 'string') return data
15 |   const str = JSON.stringify(data)
16 |   if (str === '{}' || str === '[]') return ''
17 |   else return str
18 | }
19 | 


--------------------------------------------------------------------------------
/tests/unit/claim/remove.ts:
--------------------------------------------------------------------------------
 1 | import 'should'
 2 | import { removeClaim } from '#lib/claim/remove'
 3 | import { guid, guid2 } from '#tests/unit/utils'
 4 | 
 5 | describe('claim remove', () => {
 6 |   it('should set the action to wbremoveclaims', async () => {
 7 |     // @ts-expect-error
 8 |     const { action } = await removeClaim({ guid })
 9 |     action.should.equal('wbremoveclaims')
10 |   })
11 | 
12 |   it('should return formatted data for one claim', async () => {
13 |     // @ts-expect-error
14 |     const { data } = await removeClaim({ guid })
15 |     data.claim.should.equal(guid)
16 |   })
17 | 
18 |   it('should return formatted data for several claims', async () => {
19 |     const guids = [ guid, guid2 ]
20 |     // @ts-expect-error
21 |     const { data } = await removeClaim({ guid: guids })
22 |     data.claim.should.equal(guids.join('|'))
23 |   })
24 | })
25 | 


--------------------------------------------------------------------------------
/tests/integration/claim/set.ts:
--------------------------------------------------------------------------------
 1 | import 'should'
 2 | import config from 'config'
 3 | import { getSandboxClaim } from '#tests/integration/utils/sandbox_entities'
 4 | import { waitForInstance } from '#tests/integration/utils/wait_for_instance'
 5 | import { assert, randomString } from '#tests/unit/utils'
 6 | import WBEdit from '#root'
 7 | 
 8 | const wbEdit = WBEdit(config)
 9 | 
10 | describe('claim set', function () {
11 |   this.timeout(20 * 1000)
12 |   before('wait for instance', waitForInstance)
13 | 
14 |   it('should set a claim', async () => {
15 |     const claim = await getSandboxClaim()
16 |     const { property } = claim.mainsnak
17 |     const value = randomString()
18 |     const res = await wbEdit.claim.set({ guid: claim.id, property, value })
19 |     assert('datavalue' in res.claim.mainsnak)
20 |     res.claim.mainsnak.datavalue.value.should.equal(value)
21 |   })
22 | })
23 | 


--------------------------------------------------------------------------------
/src/lib/entity/merge.ts:
--------------------------------------------------------------------------------
 1 | import { isEntityId, isItemId, type EntityId } from 'wikibase-sdk'
 2 | import { newError } from '../error.js'
 3 | 
 4 | export interface MergeEntityParams {
 5 |   from: EntityId
 6 |   to: EntityId
 7 | }
 8 | 
 9 | export function mergeEntity (params) {
10 |   const { from, to } = params
11 | 
12 |   if (!isEntityId(from)) throw newError('invalid "from" entity id', params)
13 |   if (!isEntityId(to)) throw newError('invalid "to" entity id', params)
14 | 
15 |   if (!isItemId(from)) throw newError('unsupported entity type', params)
16 |   if (!isItemId(to)) throw newError('unsupported entity type', params)
17 | 
18 |   return {
19 |     action: 'wbmergeitems',
20 |     data: { fromid: from, toid: to },
21 |   }
22 | }
23 | 
24 | export interface MergeEntityResponse {
25 |   success: 1
26 |   redirected: 1
27 |   from: { id: EntityId }
28 |   to: { id: EntityId }
29 | }
30 | 


--------------------------------------------------------------------------------
/src/lib/issues.ts:
--------------------------------------------------------------------------------
 1 | import { issues } from '../assets/metadata.js'
 2 | import type { ErrorContext } from './error.js'
 3 | 
 4 | const newIssueUrl = `${issues}/new`
 5 | 
 6 | interface IssueParams {
 7 |   template: string
 8 |   title?: string
 9 |   body?: string
10 |   context: ErrorContext
11 | }
12 | 
13 | function newIssue ({ template, title = ' ', body = ' ', context }: IssueParams) {
14 |   title = encodeURIComponent(title)
15 |   if (context != null) {
16 |     body += 'Context:\n```json\n' + JSON.stringify(context, null, 2) + '\n```\n'
17 |   }
18 |   body = encodeURIComponent(body)
19 |   return `Please open an issue at ${newIssueUrl}?template=${template}&title=${title}&body=${body}`
20 | }
21 | 
22 | export function inviteToOpenAFeatureRequest ({ title, body, context }: Omit) {
23 |   return newIssue({
24 |     template: 'feature_request.md',
25 |     title,
26 |     body,
27 |     context,
28 |   })
29 | }
30 | 


--------------------------------------------------------------------------------
/tests/unit/claim/set.ts:
--------------------------------------------------------------------------------
 1 | import 'should'
 2 | import { setClaim } from '#lib/claim/set'
 3 | import { guid, sandboxStringProp as property, properties, someInstance } from '#tests/unit/utils'
 4 | 
 5 | describe('claim set', () => {
 6 |   it('should set the action to wbsetclaim', () => {
 7 |     const { action } = setClaim({
 8 |       guid,
 9 |       property,
10 |       value: 'foo',
11 |     }, properties, someInstance)
12 |     action.should.equal('wbsetclaim')
13 |   })
14 | 
15 |   it('should return formatted claim data for a string', () => {
16 |     const { data } = setClaim({
17 |       guid,
18 |       property,
19 |       value: 'foo',
20 |     }, properties, someInstance)
21 |     JSON.parse(data.claim).should.deepEqual({
22 |       id: guid,
23 |       type: 'statement',
24 |       mainsnak: {
25 |         snaktype: 'value',
26 |         property,
27 |         datavalue: {
28 |           value: 'foo',
29 |           type: 'string',
30 |         },
31 |       },
32 |     })
33 |   })
34 | })
35 | 


--------------------------------------------------------------------------------
/src/lib/alias/action.ts:
--------------------------------------------------------------------------------
 1 | import { validateAliases, validateEntityId, validateLanguage } from '#lib/validate'
 2 | import type { Aliases, EntityId, EntityType, WikimediaLanguageCode } from 'wikibase-sdk'
 3 | 
 4 | export interface AliasActionParams {
 5 |   id: EntityId
 6 |   language: WikimediaLanguageCode
 7 |   value: string | string[]
 8 | }
 9 | 
10 | export function actionFactory (action: 'add' | 'remove' | 'set') {
11 |   return function (params: AliasActionParams) {
12 |     const { id, language, value } = params
13 | 
14 |     validateEntityId(id)
15 |     validateLanguage(language)
16 |     validateAliases(value)
17 | 
18 |     const data = { id, language }
19 | 
20 |     data[action] = value instanceof Array ? value.join('|') : value
21 | 
22 |     return { action: 'wbsetaliases', data }
23 |   }
24 | }
25 | 
26 | export interface AliasActionResponse {
27 |   entity: {
28 |     aliases: Aliases
29 |     id: EntityId
30 |     type: EntityType
31 |     lastrevid: number
32 |     nochange?: ''
33 |   }
34 |   success: 1
35 | }
36 | 


--------------------------------------------------------------------------------
/src/lib/request/get_auth_data.ts:
--------------------------------------------------------------------------------
 1 | import { newError } from '../error.js'
 2 | import { getTokenFactory } from './get_token.js'
 3 | import type { ParsedTokenInfo } from './get_final_token.js'
 4 | import type { SerializedConfig } from '../types/config.js'
 5 | 
 6 | export function getAuthDataFactory (config: SerializedConfig) {
 7 |   const getToken = getTokenFactory(config)
 8 | 
 9 |   let tokenPromise
10 |   let lastTokenRefresh = 0
11 | 
12 |   function refreshToken (refresh?: boolean) {
13 |     const now = Date.now()
14 |     if (!refresh && now - lastTokenRefresh < 5000) {
15 |       throw newError("last token refreshed less than 10 seconds ago: won't retry", { config })
16 |     }
17 |     lastTokenRefresh = now
18 |     tokenPromise = getToken()
19 |     return tokenPromise as Promise
20 |   }
21 | 
22 |   return function getAuthData (params?: { refresh?: boolean }): Promise {
23 |     if (params?.refresh) return refreshToken(true)
24 |     else return tokenPromise || refreshToken()
25 |   }
26 | }
27 | 


--------------------------------------------------------------------------------
/src/lib/claim/parse_calendar.ts:
--------------------------------------------------------------------------------
 1 | import type { AbsoluteUrl } from '#lib/types/common'
 2 | 
 3 | const wdUrlBase = 'http://www.wikidata.org/entity/'
 4 | const gregorian = `${wdUrlBase}Q1985727`
 5 | const julian = `${wdUrlBase}Q1985786`
 6 | const calendarAliases = {
 7 |   julian,
 8 |   gregorian,
 9 |   Q1985727: gregorian,
10 |   Q1985786: julian,
11 | } as const
12 | 
13 | export type CalendarAlias = keyof typeof calendarAliases | AbsoluteUrl
14 | 
15 | export function parseCalendar (calendar: string, wikidataTimeString: string) {
16 |   if (!calendar) return getDefaultCalendar(wikidataTimeString)
17 |   const normalizedCalendar = calendar.replace(wdUrlBase, '')
18 |   return calendarAliases[normalizedCalendar]
19 | }
20 | 
21 | function getDefaultCalendar (wikidataTimeString: string) {
22 |   if (wikidataTimeString.startsWith('-')) return julian
23 |   const [ year ] = wikidataTimeString
24 |     .replace('+', '')
25 |     .split('-')
26 |     .map(num => parseInt(num))
27 | 
28 |   if (year > 1582) return gregorian
29 |   else return julian
30 | }
31 | 


--------------------------------------------------------------------------------
/src/lib/properties/datatypes_to_builder_datatypes.ts:
--------------------------------------------------------------------------------
 1 | import type { Datatype } from 'wikibase-sdk'
 2 | 
 3 | const buildersByNormalizedDatatypes = {
 4 |   commonsmedia: 'string',
 5 |   edtf: 'string',
 6 |   externalid: 'string',
 7 |   globecoordinate: 'globecoordinate',
 8 |   geoshape: 'string',
 9 |   // datatype from https://github.com/ProfessionalWiki/WikibaseLocalMedia
10 |   localmedia: 'string',
11 |   math: 'string',
12 |   monolingualtext: 'monolingualtext',
13 |   musicalnotation: 'string',
14 |   quantity: 'quantity',
15 |   string: 'string',
16 |   tabulardata: 'string',
17 |   time: 'time',
18 |   url: 'string',
19 |   wikibaseform: 'entity',
20 |   wikibaseitem: 'entity',
21 |   wikibaselexeme: 'entity',
22 |   wikibaseproperty: 'entity',
23 |   wikibasesense: 'entity',
24 | } as const
25 | 
26 | const allDashesPattern = /-/g
27 | 
28 | export function normalizeDatatype (datatype: string) {
29 |   const normalizedDatype = datatype.toLowerCase().replace(allDashesPattern, '')
30 |   return buildersByNormalizedDatatypes[normalizedDatype] as Datatype
31 | }
32 | 


--------------------------------------------------------------------------------
/src/lib/claim/helpers.ts:
--------------------------------------------------------------------------------
 1 | import { flatten, values } from 'lodash-es'
 2 | import { simplifyClaim, type Claim, type Claims, type Guid, type Statement, type Statements } from 'wikibase-sdk'
 3 | import { newError } from '../error.js'
 4 | import type { CustomSimplifiedEditableClaim } from '../types/edit_entity.js'
 5 | 
 6 | const simplifyOptions = {
 7 |   keepIds: true,
 8 |   keepSnaktypes: true,
 9 |   keepQualifiers: true,
10 |   keepReferences: true,
11 |   keepRanks: true,
12 |   keepRichValues: true,
13 | }
14 | 
15 | export function findClaimByGuid (claims: Claims | Statements, guid: Guid): Claim | Statement {
16 |   for (const claim of flatten(values(claims))) {
17 |     if (claim.id.toLowerCase() === guid.toLowerCase()) return claim
18 |   }
19 |   throw newError('claim not found', 400, { claims, guid })
20 | }
21 | 
22 | export const isGuidClaim = (guid: Guid) => (claim: Claim) => claim.id === guid
23 | 
24 | export function simplifyClaimForEdit (claim: Claim) {
25 |   return simplifyClaim(claim, simplifyOptions) as Required
26 | }
27 | 


--------------------------------------------------------------------------------
/tests/integration/tags.ts:
--------------------------------------------------------------------------------
 1 | import 'should'
 2 | import config from 'config'
 3 | import WBEdit from '#root'
 4 | import { randomString } from '../unit/utils.js'
 5 | import { getLastRevision } from './utils/utils.js'
 6 | 
 7 | const { wdCredentials: credentials } = config
 8 | const sandboxEntityId = 'Q4115189'
 9 | const instance = 'https://www.wikidata.org'
10 | 
11 | describe('tags', function () {
12 |   this.timeout(20 * 1000)
13 | 
14 |   // Tests disable by default as they need wikidata credentials to be set
15 |   describe('on wikidata.org', () => {
16 |     xit('should add a wikibase-edit tag by default', async () => {
17 |       if (credentials == null) throw new Error('wikidata credentials required for this test (config.wdCredentials)')
18 |       const wbEdit = WBEdit({ instance, credentials })
19 |       const res = await wbEdit.alias.add({ id: sandboxEntityId, language: 'fr', value: randomString() })
20 |       res.success.should.equal(1)
21 |       const revision = await getLastRevision(sandboxEntityId, instance)
22 |       revision.tags.should.deepEqual([ 'WikibaseJS-edit' ])
23 |     })
24 |   })
25 | })
26 | 


--------------------------------------------------------------------------------
/src/lib/label_or_description/set.ts:
--------------------------------------------------------------------------------
 1 | import { newError } from '../error.js'
 2 | import { validateEntityId, validateLanguage } from '../validate.js'
 3 | import type { BaseRevId } from '../types/common.js'
 4 | import type { EntityId, EntityWithLabels, WikimediaLanguageCode } from 'wikibase-sdk'
 5 | 
 6 | export interface TermActionParams {
 7 |   id: EntityId
 8 |   language: WikimediaLanguageCode
 9 |   value: string
10 |   summary?: string
11 |   baserevid?: BaseRevId
12 | }
13 | 
14 | export function setLabelOrDescriptionFactory (name: string) {
15 |   return function setLabelOrDescription (params: TermActionParams) {
16 |     const { id, language } = params
17 |     let { value } = params
18 |     const action = `wbset${name}`
19 | 
20 |     validateEntityId(id)
21 |     validateLanguage(language)
22 |     if (value === undefined) throw newError(`missing ${name}`, params)
23 |     if (value === null) value = ''
24 | 
25 |     return {
26 |       action,
27 |       data: { id, language, value },
28 |     }
29 |   }
30 | }
31 | 
32 | export interface TermActionResponse {
33 |   entity: EntityWithLabels
34 |   success: 1
35 | }
36 | 


--------------------------------------------------------------------------------
/tests/unit/label/set.ts:
--------------------------------------------------------------------------------
 1 | import 'should'
 2 | import { setLabel } from '#lib/label/set'
 3 | import { randomString, someEntityId } from '#tests/unit/utils'
 4 | 
 5 | const language = 'fr'
 6 | 
 7 | describe('label', () => {
 8 |   it('should throw if not passed an entity', () => {
 9 |   // @ts-expect-error
10 |     setLabel.bind(null, {}).should.throw('invalid entity id')
11 |   })
12 | 
13 |   it('should throw if not passed a language', () => {
14 |   // @ts-expect-error
15 |     setLabel.bind(null, { id: someEntityId }).should.throw('invalid language')
16 |   })
17 | 
18 |   it('should throw if not passed a label', () => {
19 |   // @ts-expect-error
20 |     setLabel.bind(null, { id: someEntityId, language }).should.throw('missing label')
21 |   })
22 | 
23 |   it('should return an action and data', () => {
24 |     const value = `Bac à Sable (${randomString()})`
25 |     const { action, data } = setLabel({ id: someEntityId, language, value })
26 |     action.should.equal('wbsetlabel')
27 |     data.id.should.equal(someEntityId)
28 |     data.language.should.equal(language)
29 |     data.value.should.equal(value)
30 |   })
31 | })
32 | 


--------------------------------------------------------------------------------
/tests/integration/alias/add.ts:
--------------------------------------------------------------------------------
 1 | import 'should'
 2 | import config from 'config'
 3 | import { getSandboxItemId } from '#tests/integration/utils/sandbox_entities'
 4 | import { waitForInstance } from '#tests/integration/utils/wait_for_instance'
 5 | import { randomString } from '#tests/unit/utils'
 6 | import WBEdit from '#root'
 7 | 
 8 | const wbEdit = WBEdit(config)
 9 | const language = 'fr'
10 | 
11 | describe('alias add', function () {
12 |   this.timeout(20 * 1000)
13 |   before('wait for instance', waitForInstance)
14 | 
15 |   it('should add an alias', async () => {
16 |     const id = await getSandboxItemId()
17 |     const value = randomString()
18 |     const res = await wbEdit.alias.add({ id, language, value })
19 |     res.success.should.equal(1)
20 |   })
21 | 
22 |   it('should add several aliases', async () => {
23 |     const id = await getSandboxItemId()
24 |     const aliases = [
25 |       randomString(),
26 |       randomString(),
27 |       randomString(),
28 |       randomString(),
29 |     ]
30 |     const res = await wbEdit.alias.add({ id, language, value: aliases })
31 |     res.success.should.equal(1)
32 |   })
33 | })
34 | 


--------------------------------------------------------------------------------
/src/lib/entity/create.ts:
--------------------------------------------------------------------------------
 1 | import { newError } from '../error.js'
 2 | import { editEntity, type EditEntityParams } from './edit.js'
 3 | import type { PropertiesDatatypes } from '../properties/fetch_properties_datatypes.js'
 4 | import type { AbsoluteUrl } from '../types/common.js'
 5 | import type { SerializedConfig } from '../types/config.js'
 6 | import type { Item, Lexeme, Property } from 'wikibase-sdk'
 7 | 
 8 | export type CreateEntityParams = Omit
 9 | 
10 | export async function createEntity (params: CreateEntityParams, properties: PropertiesDatatypes, instance: AbsoluteUrl, config: SerializedConfig) {
11 |   if ('id' in params && params.id) {
12 |     throw newError("a new entity can't already have an id", { id: params.id })
13 |   }
14 |   return editEntity({ create: true, ...params }, properties, instance, config)
15 | }
16 | 
17 | export interface CreateEntityResponse {
18 |   // Leaving MediaInfo aside to not have to deal with claims/statements inconsistencies
19 |   // (see https://phabricator.wikimedia.org/T149410) but it should still work for MediaInfo
20 |   entity: Item | Property | Lexeme
21 |   success: 1
22 | }
23 | 


--------------------------------------------------------------------------------
/tests/unit/utils.ts:
--------------------------------------------------------------------------------
 1 | import type { PropertiesDatatypes } from '#lib/properties/fetch_properties_datatypes'
 2 | import type { Guid, Hash, ItemId, PropertyId } from 'wikibase-sdk'
 3 | 
 4 | export const randomString = () => Math.random().toString(36).slice(2, 10)
 5 | export const randomNumber = (length = 5) => Math.trunc(Math.random() * Math.pow(10, length))
 6 | 
 7 | export const someEntityId: ItemId = 'Q1'
 8 | export const guid: Guid = 'Q1$3A8AA34F-0DEF-4803-AA8E-39D9EFD4DEAF'
 9 | export const guid2: Guid = 'Q1$3A8AA34F-0DAB-4803-AA8E-39D9EFD4DEAF'
10 | export const hash: Hash = '3d22f4dffba1ac6f66f521ea6bea924e46df4129'
11 | export const sandboxStringProp: PropertyId = 'P1'
12 | 
13 | export const properties: PropertiesDatatypes = {
14 |   P1: 'string',
15 |   P2: 'wikibase-item',
16 |   P3: 'wikibase-property',
17 |   P4: 'time',
18 |   P5: 'external-id',
19 |   P6: 'globe-coordinate',
20 |   P7: 'url',
21 |   P8: 'quantity',
22 |   P9: 'monolingualtext',
23 | }
24 | 
25 | export function assert (condition: boolean): asserts condition {
26 |   if (!condition) throw new Error('assertion failed')
27 | }
28 | 
29 | export const someInstance = 'http://example.org'
30 | 


--------------------------------------------------------------------------------
/src/lib/request/parse_session_cookies.ts:
--------------------------------------------------------------------------------
 1 | // - Formatting cookies to match servers expecations (dropping 'HttpOnly')
 2 | // - Removing all the non-required cookies (reducing noise for debugging)
 3 | 
 4 | // The final cookie should look like:
 5 | // docker-wikibase: 'wikibase_session=dqnrrb70jaci8cgdl2s0am0oqtr02cp1; wikibaseUserID=1'
 6 | // wikimedia servers: 'testwikidatawikiSession=cd7h39ik5lf4leh1dugts9vkta4t2e0d; testwikidatawikiUserID=1; testwikidatawikiUserName=Someusername; centralauth_Token=f355932ed4da146b29f7887179af746b; centralauth_Session=950509520bb4bd3dbc7d595c4b06141c;'
 7 | 
 8 | export default resCookies => {
 9 |   return resCookies
10 |   .split(parameterSeparator)
11 |   .map(formatCookieParameter)
12 |   .filter(parameter => isRequiredCookieKey(parameter.split('=')[0]))
13 |   .join(parameterSeparator)
14 |   .trim()
15 | }
16 | 
17 | const parameterSeparator = '; '
18 | 
19 | const isRequiredCookieKey = key => key.match(/(User\w*|[sS]ession|[tT]oken)$/)
20 | 
21 | const formatCookieParameter = parameter => {
22 |   return parameter
23 |   // Somehow, it fails to get cookies if the answer parameter is prefixed with HttpOnly
24 |   .replace('HttpOnly,', '')
25 |   .trim()
26 | }
27 | 


--------------------------------------------------------------------------------
/tests/unit/description/set.ts:
--------------------------------------------------------------------------------
 1 | import 'should'
 2 | import { setDescription } from '#lib/description/set'
 3 | import { randomString, someEntityId } from '#tests/unit/utils'
 4 | 
 5 | const language = 'fr'
 6 | 
 7 | describe('description', () => {
 8 |   it('should throw if not passed an entity', () => {
 9 |     // @ts-expect-error
10 |     setDescription.bind(null, {}).should.throw('invalid entity id')
11 |   })
12 | 
13 |   it('should throw if not passed a language', () => {
14 |     // @ts-expect-error
15 |     setDescription.bind(null, { id: someEntityId }).should.throw('invalid language')
16 |   })
17 | 
18 |   it('should throw if not passed a description', () => {
19 |     // @ts-expect-error
20 |     setDescription.bind(null, { id: someEntityId, language })
21 |     .should.throw('missing description')
22 |   })
23 | 
24 |   it('should return an action and data', () => {
25 |     const value = `Bac à Sable (${randomString()})`
26 |     const { action, data } = setDescription({ id: someEntityId, language, value })
27 |     action.should.equal('wbsetdescription')
28 |     data.id.should.equal(someEntityId)
29 |     data.language.should.equal(language)
30 |     data.value.should.equal(value)
31 |   })
32 | })
33 | 


--------------------------------------------------------------------------------
/src/lib/sitelink/set.ts:
--------------------------------------------------------------------------------
 1 | // Doc https://www.wikidata.org/w/api.php?action=help&modules=wbsetsitelink
 2 | import { formatBadges } from '../entity/format.js'
 3 | import { validateEntityId, validateSite, validateSiteTitle } from '../validate.js'
 4 | import type { EntityWithSitelinks, SitelinkBadges } from 'wikibase-sdk'
 5 | 
 6 | export interface SetSitelinkParams {
 7 |   id: EntityWithSitelinks['id']
 8 |   site: string
 9 |   title: string
10 |   badges?: SitelinkBadges | string
11 | }
12 | 
13 | export function setSitelink ({ id, site, title, badges }: SetSitelinkParams) {
14 |   validateEntityId(id)
15 |   validateSite(site)
16 |   validateSiteTitle(title)
17 | 
18 |   const params = {
19 |     action: 'wbsetsitelink',
20 |     data: {
21 |       id,
22 |       linksite: site,
23 |       linktitle: title,
24 |     },
25 |   }
26 | 
27 |   // Allow to pass null to delete a sitelink
28 |   if (title === null) {
29 |     delete params.data.linktitle
30 |   }
31 | 
32 |   if (badges != null) {
33 |     // @ts-expect-error
34 |     params.data.badges = formatBadges(badges).join('|')
35 |   }
36 | 
37 |   return params
38 | }
39 | 
40 | export interface SetSitelinkResponse {
41 |   success: 1
42 |   entity: EntityWithSitelinks
43 | }
44 | 


--------------------------------------------------------------------------------
/tests/integration/fetch_properties_datatypes.ts:
--------------------------------------------------------------------------------
 1 | import 'should'
 2 | import config from 'config'
 3 | import { fetchPropertiesDatatypes } from '#lib/properties/fetch_properties_datatypes'
 4 | import { getSandboxPropertyId } from '#tests/integration/utils/sandbox_entities'
 5 | import { waitForInstance } from '#tests/integration/utils/wait_for_instance'
 6 | import { undesiredRes } from './utils/utils.js'
 7 | 
 8 | describe('fetch properties datatypes', function () {
 9 |   this.timeout(20 * 1000)
10 |   before('wait for instance', waitForInstance)
11 | 
12 |   it('should fetch a property datatype', async () => {
13 |     const propertyId = await getSandboxPropertyId('wikibase-item')
14 |     await fetchPropertiesDatatypes(config, [ propertyId ])
15 |     config.properties.should.be.an.Object()
16 |     const datatype = config.properties[propertyId]
17 |     datatype.should.equal('wikibase-item')
18 |   })
19 | 
20 |   it("should throw when it can't find the property datatype", done => {
21 |     fetchPropertiesDatatypes(config, [ 'P999999' ])
22 |     .then(undesiredRes(done))
23 |     .catch(err => {
24 |       err.message.should.equal('property not found')
25 |       done()
26 |     })
27 |     .catch(done)
28 |   })
29 | })
30 | 


--------------------------------------------------------------------------------
/src/lib/properties/find_snaks_properties.ts:
--------------------------------------------------------------------------------
 1 | import { uniq } from 'lodash-es'
 2 | import { forceArray } from '../utils.js'
 3 | 
 4 | export const findClaimsProperties = claims => {
 5 |   if (!claims) return []
 6 | 
 7 |   const claimsProperties = Object.keys(claims)
 8 | 
 9 |   const subSnaksProperties = []
10 | 
11 |   const addSnaksProperties = snaks => {
12 |     const properties = findSnaksProperties(snaks)
13 |     subSnaksProperties.push(...properties)
14 |   }
15 | 
16 |   claimsProperties.forEach(addPropertyClaimsSnaksProperties(claims, addSnaksProperties))
17 | 
18 |   return uniq(claimsProperties.concat(subSnaksProperties))
19 | }
20 | 
21 | const addPropertyClaimsSnaksProperties = (claims, addSnaksProperties) => claimProperty => {
22 |   const propertyClaims = claims[claimProperty]
23 |   forceArray(propertyClaims).forEach(claim => {
24 |     const { qualifiers, references } = claim
25 |     if (qualifiers) addSnaksProperties(qualifiers)
26 |     if (references) {
27 |       forceArray(references).forEach(reference => {
28 |         const snaks = reference.snaks || reference
29 |         addSnaksProperties(snaks)
30 |       })
31 |     }
32 |   })
33 | }
34 | 
35 | export const findSnaksProperties = snaks => Object.keys(snaks)
36 | 


--------------------------------------------------------------------------------
/tests/integration/anonymous_edit.ts:
--------------------------------------------------------------------------------
 1 | import 'should'
 2 | import config from 'config'
 3 | import { getSandboxItemId } from '#tests/integration/utils/sandbox_entities'
 4 | import { waitForInstance } from '#tests/integration/utils/wait_for_instance'
 5 | import { randomString } from '#tests/unit/utils'
 6 | import WBEdit from '#root'
 7 | 
 8 | const { instance } = config
 9 | 
10 | describe('anonymous edit', function () {
11 |   this.timeout(20 * 1000)
12 |   before('wait for instance', waitForInstance)
13 | 
14 |   it('should make an anonymous edit when general config has anonymous=true', async () => {
15 |     const wbEdit = WBEdit({ instance, anonymous: true })
16 |     const id = await getSandboxItemId()
17 |     const value = randomString()
18 |     const res = await wbEdit.alias.add({ id, language: 'fr', value })
19 |     res.success.should.equal(1)
20 |   })
21 | 
22 |   it('should make an anonymous edit when request config has anonymous=true', async () => {
23 |     const wbEdit = WBEdit({ instance })
24 |     const id = await getSandboxItemId()
25 |     const value = randomString()
26 |     const res = await wbEdit.alias.add({ id, language: 'fr', value }, { anonymous: true })
27 |     res.success.should.equal(1)
28 |   })
29 | })
30 | 


--------------------------------------------------------------------------------
/tests/unit/parse_instance.ts:
--------------------------------------------------------------------------------
 1 | import 'should'
 2 | import parseInstance from '#lib/parse_instance'
 3 | import type { SerializedConfig } from '../../src/lib/types/config'
 4 | 
 5 | const instance = 'https://hello.bla'
 6 | const apiEndpoint = `${instance}/w/api.php`
 7 | 
 8 | describe('parseInstance', () => {
 9 |   it('reject a missing instance', () => {
10 |     parseInstance.bind(null, {}).should.throw('missing config parameter: instance')
11 |   })
12 | 
13 |   it('should return an instance and sparql endpoint', () => {
14 |     const configA = { instance }
15 |     const configB = { instance: apiEndpoint }
16 |     parseInstance(configA)
17 |     parseInstance(configB)
18 |     configA.instance.should.equal(instance)
19 |     configB.instance.should.equal(instance)
20 |   })
21 | 
22 |   it('should allow to customize the script path', () => {
23 |     const configA: Partial = { instance, wgScriptPath: 'foo' }
24 |     const configB: Partial = { instance, wgScriptPath: '/foo' }
25 |     parseInstance(configA)
26 |     configA.instanceApiEndpoint.should.equal(`${instance}/foo/api.php`)
27 |     parseInstance(configB)
28 |     configB.instanceApiEndpoint.should.equal(`${instance}/foo/api.php`)
29 |   })
30 | })
31 | 


--------------------------------------------------------------------------------
/tests/integration/entity/merge.ts:
--------------------------------------------------------------------------------
 1 | import 'should'
 2 | import config from 'config'
 3 | import type { EditEntitySimplifiedModeParams } from '#lib/entity/edit'
 4 | import { waitForInstance } from '#tests/integration/utils/wait_for_instance'
 5 | import { randomString } from '#tests/unit/utils'
 6 | import WBEdit from '#root'
 7 | 
 8 | const wbEdit = WBEdit(config)
 9 | 
10 | describe('entity merge', function () {
11 |   this.timeout(20 * 1000)
12 |   before('wait for instance', waitForInstance)
13 | 
14 |   // Do not run this test on the local instance as it currently fails
15 |   // https://phabricator.wikimedia.org/T232925
16 |   xit('should merge two items', async () => {
17 |     const [ res1, res2 ] = await Promise.all([
18 |       wbEdit.entity.create({ labels: { en: randomString() } } as EditEntitySimplifiedModeParams),
19 |       wbEdit.entity.create({ labels: { en: randomString() } } as EditEntitySimplifiedModeParams),
20 |     ])
21 |     const { id: from } = res1.entity
22 |     const { id: to } = res2.entity
23 |     const res3 = await wbEdit.entity.merge({ from, to })
24 |     res3.success.should.equal(1)
25 |     res3.redirected.should.equal(1)
26 |     res3.from.id.should.equal(from)
27 |     res3.to.id.should.equal(to)
28 |   })
29 | })
30 | 


--------------------------------------------------------------------------------
/src/lib/entity/id_alias.ts:
--------------------------------------------------------------------------------
 1 | import { WBK, isPropertyId } from 'wikibase-sdk'
 2 | import { newError } from '../error.js'
 3 | import { request } from '../request/request.js'
 4 | import { isNonEmptyString } from '../utils.js'
 5 | import type { AbsoluteUrl } from '../types/common.js'
 6 | 
 7 | export function isIdAliasPattern (str: string) {
 8 |   if (typeof str !== 'string') return false
 9 |   const [ property, id ] = str.split(/[=:]/)
10 |   return isPropertyId(property) && isNonEmptyString(id)
11 | }
12 | 
13 | export async function resolveIdAlias (idAlias: string, instance: AbsoluteUrl) {
14 |   const wbk = WBK({ instance })
15 |   if (!idAlias.includes('=')) {
16 |     // Accept both ':' and '=' as separators (as the Wikidata Hub uses one and haswbstatement the other)
17 |     // but only replace the first instance of ':' to avoid corrupting valid ids containing ':'
18 |     idAlias = idAlias.replace(':', '=')
19 |   }
20 |   const url = wbk.cirrusSearchPages({ haswbstatement: idAlias }) as AbsoluteUrl
21 |   const res = await request('get', { url })
22 |   const ids = res.query.search.map(result => result.title)
23 |   if (ids.length === 1) return ids[0]
24 |   else throw newError('id alias could not be resolved', 400, { idAlias, instance, foundIds: ids })
25 | }
26 | 


--------------------------------------------------------------------------------
/src/lib/error.ts:
--------------------------------------------------------------------------------
 1 | import type { AbsoluteUrl } from '#lib/types/common'
 2 | 
 3 | export type ErrorContext = object
 4 | 
 5 | export interface ContextualizedError extends Error {
 6 |   statusCode?: number
 7 |   context?: ErrorContext
 8 |   code?: string
 9 |   body?: unknown
10 |   headers?: Headers
11 |   url?: AbsoluteUrl
12 | }
13 | 
14 | export function newError (message: string, statusCode?: number | ErrorContext, context?: ErrorContext) {
15 |   const err: ContextualizedError = new Error(message)
16 |   if (typeof statusCode !== 'number') {
17 |     if (context == null) {
18 |       context = statusCode
19 |       statusCode = 400
20 |     } else {
21 |       throw newError('invalid error status code', 500, { message, statusCode, context })
22 |     }
23 |   }
24 |   err.statusCode = statusCode
25 |   if (context) {
26 |     context = convertSetsIntoArrays(context)
27 |     err.context = context
28 |     err.stack += `\n[context] ${JSON.stringify(context)}`
29 |   }
30 |   return err
31 | }
32 | 
33 | function convertSetsIntoArrays (context: ErrorContext) {
34 |   const convertedContext = {}
35 |   for (const key in context) {
36 |     const value = context[key]
37 |     convertedContext[key] = value instanceof Set ? Array.from(value) : value
38 |   }
39 |   return convertedContext
40 | }
41 | 


--------------------------------------------------------------------------------
/src/lib/request/initialize_config_auth.ts:
--------------------------------------------------------------------------------
 1 | import { getAuthDataFactory } from './get_auth_data.js'
 2 | import type { SerializedConfig } from '../types/config.js'
 3 | 
 4 | export function initializeConfigAuth (config: SerializedConfig) {
 5 |   if (!config) throw new Error('missing config')
 6 |   if (config.anonymous) return
 7 | 
 8 |   const credentialsKey = getCredentialsKey(config)
 9 |   const { credentials } = config
10 | 
11 |   // Generate the function only once per credentials
12 |   if (credentials._getAuthData && credentialsKey === credentials._credentialsKey) return
13 | 
14 |   credentials._getAuthData = getAuthDataFactory(config)
15 |   credentials._credentialsKey = credentialsKey
16 | }
17 | 
18 | function getCredentialsKey (config: SerializedConfig) {
19 |   const { instance, credentials } = config
20 |   const oauth = 'oauth' in credentials ? credentials.oauth : undefined
21 |   const username = 'username' in credentials ? credentials.username : undefined
22 |   const browserSession = 'browserSession' in credentials ? credentials.browserSession : undefined
23 |   if (browserSession) return instance
24 |   // Namespacing keys as a oauth.consumer_key could theoretically be a username
25 |   return username ? `${instance}|u|${username}` : `${instance}|o|${oauth.consumer_key}`
26 | }
27 | 


--------------------------------------------------------------------------------
/tests/integration/qualifier/remove.ts:
--------------------------------------------------------------------------------
 1 | import 'should'
 2 | import config from 'config'
 3 | import { addQualifier } from '#tests/integration/utils/sandbox_snaks'
 4 | import { waitForInstance } from '#tests/integration/utils/wait_for_instance'
 5 | import { randomString } from '#tests/unit/utils'
 6 | import WBEdit from '#root'
 7 | 
 8 | const wbEdit = WBEdit(config)
 9 | const removeQualifier = wbEdit.qualifier.remove
10 | 
11 | describe('qualifier remove', function () {
12 |   this.timeout(20 * 1000)
13 |   before('wait for instance', waitForInstance)
14 | 
15 |   it('should remove a qualifier', async () => {
16 |     const { guid, qualifier } = await addQualifier({ datatype: 'string', value: randomString() })
17 |     const res = await removeQualifier({ guid, hash: qualifier.hash })
18 |     res.success.should.equal(1)
19 |   })
20 | 
21 |   it('should remove several qualifiers', async () => {
22 |     const [ res1, res2 ] = await Promise.all([
23 |       addQualifier({ datatype: 'string', value: randomString() }),
24 |       addQualifier({ datatype: 'string', value: randomString() }),
25 |     ])
26 |     const res = await removeQualifier({
27 |       guid: res1.guid,
28 |       hash: [ res1.qualifier.hash, res2.qualifier.hash ],
29 |     })
30 |     res.success.should.equal(1)
31 |   })
32 | })
33 | 


--------------------------------------------------------------------------------
/tests/integration/reference/remove.ts:
--------------------------------------------------------------------------------
 1 | import 'should'
 2 | import config from 'config'
 3 | import { addReference } from '#tests/integration/utils/sandbox_snaks'
 4 | import { waitForInstance } from '#tests/integration/utils/wait_for_instance'
 5 | import { randomString } from '#tests/unit/utils'
 6 | import WBEdit from '#root'
 7 | 
 8 | const wbEdit = WBEdit(config)
 9 | const removeReference = wbEdit.reference.remove
10 | 
11 | describe('reference remove', function () {
12 |   this.timeout(20 * 1000)
13 |   before('wait for instance', waitForInstance)
14 | 
15 |   it('should remove a reference', async () => {
16 |     const { guid, reference } = await addReference({ datatype: 'string', value: randomString() })
17 |     const res = await removeReference({ guid, hash: reference.hash })
18 |     res.success.should.equal(1)
19 |   })
20 | 
21 |   it('should remove several qualifiers', async () => {
22 |     const [ res1, res2 ] = await Promise.all([
23 |       addReference({ datatype: 'string', value: randomString() }),
24 |       addReference({ datatype: 'string', value: randomString() }),
25 |     ])
26 |     const res3 = await removeReference({
27 |       guid: res1.guid,
28 |       hash: [ res1.reference.hash, res2.reference.hash ],
29 |     })
30 |     res3.success.should.equal(1)
31 |   })
32 | })
33 | 


--------------------------------------------------------------------------------
/src/lib/properties/fetch_used_properties_datatypes.ts:
--------------------------------------------------------------------------------
 1 | import { hasTruthy } from '../utils.js'
 2 | import { fetchPropertiesDatatypes } from './fetch_properties_datatypes.js'
 3 | import { findClaimsProperties, findSnaksProperties } from './find_snaks_properties.js'
 4 | import type { SerializedConfig } from '../types/config.js'
 5 | 
 6 | export function fetchUsedPropertiesDatatypes (params: object, config: SerializedConfig) {
 7 |   const propertyIds = findUsedProperties(params)
 8 |   return fetchPropertiesDatatypes(config, propertyIds)
 9 | }
10 | 
11 | function findUsedProperties (params: object) {
12 |   const ids = []
13 |   if (!hasTruthy(params, 'rawMode')) {
14 |     if (hasTruthy(params, 'claims')) ids.push(...findClaimsProperties(params.claims))
15 |     if (hasTruthy(params, 'statements')) ids.push(...findClaimsProperties(params.statements))
16 |     if (hasTruthy(params, 'snaks')) ids.push(...findSnaksProperties(params.snaks))
17 |     if (hasTruthy(params, 'property')) ids.push(params.property)
18 |   }
19 |   if (hasTruthy(params, 'newProperty')) ids.push(params.newProperty)
20 |   if (hasTruthy(params, 'oldProperty')) ids.push(params.oldProperty)
21 |   if (hasTruthy(params, 'propertyClaimsId') && typeof params.propertyClaimsId === 'string') {
22 |     ids.push(params.propertyClaimsId.split('#')[1])
23 |   }
24 |   return ids
25 | }
26 | 


--------------------------------------------------------------------------------
/src/lib/badge/add.ts:
--------------------------------------------------------------------------------
 1 | import { uniq } from 'lodash-es'
 2 | import { formatBadges } from '../entity/format.js'
 3 | import { newError } from '../error.js'
 4 | import { getEntitySitelinks } from '../get_entity.js'
 5 | import { validateEntityId, validateSite } from '../validate.js'
 6 | import type { WikibaseEditAPI } from '../index.js'
 7 | import type { SetSitelinkResponse } from '../sitelink/set.js'
 8 | import type { SerializedConfig } from '../types/config.js'
 9 | import type { EntityWithSitelinks, ItemId } from 'wikibase-sdk'
10 | 
11 | export interface AddBadgeParams {
12 |   id: EntityWithSitelinks['id']
13 |   site: string
14 |   badges: ItemId | ItemId[]
15 | }
16 | 
17 | export async function addBadge (params: AddBadgeParams, config: SerializedConfig, API: WikibaseEditAPI) {
18 |   let { id, site, badges } = params
19 |   validateEntityId(id)
20 |   validateSite(site)
21 |   badges = formatBadges(badges)
22 | 
23 |   const sitelinks = await getEntitySitelinks(id, config)
24 |   const siteObj = sitelinks[site]
25 | 
26 |   if (!siteObj) {
27 |     throw newError('sitelink does not exist', 400, params)
28 |   }
29 | 
30 |   const { title, badges: currentBadges } = siteObj
31 |   return API.sitelink.set({
32 |     id,
33 |     site,
34 |     title,
35 |     badges: uniq(currentBadges.concat(badges)),
36 |   })
37 | }
38 | 
39 | export type AddBadgeResponse = SetSitelinkResponse
40 | 


--------------------------------------------------------------------------------
/tests/integration/sitelink/set.ts:
--------------------------------------------------------------------------------
 1 | import 'should'
 2 | import config from 'config'
 3 | import WBEdit from '#root'
 4 | import { assert } from '../../unit/utils'
 5 | 
 6 | const wbEdit = WBEdit(config)
 7 | 
 8 | // Those tests require setting an instance with sitelinks
 9 | // (such as test.wikidata.org) in config, and are thus disabled by default
10 | xdescribe('set sitelink', () => {
11 |   it('should set a sitelink', async () => {
12 |     const res = await wbEdit.sitelink.set({
13 |       id: 'Q224124',
14 |       site: 'frwiki',
15 |       title: 'Septembre',
16 |       badges: 'Q608|Q609',
17 |     })
18 |     res.success.should.equal(1)
19 |     res.entity.id.should.equal('Q224124')
20 |     res.entity.sitelinks.frwiki.title.should.equal('Septembre')
21 |     res.entity.sitelinks.frwiki.badges.should.deepEqual([ 'Q608', 'Q609' ])
22 |   })
23 | 
24 |   it('should remove a sitelink', async () => {
25 |     await wbEdit.sitelink.set({
26 |       id: 'Q224124',
27 |       site: 'eswiki',
28 |       title: 'Septiembre',
29 |     })
30 |     const res = await wbEdit.sitelink.set({
31 |       id: 'Q224124',
32 |       site: 'eswiki',
33 |       title: null,
34 |     })
35 |     res.success.should.equal(1)
36 |     res.entity.id.should.equal('Q224124')
37 |     assert('removed' in res.entity.sitelinks.eswiki)
38 |     res.entity.sitelinks.eswiki.removed.should.equal('')
39 |   })
40 | })
41 | 


--------------------------------------------------------------------------------
/src/lib/get_entity.ts:
--------------------------------------------------------------------------------
 1 | import { newError } from './error.js'
 2 | import WBK from './get_instance_wikibase_sdk.js'
 3 | import { getJson } from './request/get_json.js'
 4 | import type { SerializedConfig } from './types/config.js'
 5 | import type { Claims, Entity, EntityWithClaims, EntityWithSitelinks, MediaInfo, Props, Statements } from 'wikibase-sdk'
 6 | 
 7 | async function getEntity  (id: T['id'], props: Props, config: SerializedConfig) {
 8 |   const { instance } = config
 9 |   const url = WBK(instance).getEntities({ ids: id, props })
10 |   const headers = { 'user-agent': config.userAgent }
11 |   const { entities } = await getJson(url, { headers })
12 |   const entity = entities[id]
13 |   if (!entity || entity.missing != null) {
14 |     throw newError('entity not found', { id, props, instance: config.instance })
15 |   }
16 |   return entity as T
17 | }
18 | 
19 | export async function getEntityClaims  (id: T['id'], config: SerializedConfig) {
20 |   const entity = await getEntity(id, 'claims', config)
21 |   const { statementsKey } = config
22 |   return entity[statementsKey] as (T extends MediaInfo ? Statements : Claims)
23 | }
24 | 
25 | export async function getEntitySitelinks  (id: T['id'], config: SerializedConfig) {
26 |   const entity = await getEntity(id, 'sitelinks', config)
27 |   return entity.sitelinks
28 | }
29 | 


--------------------------------------------------------------------------------
/tests/unit/alias/set.ts:
--------------------------------------------------------------------------------
 1 | import 'should'
 2 | import { setAlias } from '#lib/alias/set'
 3 | import { assert, randomString, someEntityId } from '#tests/unit/utils'
 4 | 
 5 | const language = 'it'
 6 | 
 7 | describe('alias set', () => {
 8 |   it('should reject if not passed an entity id', () => {
 9 |     // @ts-expect-error
10 |     setAlias.bind(null, {}).should.throw('invalid entity id')
11 |   })
12 | 
13 |   it('should reject if not passed a language', () => {
14 |     // @ts-expect-error
15 |     setAlias.bind(null, { id: someEntityId }).should.throw('invalid language')
16 |   })
17 | 
18 |   it('should reject if not passed an alias', () => {
19 |     // @ts-expect-error
20 |     setAlias.bind(null, { id: someEntityId, language }).should.throw('empty alias array')
21 |   })
22 | 
23 |   it('should accept a single alias string', () => {
24 |     const value = randomString()
25 |     const { action, data } = setAlias({ id: someEntityId, language, value })
26 |     action.should.equal('wbsetaliases')
27 |     data.should.be.an.Object()
28 |   })
29 | 
30 |   it('should accept multiple aliases as an array of strings', () => {
31 |     const value = [ randomString(), randomString() ]
32 |     const { action, data } = setAlias({ id: someEntityId, language, value })
33 |     action.should.equal('wbsetaliases')
34 |     assert('set' in data)
35 |     data.set.should.equal(value.join('|'))
36 |   })
37 | })
38 | 


--------------------------------------------------------------------------------
/tests/integration/badge/remove.ts:
--------------------------------------------------------------------------------
 1 | import 'should'
 2 | import config from 'config'
 3 | import WBEdit from '#root'
 4 | import { assert } from '../../unit/utils'
 5 | 
 6 | const wbEdit = WBEdit(config)
 7 | 
 8 | // Those tests require setting an instance with sitelinks
 9 | // (such as test.wikidata.org) in config, and are thus disabled by default
10 | xdescribe('remove badges', () => {
11 |   it('should remove a badge', async () => {
12 |     await wbEdit.sitelink.set({
13 |       id: 'Q224124',
14 |       site: 'dewiki',
15 |       title: 'September',
16 |       badges: [ 'Q608' ],
17 |     })
18 |     const res = await wbEdit.badge.remove({
19 |       id: 'Q224124',
20 |       site: 'dewiki',
21 |       badges: [ 'Q608' ],
22 |     })
23 |     res.success.should.equal(1)
24 |     assert('sitelinks' in res.entity)
25 |     res.entity.sitelinks.dewiki.badges.should.deepEqual([])
26 |   })
27 | 
28 |   it('should ignore absent badges', async () => {
29 |     await wbEdit.sitelink.set({
30 |       id: 'Q224124',
31 |       site: 'dewiki',
32 |       title: 'September',
33 |       badges: [ 'Q608' ],
34 |     })
35 |     const res = await wbEdit.badge.remove({
36 |       id: 'Q224124',
37 |       site: 'dewiki',
38 |       badges: [ 'Q609' ],
39 |     })
40 |     res.success.should.equal(1)
41 |     assert('sitelinks' in res.entity)
42 |     res.entity.sitelinks.dewiki.badges.should.deepEqual([ 'Q608' ])
43 |   })
44 | })
45 | 


--------------------------------------------------------------------------------
/src/lib/request/oauth.ts:
--------------------------------------------------------------------------------
 1 | import base64 from 'crypto-js/enc-base64.js'
 2 | import hmacSHA1 from 'crypto-js/hmac-sha1.js'
 3 | import OAuth from 'oauth-1.0a'
 4 | import type { HttpMethod } from '#lib/request/fetch'
 5 | import type { AbsoluteUrl } from '#lib/types/common'
 6 | import type { OAuthCredentials } from '#lib/types/config'
 7 | 
 8 | const hashFunction = (baseString: string, key: string) => base64.stringify(hmacSHA1(baseString, key))
 9 | 
10 | type OAuthData = Pick
11 | 
12 | function getOAuthData ({ consumer_key: key, consumer_secret: secret }: OAuthData) {
13 |   // @ts-expect-error following documentation
14 |   return OAuth({
15 |     consumer: { key, secret },
16 |     signature_method: 'HMAC-SHA1',
17 |     hash_function: hashFunction,
18 |   })
19 | }
20 | 
21 | interface GetSignatureHeadersParams {
22 |   url: AbsoluteUrl
23 |   method: HttpMethod
24 |   oauthTokens: OAuthCredentials['oauth']
25 |   data?: unknown
26 | }
27 | 
28 | export function getSignatureHeaders ({ url, method, data, oauthTokens }: GetSignatureHeadersParams) {
29 |   const { token: key, token_secret: secret } = oauthTokens
30 |   // Do not extract { authorize, toHeaders } functions as they need their context
31 |   const oauth = getOAuthData(oauthTokens)
32 |   const signature = oauth.authorize({ url, method, data }, { key, secret })
33 |   return oauth.toHeader(signature)
34 | }
35 | 


--------------------------------------------------------------------------------
/tests/integration/badge/add.ts:
--------------------------------------------------------------------------------
 1 | import 'should'
 2 | import config from 'config'
 3 | import WBEdit from '#root'
 4 | import { assert } from '../../unit/utils'
 5 | 
 6 | const wbEdit = WBEdit(config)
 7 | 
 8 | // Those tests require setting an instance with sitelinks
 9 | // (such as test.wikidata.org) in config, and are thus disabled by default
10 | xdescribe('add badges', () => {
11 |   it('should add a badge', async () => {
12 |     await wbEdit.sitelink.set({
13 |       id: 'Q224124',
14 |       site: 'dewiki',
15 |       title: 'September',
16 |       badges: [ 'Q608' ],
17 |     })
18 |     const res = await wbEdit.badge.add({
19 |       id: 'Q224124',
20 |       site: 'dewiki',
21 |       badges: [ 'Q609' ],
22 |     })
23 |     res.success.should.equal(1)
24 |     assert('sitelinks' in res.entity)
25 |     res.entity.sitelinks.dewiki.badges.should.deepEqual([ 'Q608', 'Q609' ])
26 |   })
27 | 
28 |   it('should ignore already added badges', async () => {
29 |     await wbEdit.sitelink.set({
30 |       id: 'Q224124',
31 |       site: 'dewiki',
32 |       title: 'September',
33 |       badges: [ 'Q608' ],
34 |     })
35 |     const res = await wbEdit.badge.add({
36 |       id: 'Q224124',
37 |       site: 'dewiki',
38 |       badges: [ 'Q608' ],
39 |     })
40 |     res.success.should.equal(1)
41 |     assert('sitelinks' in res.entity)
42 |     res.entity.sitelinks.dewiki.badges.should.deepEqual([ 'Q608' ])
43 |   })
44 | })
45 | 


--------------------------------------------------------------------------------
/tests/unit/alias/add.ts:
--------------------------------------------------------------------------------
 1 | import 'should'
 2 | import { addAlias } from '#lib/alias/add'
 3 | import { assert, randomString, someEntityId } from '#tests/unit/utils'
 4 | 
 5 | const language = 'it'
 6 | 
 7 | describe('alias add', () => {
 8 |   it('should reject if not passed an entity id', () => {
 9 |     // @ts-expect-error
10 |     addAlias.bind(null, {}).should.throw('invalid entity id')
11 |   })
12 | 
13 |   it('should reject if not passed a language', () => {
14 |     // @ts-expect-error
15 |     addAlias.bind(null, { id: someEntityId }).should.throw('invalid language')
16 |   })
17 | 
18 |   it('should reject if not passed an alias', () => {
19 |     // @ts-expect-error
20 |     addAlias.bind(null, { id: someEntityId, language }).should.throw('empty alias array')
21 |   })
22 | 
23 |   it('should accept a single alias string', () => {
24 |     const value = randomString()
25 |     const { action, data } = addAlias({ id: someEntityId, language, value })
26 |     action.should.equal('wbsetaliases')
27 |     assert('add' in data)
28 |     data.add.should.deepEqual(value)
29 |   })
30 | 
31 |   it('should accept multiple aliases as an array of strings', () => {
32 |     const value = [ randomString(), randomString() ]
33 |     const { action, data } = addAlias({ id: someEntityId, language, value })
34 |     action.should.equal('wbsetaliases')
35 |     assert('add' in data)
36 |     data.add.should.equal(value.join('|'))
37 |   })
38 | })
39 | 


--------------------------------------------------------------------------------
/src/lib/claim/set.ts:
--------------------------------------------------------------------------------
 1 | import { validateGuid, validatePropertyId, validateSnakValue } from '../validate.js'
 2 | import { formatClaimValue } from './format_claim_value.js'
 3 | import { buildSnak } from './snak.js'
 4 | import type { PropertiesDatatypes } from '../properties/fetch_properties_datatypes.js'
 5 | import type { AbsoluteUrl } from '../types/common.js'
 6 | import type { Claim, Guid, PropertyId, SimplifiedClaim } from 'wikibase-sdk'
 7 | 
 8 | export interface SetClaimParams {
 9 |   guid: Guid
10 |   property: PropertyId
11 |   value: SimplifiedClaim
12 | }
13 | 
14 | export function setClaim (params: SetClaimParams, properties: PropertiesDatatypes, instance: AbsoluteUrl) {
15 |   const { guid, property, value: rawValue } = params
16 |   const datatype = properties[property]
17 | 
18 |   validateGuid(guid)
19 |   validatePropertyId(property)
20 | 
21 |   // Format before testing validity to avoid throwing on type errors
22 |   // that could be recovered
23 |   const value = formatClaimValue(datatype, rawValue, instance)
24 | 
25 |   validateSnakValue(property, datatype, value)
26 | 
27 |   const claim = {
28 |     id: guid,
29 |     type: 'statement',
30 |     mainsnak: buildSnak(property, datatype, value, instance),
31 |   }
32 | 
33 |   return { action: 'wbsetclaim', data: { claim: JSON.stringify(claim) } }
34 | }
35 | 
36 | export interface SetClaimResponse {
37 |   pageinfo: { lastrevid: number }
38 |   success: 1
39 |   claim: Claim
40 | }
41 | 


--------------------------------------------------------------------------------
/src/lib/badge/remove.ts:
--------------------------------------------------------------------------------
 1 | // Doc https://www.wikidata.org/w/api.php?action=help&modules=wbsetsitelink
 2 | 
 3 | import { difference } from 'lodash-es'
 4 | import { formatBadges } from '../entity/format.js'
 5 | import { newError } from '../error.js'
 6 | import { getEntitySitelinks } from '../get_entity.js'
 7 | import { validateEntityId, validateSite } from '../validate.js'
 8 | import type { WikibaseEditAPI } from '../index.js'
 9 | import type { SetSitelinkResponse } from '../sitelink/set.js'
10 | import type { SerializedConfig } from '../types/config.js'
11 | import type { EntityWithSitelinks, ItemId } from 'wikibase-sdk'
12 | 
13 | export interface RemoveBadgeParams {
14 |   id: EntityWithSitelinks['id']
15 |   site: string
16 |   badges: ItemId | ItemId[]
17 | }
18 | 
19 | export async function removeBadge (params: RemoveBadgeParams, config: SerializedConfig, API: WikibaseEditAPI) {
20 |   let { id, site, badges } = params
21 |   validateEntityId(id)
22 |   validateSite(site)
23 |   badges = formatBadges(badges)
24 | 
25 |   const sitelinks = await getEntitySitelinks(id, config)
26 |   const siteObj = sitelinks[site]
27 | 
28 |   if (!siteObj) {
29 |     throw newError('sitelink does not exist', 400, params)
30 |   }
31 | 
32 |   const { title, badges: currentBadges } = siteObj
33 |   return API.sitelink.set({
34 |     id,
35 |     site,
36 |     title,
37 |     badges: difference(currentBadges, badges),
38 |   })
39 | }
40 | 
41 | export type RemoveBadgeResponse = SetSitelinkResponse
42 | 


--------------------------------------------------------------------------------
/src/lib/request/parse_response_body.ts:
--------------------------------------------------------------------------------
 1 | import { debug } from '../debug.js'
 2 | import type { ContextualizedError } from '../error.js'
 3 | import type { APIResponseError } from './request.js'
 4 | import type { AbsoluteUrl } from '../types/common.js'
 5 | 
 6 | export async function parseResponseBody (res: Response) {
 7 |   const raw = await res.text()
 8 |   let data
 9 |   try {
10 |     data = JSON.parse(raw)
11 |   } catch (err) {
12 |     const customErr = new Error('Could not parse response: ' + raw)
13 |     customErr.cause = err
14 |     customErr.name = 'wrong response format'
15 |     throw customErr
16 |   }
17 |   debug('response', res.url, res.status, data)
18 |   if (data.error != null) throw requestError(res, data)
19 |   else return data
20 | }
21 | 
22 | function requestError (res: Response, body: { error: APIResponseError }) {
23 |   const { code, info } = body.error || {}
24 |   const errMessage = `${code}: ${info}`
25 |   const err: ContextualizedError = new Error(errMessage)
26 |   err.name = code
27 |   if (res.status === 200) {
28 |     // Override false positive status code
29 |     err.statusCode = 500
30 |   } else {
31 |     err.statusCode = res.status
32 |   }
33 |   err.headers = res.headers
34 |   err.body = body
35 |   err.url = res.url as AbsoluteUrl
36 |   if (res.url) err.stack += `\nurl: ${res.url}`
37 |   if (res.status) err.stack += `\nresponse status: ${res.status}`
38 |   if (body) err.stack += `\nresponse body: ${JSON.stringify(body)}`
39 |   err.context = { url: res.url, body }
40 |   return err
41 | }
42 | 


--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "compilerOptions": {
 3 |     "rootDir": ".",
 4 |     "outDir": "./dist",
 5 |     "target": "es2022",
 6 |     "module": "esnext",
 7 |     "moduleResolution": "bundler",
 8 |     "checkJs": true,
 9 |     "declaration": true,
10 |     "declarationMap": true,
11 |     "erasableSyntaxOnly": true,
12 |     "forceConsistentCasingInFileNames": true,
13 |     "incremental": true,
14 |     "newLine": "lf",
15 |     "noEmitOnError": true,
16 |     "resolveJsonModule": false,
17 |     "skipLibCheck": true,
18 |     "sourceMap": true,
19 |     "stripInternal": true,
20 |     "useDefineForClassFields": true,
21 |     "verbatimModuleSyntax": true,
22 | 
23 |     "noFallthroughCasesInSwitch": true,
24 |     "noImplicitOverride": true,
25 |     "noImplicitReturns": true,
26 |     // "noPropertyAccessFromIndexSignature": true,
27 |     "noUncheckedIndexedAccess": true,
28 |     "noUnusedLocals": true,
29 |     "noUnusedParameters": true,
30 |     // "strict": true,
31 | 
32 |     // part of strict
33 |     "alwaysStrict": true,
34 |     // "noImplicitAny": true,
35 |     "noImplicitThis": true,
36 |     "strictBindCallApply": true,
37 |     "strictFunctionTypes": true,
38 |     // "strictNullChecks": true,
39 |     // "strictPropertyInitialization": true,
40 |     // "useUnknownInCatchVariables": true,
41 |   },
42 |   "include": [
43 |     "src/**/*",
44 |     "tests/**/*",
45 |   ],
46 |   "paths": {
47 |     "#lib/*": [
48 |       "./src/lib/*.js",
49 |     ],
50 |     "#tests/*": [
51 |       "./tests/*.js",
52 |     ],
53 |   },
54 | }
55 | 


--------------------------------------------------------------------------------
/tests/integration/maxlag.ts:
--------------------------------------------------------------------------------
 1 | import 'should'
 2 | import config from 'config'
 3 | import { getSandboxItemId } from '#tests/integration/utils/sandbox_entities'
 4 | import { waitForInstance } from '#tests/integration/utils/wait_for_instance'
 5 | import { randomString } from '#tests/unit/utils'
 6 | import WBEdit from '#root'
 7 | import { undesiredRes } from './utils/utils.js'
 8 | 
 9 | describe('maxlag', function () {
10 |   this.timeout(120 * 1000)
11 |   before('wait for instance', waitForInstance)
12 | 
13 |   it('should accept a maxlag from initialization configuration', done => {
14 |     const customConfig = Object.assign({ maxlag: -100, autoRetry: false }, config)
15 |     const wbEdit = WBEdit(customConfig)
16 |     doAction(wbEdit)
17 |     .then(undesiredRes(done))
18 |     .catch(err => {
19 |       err.body.error.code.should.equal('maxlag')
20 |       done()
21 |     })
22 |     .catch(done)
23 |   })
24 | 
25 |   it('should accept a maxlag from request configuration', done => {
26 |     const customConfig = Object.assign({ maxlag: 100, autoRetry: false }, config)
27 |     const wbEdit = WBEdit(customConfig)
28 |     doAction(wbEdit, { maxlag: -100 })
29 |     .then(undesiredRes(done))
30 |     .catch(err => {
31 |       err.body.error.code.should.equal('maxlag')
32 |       done()
33 |     })
34 |     .catch(done)
35 |   })
36 | })
37 | 
38 | async function doAction (wbEdit, reqConfig?) {
39 |   const id = await getSandboxItemId()
40 |   const params = { id, language: 'fr', value: randomString() }
41 |   return wbEdit.alias.add(params, reqConfig)
42 | }
43 | 


--------------------------------------------------------------------------------
/src/lib/claim/is_matching_claim.ts:
--------------------------------------------------------------------------------
 1 | import { isMatchingSnak } from './is_matching_snak.js'
 2 | 
 3 | export function isMatchingClaimFactory (newClaim, matchingQualifiers) {
 4 |   return function isMatchingClaim (existingClaim) {
 5 |     const { mainsnak, qualifiers = {} } = existingClaim
 6 |     if (!isMatchingSnak(mainsnak, newClaim.mainsnak)) return false
 7 |     if (matchingQualifiers) {
 8 |       for (const property of matchingQualifiers) {
 9 |         const [ pid, option = 'all' ] = property.split(':')
10 |         if (newClaim.qualifiers[pid] != null && qualifiers[pid] == null) return false
11 |         if (newClaim.qualifiers[pid] == null && qualifiers[pid] != null) return false
12 |         const propertyQualifiersMatch = matchFunctions[option](newClaim.qualifiers[pid], qualifiers[pid])
13 |         if (!propertyQualifiersMatch) return false
14 |       }
15 |     }
16 |     return true
17 |   }
18 | }
19 | 
20 | const matchFunctions = {
21 |   all: (newPropertyQualifiers, existingPropertyQualifiers) => {
22 |     for (const newQualifier of newPropertyQualifiers) {
23 |       for (const existingQualifier of existingPropertyQualifiers) {
24 |         if (!isMatchingSnak(existingQualifier, newQualifier)) return false
25 |       }
26 |     }
27 |     return true
28 |   },
29 |   any: (newPropertyQualifiers, existingPropertyQualifiers) => {
30 |     for (const newQualifier of newPropertyQualifiers) {
31 |       for (const existingQualifier of existingPropertyQualifiers) {
32 |         if (isMatchingSnak(existingQualifier, newQualifier)) return true
33 |       }
34 |     }
35 |     return false
36 |   },
37 | }
38 | 


--------------------------------------------------------------------------------
/tests/unit/alias/remove.ts:
--------------------------------------------------------------------------------
 1 | import 'should'
 2 | import { removeAlias } from '#lib/alias/remove'
 3 | import { assert, randomString, someEntityId } from '#tests/unit/utils'
 4 | 
 5 | const language = 'it'
 6 | 
 7 | describe('alias remove', () => {
 8 |   it('should reject if not passed an entity id', () => {
 9 |     // @ts-expect-error
10 |     removeAlias.bind(null, {}).should.throw('invalid entity id')
11 |   })
12 | 
13 |   it('should reject if not passed a language', () => {
14 |     // @ts-expect-error
15 |     removeAlias.bind(null, { id: someEntityId }).should.throw('invalid language')
16 |   })
17 | 
18 |   it('should reject if not passed an alias', () => {
19 |     // @ts-expect-error
20 |     removeAlias.bind(null, { id: someEntityId, language }).should.throw('empty alias array')
21 |   })
22 | 
23 |   it('should accept a single alias string', () => {
24 |     // It's not necessary that the removed alias actually exist
25 |     // so we can just pass a random string and expect Wikibase to deal with it
26 |     const value = randomString()
27 |     const { action, data } = removeAlias({ id: someEntityId, language, value })
28 |     action.should.equal('wbsetaliases')
29 |     assert('remove' in data)
30 |     data.remove.should.equal(value)
31 |   })
32 | 
33 |   it('should accept multiple aliases as an array of strings', () => {
34 |     const value = [ randomString(), randomString() ]
35 |     const { action, data } = removeAlias({ id: someEntityId, language, value })
36 |     action.should.equal('wbsetaliases')
37 |     assert('remove' in data)
38 |     data.remove.should.equal(value.join('|'))
39 |   })
40 | })
41 | 


--------------------------------------------------------------------------------
/config/local.cjs:
--------------------------------------------------------------------------------
 1 | module.exports = {
 2 |   instance: 'http://localhost:8181',
 3 |   credentials: {
 4 |     oauth: {
 5 |       consumer_key: '7bc33647478f215844e82a1ca3e91fd7',
 6 |       consumer_secret: 'f3bec9695f04c98a7b437a9e58e396ff4abb93de',
 7 |       token: '40f64185a8108b127cc4f21d00175ad0',
 8 |       token_secret: '1757420b6a16b2d4a0fd34dee050eb6f603de787',
 9 |     }
10 |   },
11 | 
12 |   credentialsAlt: {
13 |     username: 'WikibaseAdmin',
14 |     password: 'WikibaseDockerAdminPass',
15 |     // oauth: null
16 |     // password: 'test_pwd@cq29uo9d4q1df56tq573bbd9ja97332g'
17 |   },
18 | 
19 |   secondUserCredentials: {
20 |     username: 'Lnwev',
21 |     password: 'vHuLL_AkPSdgoYL*LU1=Us-LlEwMLY5Y'
22 |   },
23 | 
24 |   // /!\ TESTS REQUIRING ENTITY DELETION WILL FAIL ON test.wikidata.org
25 |   //     AS IT REQUIRES EXTRA PRIVILEGES
26 |   // instance: 'https://test.wikidata.org',
27 |   // credentials: {
28 |   //   oauth: {
29 |   //     consumer_key: 'cd4cbe38901003712654f1a284da16b6',
30 |   //     consumer_secret: '913f78ff9d2c715613b71d012115f05acec3e173',
31 |   //     token: 'fe7eb84d59e964a39d3a023da6a04c0c',
32 |   //     token_secret: '0961e72de9478a08c63dde04c62c4d6b61092d00',
33 |   //   }
34 |   // },
35 |   // credentialsAlt: {
36 |   //   username: 'Maxlath_tests',
37 |   //   password: 'MJ188QwTnm=P;lVnPS_&g!s_&YbKGCDu',
38 |   // },
39 | 
40 |   // credentialsAlt: {
41 |   //   username: 'Maxlath',
42 |   //   password: 'RéeEuùà:Q;2MamMu0Xvu8Lé6K4rfXkjD',
43 |   // },
44 | 
45 |   // instance: 'https://www.wikidata.org',
46 |   // sparqlEndpoint: 'https://query.wikidata.org',
47 |   // maxlag: 20,
48 | }
49 | 


--------------------------------------------------------------------------------
/src/lib/types/snaks.ts:
--------------------------------------------------------------------------------
 1 | import type { CalendarAlias } from '#lib/claim/parse_calendar'
 2 | import type { SpecialSnak } from '#lib/claim/special_snaktype'
 3 | import type { GlobeCoordinateSnakDataValue, MonolingualTextSnakDataValue, StringSnakDataValue, TimeSnakDataValue, WikibaseEntityIdSnakDataValue } from 'wikibase-sdk'
 4 | 
 5 | export type CustomEditableGlobeCoordinateSnakValue = Pick & Partial>
 6 | export type EditableGlobeCoordinateSnakValue = [ number, number ] | CustomEditableGlobeCoordinateSnakValue
 7 | 
 8 | export type EditableMonolingualTextSnakValue = MonolingualTextSnakDataValue['value']
 9 | 
10 | export interface CustomQuantitySnakDataValue {
11 |   amount: number | string
12 |   unit?: string
13 |   upperBound?: number | string
14 |   lowerBound?: number | string
15 | }
16 | export type EditableQuantitySnakValue = number | CustomQuantitySnakDataValue
17 | 
18 | export type EditableStringSnakValue = StringSnakDataValue['value']
19 | 
20 | export type CustomEditableTimeSnakValue = {
21 |   time: string
22 |   calendar?: CalendarAlias
23 | } & Partial>
24 | export type EditableTimeSnakValue = string | CustomEditableTimeSnakValue
25 | 
26 | export type EditableWikibaseEntityIdSnakValue = WikibaseEntityIdSnakDataValue['value']
27 | 
28 | export type EditableSnakValue = EditableGlobeCoordinateSnakValue | EditableMonolingualTextSnakValue | EditableQuantitySnakValue | EditableStringSnakValue | EditableTimeSnakValue | EditableWikibaseEntityIdSnakValue | SpecialSnak
29 | 


--------------------------------------------------------------------------------
/tests/unit/general.ts:
--------------------------------------------------------------------------------
 1 | import 'should'
 2 | import wdEdit from '#root'
 3 | 
 4 | describe('general', () => {
 5 |   it('should return an object', () => {
 6 |     wdEdit({}).should.be.an.Object()
 7 |   })
 8 | 
 9 |   it('should have label functions', () => {
10 |     wdEdit({}).label.set.should.be.a.Function()
11 |   })
12 | 
13 |   it('should have description functions', () => {
14 |     wdEdit({}).description.set.should.be.a.Function()
15 |   })
16 | 
17 |   it('should have alias functions', () => {
18 |     wdEdit({}).alias.set.should.be.a.Function()
19 |     wdEdit({}).alias.add.should.be.a.Function()
20 |     wdEdit({}).alias.remove.should.be.a.Function()
21 |   })
22 | 
23 |   it('should have claim functions', () => {
24 |     wdEdit({}).claim.create.should.be.a.Function()
25 |     wdEdit({}).claim.update.should.be.a.Function()
26 |     wdEdit({}).claim.remove.should.be.a.Function()
27 |   })
28 | 
29 |   it('should have qualifier functions', () => {
30 |     wdEdit({}).qualifier.set.should.be.a.Function()
31 |     wdEdit({}).qualifier.update.should.be.a.Function()
32 |     wdEdit({}).qualifier.remove.should.be.a.Function()
33 |   })
34 | 
35 |   it('should have reference functions', () => {
36 |     wdEdit({}).reference.set.should.be.a.Function()
37 |     wdEdit({}).qualifier.update.should.be.a.Function()
38 |     wdEdit({}).reference.remove.should.be.a.Function()
39 |   })
40 | 
41 |   it('should have entity functions', () => {
42 |     wdEdit({}).entity.create.should.be.a.Function()
43 |     wdEdit({}).entity.edit.should.be.a.Function()
44 |   })
45 | 
46 |   it('should have auth functions', () => {
47 |     wdEdit({}).getAuthData.should.be.a.Function()
48 |   })
49 | })
50 | 


--------------------------------------------------------------------------------
/tests/integration/entity/delete.ts:
--------------------------------------------------------------------------------
 1 | import 'should'
 2 | import config from 'config'
 3 | import type { EditEntitySimplifiedModeParams } from '#lib/entity/edit'
 4 | import { waitForInstance } from '#tests/integration/utils/wait_for_instance'
 5 | // Use credentialsAlt as the OAuth token might miss the permission to delete pages
 6 | // thus getting a 'permissiondenied' error
 7 | import { assert, randomString } from '#tests/unit/utils'
 8 | import WBEdit from '#root'
 9 | 
10 | const { instance, credentialsAlt } = config
11 | 
12 | const wbEdit = WBEdit({ instance, credentials: credentialsAlt })
13 | 
14 | describe('entity delete', function () {
15 |   this.timeout(20 * 1000)
16 |   before('wait for instance', waitForInstance)
17 | 
18 |   it('should delete an item', async () => {
19 |     const resA = await wbEdit.entity.create({ labels: { en: randomString() } } as EditEntitySimplifiedModeParams)
20 |     const { id } = resA.entity
21 |     const resB = await wbEdit.entity.delete({ id })
22 |     assert('delete' in resB)
23 |     assert(typeof resB.delete === 'object')
24 |     assert('title' in resB.delete)
25 |     resB.delete.title.should.endWith(id)
26 |   })
27 | 
28 |   it('should delete a property', async () => {
29 |     const resA = await wbEdit.entity.create({
30 |       type: 'property',
31 |       datatype: 'string',
32 |       labels: { en: randomString() },
33 |     } as EditEntitySimplifiedModeParams)
34 |     const { id } = resA.entity
35 |     const resB = await wbEdit.entity.delete({ id })
36 |     assert('delete' in resB)
37 |     assert(typeof resB.delete === 'object')
38 |     assert('title' in resB.delete)
39 |     resB.delete.title.should.equal(`Property:${id}`)
40 |   })
41 | })
42 | 


--------------------------------------------------------------------------------
/tests/integration/multi_users.ts:
--------------------------------------------------------------------------------
 1 | import 'should'
 2 | import config from 'config'
 3 | import { getSandboxItemId } from '#tests/integration/utils/sandbox_entities'
 4 | import { waitForInstance } from '#tests/integration/utils/wait_for_instance'
 5 | import { randomString } from '#tests/unit/utils'
 6 | import WBEdit from '#root'
 7 | import { getEntityHistory } from './utils/utils.js'
 8 | 
 9 | const { instance, credentialsAlt, secondUserCredentials } = config
10 | 
11 | describe('multi users edits', function () {
12 |   this.timeout(20 * 1000)
13 |   before('wait for instance', waitForInstance)
14 | 
15 |   it('should allow to change user at each request', async () => {
16 |     const wbEdit = WBEdit({ instance })
17 |     const id = await getSandboxItemId()
18 |     await addAlias(wbEdit, id, { anonymous: true })
19 |     await addAlias(wbEdit, id, { credentials: credentialsAlt })
20 |     await addAlias(wbEdit, id, { credentials: secondUserCredentials })
21 |     await addAlias(wbEdit, id, { anonymous: true })
22 |     await addAlias(wbEdit, id, { credentials: credentialsAlt })
23 |     const revisions = await getEntityHistory(id)
24 |     const addAliasRevisions = revisions.slice(-5)
25 |     addAliasRevisions[0].anon.should.equal('')
26 |     addAliasRevisions[1].user.should.equal(credentialsAlt.username)
27 |     addAliasRevisions[2].user.should.equal(secondUserCredentials.username)
28 |     addAliasRevisions[3].anon.should.equal('')
29 |     addAliasRevisions[4].user.should.equal(credentialsAlt.username)
30 |   })
31 | })
32 | 
33 | async function addAlias (wbEdit, id, reqConfig) {
34 |   return wbEdit.alias.add({
35 |     id,
36 |     language: 'la',
37 |     value: randomString(),
38 |   }, reqConfig)
39 | }
40 | 


--------------------------------------------------------------------------------
/src/lib/claim/quantity.ts:
--------------------------------------------------------------------------------
 1 | import { isItemId, type QuantitySnakDataValue } from 'wikibase-sdk'
 2 | import { newError } from '../error.js'
 3 | import { isPlainObject, isSignedStringNumber, isString, isStringNumber } from '../utils.js'
 4 | import type { AbsoluteUrl } from '../types/common.js'
 5 | 
 6 | const itemUnitPattern = /^http.*\/entity\/(Q\d+)$/
 7 | 
 8 | export function parseQuantity (amount: number | string | QuantitySnakDataValue['value'], instance: AbsoluteUrl) {
 9 |   let unit, upperBound, lowerBound
10 |   if (isPlainObject(amount)) ({ amount, unit, upperBound, lowerBound } = amount)
11 |   if (isItemId(unit)) unit = `${forceHttp(instance)}/entity/${unit}`
12 |   validateNumber('amount', amount)
13 |   validateNumber('upperBound', upperBound)
14 |   validateNumber('lowerBound', lowerBound)
15 |   unit = unit || '1'
16 |   return { amount: signAmount(amount), unit, upperBound, lowerBound }
17 | }
18 | export function parseUnit (unit: string) {
19 |   if (unit.match(itemUnitPattern)) unit = unit.replace(itemUnitPattern, '$1')
20 |   return unit
21 | }
22 | 
23 | function signAmount (amount: string | number) {
24 |   let amountNum: number
25 |   if (isSignedStringNumber(amount)) return `${amount}`
26 |   else if (typeof amount === 'number') amountNum = amount
27 |   else if (isStringNumber(amount)) amountNum = parseFloat(amount)
28 |   if (amountNum === 0) return '0'
29 |   return amountNum > 0 ? `+${amountNum}` : `${amountNum}`
30 | }
31 | 
32 | const forceHttp = (instance: string) => instance.replace('https:', 'http:')
33 | 
34 | function validateNumber (label: string, num: string | number) {
35 |   if (isString(num) && !isStringNumber(num)) {
36 |     throw newError('invalid string number', { [label]: num })
37 |   }
38 | }
39 | 


--------------------------------------------------------------------------------
/tests/integration/entity/create.ts:
--------------------------------------------------------------------------------
 1 | import 'should'
 2 | import config from 'config'
 3 | import { getSandboxPropertyId } from '#tests/integration/utils/sandbox_entities'
 4 | import { waitForInstance } from '#tests/integration/utils/wait_for_instance'
 5 | import { randomString } from '#tests/unit/utils'
 6 | import WBEdit from '#root'
 7 | import type { EditEntitySimplifiedModeParams } from '../../../src/lib/entity/edit'
 8 | 
 9 | const wbEdit = WBEdit(config)
10 | 
11 | describe('entity create', function () {
12 |   this.timeout(20 * 1000)
13 |   before('wait for instance', waitForInstance)
14 | 
15 |   it('should create a property', async () => {
16 |     const res = await wbEdit.entity.create({
17 |       type: 'property',
18 |       datatype: 'external-id',
19 |       labels: {
20 |         en: randomString(),
21 |       },
22 |     } as EditEntitySimplifiedModeParams)
23 |     res.success.should.equal(1)
24 |     res.entity.type.should.equal('property')
25 |   })
26 | 
27 |   it('should create an item', async () => {
28 |     const [ pidA, pidB, pidC ] = await Promise.all([
29 |       getSandboxPropertyId('string'),
30 |       getSandboxPropertyId('external-id'),
31 |       getSandboxPropertyId('url'),
32 |     ])
33 |     const claims = {}
34 |     claims[pidA] = { value: randomString(), qualifiers: {}, references: {} }
35 |     claims[pidA].qualifiers[pidB] = randomString()
36 |     claims[pidA].references[pidC] = 'http://foo.bar'
37 |     const res = await wbEdit.entity.create({
38 |       type: 'item',
39 |       labels: { en: randomString() },
40 |       descriptions: { en: randomString() },
41 |       aliases: { en: randomString() },
42 |       claims,
43 |     } as EditEntitySimplifiedModeParams)
44 |     res.success.should.equal(1)
45 |   })
46 | })
47 | 


--------------------------------------------------------------------------------
/tests/integration/qualifier/set.ts:
--------------------------------------------------------------------------------
 1 | import 'should'
 2 | import config from 'config'
 3 | import { getSandboxClaimId, getSandboxPropertyId } from '#tests/integration/utils/sandbox_entities'
 4 | import { waitForInstance } from '#tests/integration/utils/wait_for_instance'
 5 | import { assert, randomString } from '#tests/unit/utils'
 6 | import WBEdit from '#root'
 7 | 
 8 | const wbEdit = WBEdit(config)
 9 | const setQualifier = wbEdit.qualifier.set
10 | 
11 | describe('qualifier set', function () {
12 |   this.timeout(20 * 1000)
13 |   before('wait for instance', waitForInstance)
14 | 
15 |   it('should set a qualifier', async () => {
16 |     const [ guid, property ] = await Promise.all([
17 |       getSandboxClaimId(),
18 |       getSandboxPropertyId('string'),
19 |     ])
20 |     const value = randomString()
21 |     const res = await setQualifier({ guid, property, value })
22 |     res.success.should.equal(1)
23 |     const qualifier = res.claim.qualifiers[property].slice(-1)[0]
24 |     assert('datavalue' in qualifier)
25 |     qualifier.datavalue.value.should.equal(value)
26 |   })
27 | 
28 |   it('should set a qualifier with a custom calendar', async () => {
29 |     const [ guid, property ] = await Promise.all([
30 |       getSandboxClaimId(),
31 |       getSandboxPropertyId('time'),
32 |     ])
33 |     const res = await setQualifier({ guid, property, value: { time: '1802-02-26', calendar: 'julian' } })
34 |     res.success.should.equal(1)
35 |     const qualifier = res.claim.qualifiers[property].slice(-1)[0]
36 |     assert('datavalue' in qualifier)
37 |     assert(typeof qualifier.datavalue.value === 'object')
38 |     assert('calendarmodel' in qualifier.datavalue.value)
39 |     qualifier.datavalue.value.calendarmodel.should.equal('http://www.wikidata.org/entity/Q1985786')
40 |   })
41 | })
42 | 


--------------------------------------------------------------------------------
/tests/integration/claim/reconciliation_skip_on_any_value.ts:
--------------------------------------------------------------------------------
 1 | import 'should'
 2 | import config from 'config'
 3 | import { getSandboxPropertyId, getReservedItemId } from '#tests/integration/utils/sandbox_entities'
 4 | import { waitForInstance } from '#tests/integration/utils/wait_for_instance'
 5 | import WBEdit from '#root'
 6 | import { assert } from '../../unit/utils'
 7 | 
 8 | const wbEdit = WBEdit(config)
 9 | 
10 | describe('reconciliation: skip-on-any-value mode', function () {
11 |   this.timeout(20 * 1000)
12 |   before('wait for instance', waitForInstance)
13 | 
14 |   it('should add a statement when no statement exists for that property', async () => {
15 |     const [ id, property ] = await Promise.all([
16 |       getReservedItemId(),
17 |       getSandboxPropertyId('string'),
18 |     ])
19 |     const res = await wbEdit.claim.create({
20 |       id,
21 |       property,
22 |       value: 'foo',
23 |       reconciliation: {
24 |         mode: 'skip-on-any-value',
25 |       },
26 |     })
27 |     assert('datavalue' in res.claim.mainsnak)
28 |     res.claim.mainsnak.datavalue.value.should.equal('foo')
29 |   })
30 | 
31 |   it('should not add a statement when a statement exists for that property', async () => {
32 |     const [ id, property ] = await Promise.all([
33 |       getReservedItemId(),
34 |       getSandboxPropertyId('string'),
35 |     ])
36 |     const res = await wbEdit.claim.create({ id, property, value: 'foo' })
37 |     const res2 = await wbEdit.claim.create({
38 |       id,
39 |       property,
40 |       value: 'bar',
41 |       reconciliation: {
42 |         mode: 'skip-on-any-value',
43 |       },
44 |     })
45 |     res2.claim.id.should.equal(res.claim.id)
46 |     assert('datavalue' in res2.claim.mainsnak)
47 |     res2.claim.mainsnak.datavalue.value.should.equal('foo')
48 |   })
49 | })
50 | 


--------------------------------------------------------------------------------
/tests/integration/label/set.ts:
--------------------------------------------------------------------------------
 1 | import config from 'config'
 2 | import should from 'should'
 3 | import { getSandboxItemId, getRefreshedEntity } from '#tests/integration/utils/sandbox_entities'
 4 | import { waitForInstance } from '#tests/integration/utils/wait_for_instance'
 5 | import { randomString } from '#tests/unit/utils'
 6 | import WBEdit from '#root'
 7 | 
 8 | const wbEdit = WBEdit(config)
 9 | const language = 'fr'
10 | 
11 | describe('label set', function () {
12 |   this.timeout(20 * 1000)
13 |   before('wait for instance', waitForInstance)
14 | 
15 |   it('should set a label', async () => {
16 |     const id = await getSandboxItemId()
17 |     const value = `Bac à Sable (${randomString()})`
18 |     const res = await wbEdit.label.set({ id, language, value })
19 |     res.success.should.equal(1)
20 |     const item = await getRefreshedEntity(id)
21 |     item.labels[language].value.should.equal(value)
22 |   })
23 | 
24 |   it('should remove a label when passed value=null', async () => {
25 |     const id = await getSandboxItemId()
26 |     const value = `Bac à Sable (${randomString()})`
27 |     await wbEdit.label.set({ id, language, value })
28 |     const res = await wbEdit.label.set({ id, language, value: '' })
29 |     res.success.should.equal(1)
30 |     const item = await getRefreshedEntity(id)
31 |     should(item.labels[language]).not.be.ok()
32 |   })
33 | 
34 |   it('should remove a label when passed value=""', async () => {
35 |     const id = await getSandboxItemId()
36 |     const value = `Bac à Sable (${randomString()})`
37 |     await wbEdit.label.set({ id, language, value })
38 |     const res = await wbEdit.label.set({ id, language, value: '' })
39 |     res.success.should.equal(1)
40 |     const item = await getRefreshedEntity(id)
41 |     should(item.labels[language]).not.be.ok()
42 |   })
43 | })
44 | 


--------------------------------------------------------------------------------
/tests/integration/token_expiration.ts:
--------------------------------------------------------------------------------
 1 | import 'should'
 2 | import config from 'config'
 3 | import { getSandboxItemId } from '#tests/integration/utils/sandbox_entities'
 4 | import { wait } from '#tests/integration/utils/utils'
 5 | import { waitForInstance } from '#tests/integration/utils/wait_for_instance'
 6 | import { randomString } from '#tests/unit/utils'
 7 | import WBEdit from '#root'
 8 | 
 9 | const { instance, credentials, credentialsAlt } = config
10 | const language = 'fr'
11 | 
12 | describe('token expiration', function () {
13 |   this.timeout(24 * 60 * 60 * 1000)
14 | 
15 |   before('wait for instance', waitForInstance)
16 | 
17 |   xit('should renew tokens (oauth)', async () => {
18 |     const wbEdit = WBEdit({ instance, credentials })
19 |     const id = await getSandboxItemId()
20 |     const doActionRequiringAuthPeriodically = async () => {
21 |       const value = randomString()
22 |       const res = await wbEdit.alias.add({ id, language, value })
23 |       res.success.should.equal(1)
24 |       console.log(new Date().toISOString(), 'added alias', value)
25 |       await wait(60 * 1000)
26 |       return doActionRequiringAuthPeriodically()
27 |     }
28 |     await doActionRequiringAuthPeriodically()
29 |   })
30 | 
31 |   xit('should renew tokens (username/password)', async () => {
32 |     const wbEdit = WBEdit({ instance, credentials: credentialsAlt })
33 |     const id = await getSandboxItemId()
34 |     const doActionRequiringAuthPeriodically = async () => {
35 |       const value = randomString()
36 |       const res = await wbEdit.alias.add({ id, language, value })
37 |       res.success.should.equal(1)
38 |       console.log(new Date().toISOString(), 'added alias', value)
39 |       await wait(60 * 1000)
40 |       return doActionRequiringAuthPeriodically()
41 |     }
42 |     await doActionRequiringAuthPeriodically()
43 |   })
44 | })
45 | 


--------------------------------------------------------------------------------
/tests/integration/description/set.ts:
--------------------------------------------------------------------------------
 1 | import config from 'config'
 2 | import should from 'should'
 3 | import { getSandboxItemId, getRefreshedEntity } from '#tests/integration/utils/sandbox_entities'
 4 | import { waitForInstance } from '#tests/integration/utils/wait_for_instance'
 5 | import { randomString } from '#tests/unit/utils'
 6 | import WBEdit from '#root'
 7 | 
 8 | const wbEdit = WBEdit(config)
 9 | const language = 'fr'
10 | 
11 | describe('description set', function () {
12 |   this.timeout(20 * 1000)
13 |   before('wait for instance', waitForInstance)
14 | 
15 |   it('should set a description', async () => {
16 |     const id = await getSandboxItemId()
17 |     const value = `Bac à Sable (${randomString()})`
18 |     const res = await wbEdit.description.set({ id, language, value })
19 |     res.success.should.equal(1)
20 |     const item = await getRefreshedEntity(id)
21 |     item.descriptions[language].value.should.equal(value)
22 |   })
23 | 
24 |   it('should remove a description when passed value=null', async () => {
25 |     const id = await getSandboxItemId()
26 |     const value = `Bac à Sable (${randomString()})`
27 |     await wbEdit.description.set({ id, language, value })
28 |     const res = await wbEdit.description.set({ id, language, value: null })
29 |     res.success.should.equal(1)
30 |     const item = await getRefreshedEntity(id)
31 |     should(item.descriptions[language]).not.be.ok()
32 |   })
33 | 
34 |   it('should remove a description when passed value=""', async () => {
35 |     const id = await getSandboxItemId()
36 |     const value = `Bac à Sable (${randomString()})`
37 |     await wbEdit.description.set({ id, language, value })
38 |     const res = await wbEdit.description.set({ id, language, value: '' })
39 |     res.success.should.equal(1)
40 |     const item = await getRefreshedEntity(id)
41 |     should(item.descriptions[language]).not.be.ok()
42 |   })
43 | })
44 | 


--------------------------------------------------------------------------------
/src/lib/resolve_title.ts:
--------------------------------------------------------------------------------
 1 | // A module that turns entity ids into full mediawiki page titles, by checking
 2 | // the Wikibase custom namespace configuration
 3 | // ex: P1 => Property:P1, Q1 => Q1 OR Item:Q1
 4 | 
 5 | import { isEntityId, type EntityId, type EntityPageTitle } from 'wikibase-sdk'
 6 | import { newError } from './error.js'
 7 | import { getJson } from './request/get_json.js'
 8 | import type { AbsoluteUrl } from './types/common.js'
 9 | 
10 | let prefixesMapPromise
11 | 
12 | export async function resolveTitle (title: EntityId, instanceApiEndpoint: AbsoluteUrl) {
13 |   if (!isEntityId(title)) throw newError('expected entity id as title')
14 |   prefixesMapPromise = prefixesMapPromise || getPrefixesMap(instanceApiEndpoint)
15 |   const prefixesMap = await prefixesMapPromise
16 |   const idFirstLetter = title[0]
17 |   const prefix = prefixesMap[idFirstLetter]
18 |   return (prefix === '' ? title : `${prefix}:${title}`) as EntityPageTitle
19 | }
20 | 
21 | async function getPrefixesMap (instanceApiEndpoint: AbsoluteUrl) {
22 |   const infoUrl = `${instanceApiEndpoint}?action=query&meta=siteinfo&siprop=namespaces&format=json` as AbsoluteUrl
23 |   const res = await getJson(infoUrl)
24 |   return parsePrefixesMap(res)
25 | }
26 | 
27 | interface Namespace {
28 |   defaultcontentmodel: string
29 |   '*': string
30 | }
31 | 
32 | interface NamespacesResponse {
33 |   query: {
34 |     namespaces: Namespace[]
35 |   }
36 | }
37 | 
38 | function parsePrefixesMap (res: NamespacesResponse) {
39 |   return Object.values(res.query.namespaces)
40 |   .filter(namespace => namespace.defaultcontentmodel)
41 |   .filter(namespace => namespace.defaultcontentmodel.startsWith('wikibase'))
42 |   .reduce(aggregatePrefixes, {})
43 | }
44 | 
45 | function aggregatePrefixes (prefixesMap: Record, namespace: Namespace) {
46 |   const { defaultcontentmodel, '*': prefix } = namespace
47 |   const type = defaultcontentmodel.split('-')[1]
48 |   const firstLetter = type === 'item' ? 'Q' : type[0].toUpperCase()
49 |   prefixesMap[firstLetter] = prefix
50 |   return prefixesMap
51 | }
52 | 


--------------------------------------------------------------------------------
/tests/integration/get_auth_data.ts:
--------------------------------------------------------------------------------
 1 | import config from 'config'
 2 | import should from 'should'
 3 | import { getAuthDataFactory } from '#lib/request/get_auth_data'
 4 | import { validateAndEnrichConfig } from '#lib/validate_and_enrich_config'
 5 | import { isBotPassword } from '#tests/integration/utils/utils'
 6 | 
 7 | const { instance, credentials, credentialsAlt } = config
 8 | 
 9 | const { username, password } = credentialsAlt
10 | 
11 | describe('get auth data', function () {
12 |   this.timeout(10000)
13 | 
14 |   it('should get token from username and password', async () => {
15 |     const config = validateAndEnrichConfig({ instance, credentials: { username, password } })
16 |     const getAuthData = getAuthDataFactory(config)
17 |     getAuthData.should.be.a.Function()
18 |     const { token, cookie } = await getAuthData()
19 |     token.length.should.equal(42)
20 |     if (!isBotPassword(password)) {
21 |       should(/.+UserID=\d+/.test(cookie)).be.true('should contain user ID')
22 |     }
23 |     should(/.+[sS]ession=\w{32}/.test(cookie)).be.true('should contain session ID')
24 |   })
25 | 
26 |   it('should get token from oauth', async () => {
27 |     const config = validateAndEnrichConfig({ instance, credentials })
28 |     const getAuthData = getAuthDataFactory(config)
29 |     const { token } = await getAuthData()
30 |     token.length.should.equal(42)
31 |   })
32 | 
33 |   it('should return the same data when called before the token expired', async () => {
34 |     const config = validateAndEnrichConfig({ instance, credentials })
35 |     const getAuthData = getAuthDataFactory(config)
36 |     const dataA = await getAuthData()
37 |     const dataB = await getAuthData()
38 |     dataA.should.equal(dataB)
39 |     dataA.token.should.equal(dataB.token)
40 |   })
41 | 
42 |   it('should return refresh data if requested', async () => {
43 |     const config = validateAndEnrichConfig({ instance, credentials })
44 |     const getAuthData = getAuthDataFactory(config)
45 |     const dataA = await getAuthData()
46 |     const dataB = await getAuthData({ refresh: true })
47 |     dataA.should.not.equal(dataB)
48 |   })
49 | })
50 | 


--------------------------------------------------------------------------------
/src/lib/qualifier/set.ts:
--------------------------------------------------------------------------------
 1 | import { singleClaimBuilders as builders } from '../claim/builders.js'
 2 | import { hasSpecialSnaktype } from '../claim/special_snaktype.js'
 3 | import { newError } from '../error.js'
 4 | import { normalizeDatatype } from '../properties/datatypes_to_builder_datatypes.js'
 5 | import { validateGuid, validateHash, validatePropertyId, validateSnakValue } from '../validate.js'
 6 | import type { PropertiesDatatypes } from '../properties/fetch_properties_datatypes.js'
 7 | import type { AbsoluteUrl, BaseRevId } from '../types/common.js'
 8 | import type { SimpifiedEditableQualifier } from '../types/edit_entity.js'
 9 | import type { Claim, Guid, Hash, PropertyId, SnakType } from 'wikibase-sdk'
10 | 
11 | export interface SetQualifierParams {
12 |   guid: Guid
13 |   property: PropertyId
14 |   value: SimpifiedEditableQualifier
15 |   hash?: Hash
16 |   summary?: string
17 |   baserevid?: BaseRevId
18 | }
19 | 
20 | export interface WbsetqualifierData {
21 |   claim: Guid
22 |   property: PropertyId
23 |   snakhash?: Hash
24 |   snaktype?: SnakType
25 |   value?: SimpifiedEditableQualifier
26 | }
27 | 
28 | export function setQualifier (params: SetQualifierParams, properties: PropertiesDatatypes, instance: AbsoluteUrl) {
29 |   const { guid, hash, property, value } = params
30 | 
31 |   validateGuid(guid)
32 |   validatePropertyId(property)
33 |   const datatype = properties[property]
34 |   if (!datatype) throw newError('missing datatype', params)
35 |   validateSnakValue(property, datatype, value)
36 | 
37 |   const data: WbsetqualifierData = {
38 |     claim: guid,
39 |     property,
40 |   }
41 | 
42 |   if (hash != null) {
43 |     validateHash(hash)
44 |     data.snakhash = hash
45 |   }
46 | 
47 |   if (hasSpecialSnaktype(value)) {
48 |     data.snaktype = value.snaktype
49 |   } else {
50 |     data.snaktype = 'value'
51 |     const builderDatatype = normalizeDatatype(datatype) || datatype
52 |     data.value = builders[builderDatatype](value, instance)
53 |   }
54 | 
55 |   return { action: 'wbsetqualifier', data }
56 | }
57 | 
58 | export interface SetQualifierResponse {
59 |   pageinfo: { lastrevid: number }
60 |   success: 1
61 |   claim: Claim
62 | }
63 | 


--------------------------------------------------------------------------------
/src/lib/reference/set.ts:
--------------------------------------------------------------------------------
 1 | import { buildSnak, buildReferenceFactory } from '../claim/snak.js'
 2 | import { validateGuid, validateHash, validatePropertyId, validateSnakValue } from '../validate.js'
 3 | import type { PropertiesDatatypes } from '../properties/fetch_properties_datatypes.js'
 4 | import type { AbsoluteUrl } from '../types/common.js'
 5 | import type { SimplifiedEditableReference, SimplifiedEditableSnaks } from '../types/edit_entity.js'
 6 | import type { Guid, Hash, PropertyId, Reference, Snak, Snaks } from 'wikibase-sdk'
 7 | 
 8 | export interface SetReferenceParams {
 9 |   guid: Guid
10 |   hash?: Hash
11 |   snaks?: SimplifiedEditableSnaks
12 |   /** @deprecated use the `snaks` object instead, to be able to set a single reference with several snaks  */
13 |   property?: PropertyId
14 |   /** @deprecated use the `snaks` object instead, to be able to set a single reference with several snaks  */
15 |   value?: SimplifiedEditableReference
16 | }
17 | 
18 | export function setReference (params: SetReferenceParams, properties: PropertiesDatatypes, instance: AbsoluteUrl) {
19 |   const { guid, property, value, hash } = params
20 |   const inputSnaks = params.snaks
21 |   let snaks: Snaks | Snak[]
22 |   if (inputSnaks) {
23 |     snaks = buildReferenceFactory(properties, instance)(inputSnaks).snaks
24 |   } else {
25 |     // Legacy interface
26 |     validateGuid(guid)
27 |     validatePropertyId(property)
28 |     const datatype = properties[property]
29 |     // @ts-expect-error
30 |     validateSnakValue(property, datatype, value)
31 |     snaks = {}
32 |     // @ts-expect-error
33 |     snaks[property] = [ buildSnak(property, datatype, value, instance) ]
34 |   }
35 | 
36 |   const data: SetReferenceData = {
37 |     statement: guid,
38 |     snaks: JSON.stringify(snaks),
39 |   }
40 | 
41 |   if (hash) {
42 |     validateHash(hash)
43 |     data.reference = hash
44 |   }
45 | 
46 |   return { action: 'wbsetreference', data }
47 | }
48 | 
49 | interface SetReferenceData {
50 |   statement: Guid
51 |   snaks: string
52 |   reference?: Hash
53 | }
54 | 
55 | export interface SetReferenceResponse {
56 |   pageinfo: { lastrevid: number }
57 |   success: 1
58 |   reference: Reference
59 | }
60 | 


--------------------------------------------------------------------------------
/src/lib/bundle_wrapper.ts:
--------------------------------------------------------------------------------
 1 | import { newError } from './error.js'
 2 | import { fetchUsedPropertiesDatatypes } from './properties/fetch_used_properties_datatypes.js'
 3 | import { validateAndEnrichConfig } from './validate_and_enrich_config.js'
 4 | import type { addBadge } from './badge/add.js'
 5 | import type { removeBadge } from './badge/remove.js'
 6 | import type { createClaim } from './claim/create.js'
 7 | import type { moveClaims } from './claim/move.js'
 8 | import type { updateClaim } from './claim/update.js'
 9 | import type { moveQualifier } from './qualifier/move.js'
10 | import type { updateQualifier } from './qualifier/update.js'
11 | import type { GeneralConfig, RequestConfig } from './types/config.js'
12 | 
13 | type ActionFunction = typeof createClaim | typeof updateClaim | typeof moveClaims | typeof updateQualifier | typeof moveQualifier | typeof addBadge | typeof removeBadge
14 | 
15 | // Can't use API type definition, as that would trigger "TS2502 API is referenced directly or indirectly in its own type annotation"
16 | export function bundleWrapper  (fn: ActionFunction, generalConfig: GeneralConfig, API) {
17 |   return async function (params: Params, reqConfig?: RequestConfig): Promise {
18 |     validateParams(params)
19 |     const config = validateAndEnrichConfig(generalConfig, reqConfig)
20 |     await fetchUsedPropertiesDatatypes(params, config)
21 |     // @ts-expect-error
22 |     return fn(params, config, API)
23 |   }
24 | }
25 | 
26 | function validateParams (params) {
27 |   for (const parameter in params) {
28 |     if (!validParametersKeysSet.has(parameter)) {
29 |       throw newError(`invalid parameter: ${parameter}`, { parameter, validParametersKeys })
30 |     }
31 |   }
32 | }
33 | 
34 | const validParametersKeys = [
35 |   'baserevid',
36 |   'guid',
37 |   'hash',
38 |   'id',
39 |   'newProperty',
40 |   'newValue',
41 |   'oldProperty',
42 |   'oldValue',
43 |   'property',
44 |   'propertyClaimsId',
45 |   'qualifiers',
46 |   'rank',
47 |   'reconciliation',
48 |   'references',
49 |   'summary',
50 |   'value',
51 |   'site',
52 |   'badges',
53 | ]
54 | 
55 | const validParametersKeysSet = new Set(validParametersKeys)
56 | 


--------------------------------------------------------------------------------
/tests/integration/utils/get_property.ts:
--------------------------------------------------------------------------------
 1 | import config from 'config'
 2 | import wbkFactory, { type Datatype, type Property } from 'wikibase-sdk'
 3 | import { customFetch } from '#lib/request/fetch'
 4 | import { randomString } from '#tests/unit/utils'
 5 | import WBEdit from '#root'
 6 | import type { EditEntitySimplifiedModeParams } from '../../../src/lib/entity/edit'
 7 | import type { AbsoluteUrl } from '../../../src/lib/types/common'
 8 | 
 9 | const wbk = wbkFactory({ instance: config.instance })
10 | const sandboxProperties = {}
11 | const wbEdit = WBEdit(config)
12 | 
13 | export async function getProperty ({ datatype, reserved }: { datatype: Datatype, reserved?: boolean }): Promise {
14 |   if (!datatype) throw new Error('missing datatype')
15 |   if (reserved) return createProperty(datatype)
16 |   const property = await _getProperty(datatype)
17 |   sandboxProperties[datatype] = property
18 |   return property
19 | }
20 | 
21 | async function _getProperty (datatype: Datatype) {
22 |   const pseudoPropertyId = getPseudoPropertyId(datatype)
23 | 
24 |   const cachedPropertyId = sandboxProperties[pseudoPropertyId]
25 | 
26 |   if (cachedPropertyId) return cachedPropertyId
27 | 
28 |   const foundPropertyId = await findOnWikibase(pseudoPropertyId)
29 |   if (foundPropertyId) return foundPropertyId
30 |   else return createProperty(datatype)
31 | }
32 | 
33 | async function findOnWikibase (pseudoPropertyId: string) {
34 |   const url = wbk.searchEntities({ search: pseudoPropertyId, type: 'property' }) as AbsoluteUrl
35 |   const body = await customFetch(url).then(res => res.json())
36 |   const firstWbResult = body.search[0]
37 |   if (firstWbResult) return firstWbResult
38 | }
39 | 
40 | async function createProperty (datatype: Datatype) {
41 |   const pseudoPropertyId = getPseudoPropertyId(datatype)
42 |   const res = await wbEdit.entity.create({
43 |     type: 'property',
44 |     datatype,
45 |     labels: {
46 |       // Including a random string to avoid conflicts in case a property with that pseudoPropertyId
47 |       // already exist but wasn't found due to a problem in ElasticSearch
48 |       en: `${pseudoPropertyId} (${randomString()})`,
49 |     },
50 |   } as EditEntitySimplifiedModeParams)
51 |   return res.entity as Property
52 | }
53 | 
54 | const getPseudoPropertyId = (datatype: Datatype) => `${datatype} sandbox property`
55 | 


--------------------------------------------------------------------------------
/src/lib/properties/fetch_properties_datatypes.ts:
--------------------------------------------------------------------------------
 1 | import { newError } from '../error.js'
 2 | import WBK from '../get_instance_wikibase_sdk.js'
 3 | import { getJson } from '../request/get_json.js'
 4 | import { validatePropertyId } from '../validate.js'
 5 | import type { AbsoluteUrl } from '../types/common.js'
 6 | import type { SerializedConfig } from '../types/config.js'
 7 | import type { Datatype, Property, PropertyId, WbGetEntitiesResponse } from 'wikibase-sdk'
 8 | 
 9 | export type PropertiesDatatypes = Record
10 | 
11 | const propertiesByInstance: Record = {}
12 | 
13 | export async function fetchPropertiesDatatypes (config: SerializedConfig, propertyIds: PropertyId[] = []) {
14 |   let { instance, properties } = config
15 | 
16 |   propertyIds.forEach(validatePropertyId)
17 | 
18 |   if (!properties) {
19 |     propertiesByInstance[instance] = propertiesByInstance[instance] || {}
20 |     config.properties = properties = propertiesByInstance[instance]
21 |   }
22 | 
23 |   const missingPropertyIds = propertyIds.filter(notIn(properties))
24 | 
25 |   if (missingPropertyIds.length === 0) return
26 | 
27 |   const urls = WBK(instance).getManyEntities({ ids: missingPropertyIds, props: 'info' })
28 | 
29 |   const headers = { 'user-agent': config.userAgent }
30 |   const responses: WbGetEntitiesResponse[] = await Promise.all(urls.map(url => getJson(url, { headers })))
31 |   const responsesEntities = responses.map(parseResponse)
32 |   const allEntities = Object.assign({}, ...responsesEntities)
33 |   missingPropertyIds.forEach(addMissingPropertyFactory(allEntities, properties, instance))
34 | }
35 | 
36 | const notIn = object => key => object[key] == null
37 | 
38 | type PropertiesByIds = Record
39 | 
40 | function parseResponse ({ entities, error }: WbGetEntitiesResponse) {
41 |   if (error) throw newError(error.info, 400, error)
42 |   return entities as PropertiesByIds
43 | }
44 | 
45 | function addMissingPropertyFactory (entities: PropertiesByIds, properties: PropertiesDatatypes, instance: AbsoluteUrl) {
46 |   return function addMissingProperty (propertyId: PropertyId) {
47 |     const property = entities[propertyId]
48 |     if (!(property?.datatype)) throw newError('property not found', { propertyId, instance })
49 |     properties[propertyId] = property.datatype
50 |   }
51 | }
52 | 


--------------------------------------------------------------------------------
/src/lib/claim/remove.ts:
--------------------------------------------------------------------------------
 1 | import { buildClaim } from '../entity/build_claim.js'
 2 | import { newError } from '../error.js'
 3 | import { getEntityClaims } from '../get_entity.js'
 4 | import { forceArray } from '../utils.js'
 5 | import { validateGuid } from '../validate.js'
 6 | import { isMatchingClaimFactory } from './is_matching_claim.js'
 7 | import type { Reconciliation } from '../entity/validate_reconciliation_object.js'
 8 | import type { PropertiesDatatypes } from '../properties/fetch_properties_datatypes.js'
 9 | import type { AbsoluteUrl } from '../types/common.js'
10 | import type { SerializedConfig } from '../types/config.js'
11 | import type { SimplifiedEditableQualifiers } from '../types/edit_entity.js'
12 | import type { Claim, EntityWithClaims, Guid, PropertyId, SimplifiedClaim } from 'wikibase-sdk'
13 | 
14 | export interface RemoveClaimParams {
15 |   id?: EntityWithClaims['id']
16 |   property?: PropertyId
17 |   guid?: Guid | Guid[]
18 |   value?: SimplifiedClaim
19 |   qualifiers?: SimplifiedEditableQualifiers
20 |   reconciliation?: Reconciliation
21 | }
22 | 
23 | export async function removeClaim (params: RemoveClaimParams, properties: PropertiesDatatypes, instance: AbsoluteUrl, config: SerializedConfig) {
24 |   let { guid } = params
25 |   const { id, property, value, qualifiers, reconciliation = {} } = params
26 |   if (!(guid || (id && property && value))) {
27 |     throw newError('missing guid or id/property/value', params)
28 |   }
29 | 
30 |   let guids: Guid[]
31 |   if (guid) {
32 |     guids = forceArray(guid)
33 |   } else {
34 |     const existingClaims = await getEntityClaims(id, config)
35 |     const claimData = { value, qualifiers }
36 |     // @ts-expect-error
37 |     const claim: Claim = buildClaim(property, properties, claimData, instance)
38 |     const { matchingQualifiers } = reconciliation
39 |     const matchingClaims = existingClaims[property].filter(isMatchingClaimFactory(claim, matchingQualifiers))
40 |     if (matchingClaims.length === 0) throw newError('claim not found', params)
41 |     guids = matchingClaims.map(({ id }) => id)
42 |   }
43 | 
44 |   guids.forEach(validateGuid)
45 | 
46 |   return {
47 |     action: 'wbremoveclaims',
48 |     data: {
49 |       claim: guids.join('|'),
50 |     },
51 |   }
52 | }
53 | 
54 | export interface RemoveClaimResponse {
55 |   pageinfo: { lastrevid: number }
56 |   success: 1
57 |   claims: Guid[]
58 | }
59 | 


--------------------------------------------------------------------------------
/src/lib/claim/create.ts:
--------------------------------------------------------------------------------
 1 | import { newError } from '../error.js'
 2 | import { hasSpecialSnaktype } from './special_snaktype.js'
 3 | import type { Reconciliation } from '../entity/validate_reconciliation_object.js'
 4 | import type { WikibaseEditAPI } from '../index.js'
 5 | import type { BaseRevId } from '../types/common.js'
 6 | import type { SerializedConfig } from '../types/config.js'
 7 | import type { RawEditableEntity, CustomSimplifiedEditableClaim, SimplifiedEditableQualifiers, SimplifiedEditableReferences } from '../types/edit_entity.js'
 8 | import type { EditableSnakValue } from '../types/snaks.js'
 9 | import type { Claim, PropertyId, Rank } from 'wikibase-sdk'
10 | 
11 | export interface CreateClaimParams {
12 |   id: RawEditableEntity['id']
13 |   property: PropertyId
14 |   value: EditableSnakValue
15 |   qualifiers?: SimplifiedEditableQualifiers
16 |   references?: SimplifiedEditableReferences
17 |   rank?: Rank
18 |   reconciliation?: Reconciliation
19 |   summary?: string
20 |   baserevid?: BaseRevId
21 | }
22 | 
23 | export async function createClaim (params: CreateClaimParams, config: SerializedConfig, API: WikibaseEditAPI) {
24 |   const { id, property, value, qualifiers, references, rank, reconciliation } = params
25 |   const { statementsKey } = config
26 | 
27 |   if (value == null) throw newError('missing value', 400, params)
28 | 
29 |   const claim: Partial = { rank, qualifiers, references }
30 |   if (hasSpecialSnaktype(value)) {
31 |     claim.snaktype = value.snaktype
32 |   } else {
33 |     claim.value = value
34 |   }
35 | 
36 |   let summary = params.summary || config.summary
37 | 
38 |   if (!summary) {
39 |     const stringifiedValue = typeof value === 'string' ? value : JSON.stringify(value)
40 |     summary = `add ${property} claim: ${stringifiedValue}`
41 |   }
42 | 
43 |   const data = {
44 |     id,
45 |     [statementsKey]: {
46 |       [property]: claim,
47 |     },
48 |     summary,
49 |     baserevid: params.baserevid || config.baserevid,
50 |     reconciliation,
51 |   }
52 | 
53 |   // Using wbeditentity, as the endpoint is more complete
54 |   const { entity, success } = await API.entity.edit(data, config)
55 | 
56 |   const newClaim: Claim = entity[statementsKey][property].slice(-1)[0]
57 |   // Mimick claim actions responses
58 |   return { claim: newClaim, success }
59 | }
60 | 
61 | export interface CreateClaimResponse {
62 |   claim: Claim
63 |   success: 1
64 | }
65 | 


--------------------------------------------------------------------------------
/src/lib/claim/snak.ts:
--------------------------------------------------------------------------------
 1 | import { flatten, values } from 'lodash-es'
 2 | import { newError } from '../error.js'
 3 | import { normalizeDatatype } from '../properties/datatypes_to_builder_datatypes.js'
 4 | import { forceArray, mapValues } from '../utils.js'
 5 | import { validatePropertyId, validateSnakValue } from '../validate.js'
 6 | import { entityEditBuilders as builders } from './builders.js'
 7 | import type { PropertiesDatatypes } from '../properties/fetch_properties_datatypes.js'
 8 | import type { AbsoluteUrl } from '../types/common.js'
 9 | import type { SimplifiedEditableSnak, SimplifiedEditableClaim, SimplifiedEditableReference } from '../types/edit_entity.js'
10 | import type { PropertyId, Datatype, Snak } from 'wikibase-sdk'
11 | 
12 | export function buildSnak (property: PropertyId, datatype: Datatype, value: SimplifiedEditableSnak | SimplifiedEditableClaim, instance: AbsoluteUrl) {
13 |   const datavalueValue = (typeof value === 'object' && 'value' in value) ? value.value : value
14 |   if (typeof value === 'object' && 'snaktype' in value && value?.snaktype && value.snaktype !== 'value') {
15 |     return { snaktype: value.snaktype, property }
16 |   }
17 |   const builderDatatype = normalizeDatatype(datatype)
18 |   return builders[builderDatatype](property, datavalueValue, instance).mainsnak as Snak
19 | }
20 | 
21 | export function buildReferenceFactory (properties: PropertiesDatatypes, instance: AbsoluteUrl) {
22 |   return function buildReference (reference: SimplifiedEditableReference) {
23 |     if (typeof reference !== 'object') throw newError('expected reference object', 500, { reference })
24 |     const hash = 'hash' in reference ? reference.hash : undefined
25 |     const referenceSnaks = 'snaks' in reference ? reference.snaks : reference
26 |     const snaksPerProperty = mapValues(referenceSnaks, buildPropSnaksFactory(properties, instance))
27 |     const snaks = flatten(values(snaksPerProperty)) as Snak[]
28 |     return { snaks, hash }
29 |   }
30 | }
31 | 
32 | export function buildPropSnaksFactory (properties: PropertiesDatatypes, instance: AbsoluteUrl) {
33 |   return function buildPropSnaks (prop: PropertyId, propSnakValues: SimplifiedEditableSnak[]) {
34 |     validatePropertyId(prop)
35 |     return forceArray(propSnakValues).map(snakValue => {
36 |       const datatype = properties[prop]
37 |       validateSnakValue(prop, datatype, snakValue)
38 |       return buildSnak(prop, datatype, snakValue, instance)
39 |     })
40 |   }
41 | }
42 | 


--------------------------------------------------------------------------------
/tests/unit/sitelink/set.ts:
--------------------------------------------------------------------------------
 1 | import should from 'should'
 2 | import { setSitelink } from '#lib/sitelink/set'
 3 | import { shouldNotBeCalled } from '#tests/integration/utils/utils'
 4 | import { assert } from '../utils'
 5 | 
 6 | describe('set sitelink', () => {
 7 |   it('should return wbsetsitelink params', () => {
 8 |     const { action, data } = setSitelink({
 9 |       id: 'Q123',
10 |       site: 'frwiki',
11 |       title: 'Septembre',
12 |     })
13 |     action.should.equal('wbsetsitelink')
14 |     data.id.should.equal('Q123')
15 |     data.linksite.should.equal('frwiki')
16 |     data.linktitle.should.equal('Septembre')
17 |     assert(!('badges' in data))
18 |   })
19 | 
20 |   it('should reject without title', () => {
21 |     try {
22 |       // @ts-expect-error
23 |       const res = setSitelink({
24 |         id: 'Q123',
25 |         site: 'frwiki',
26 |       })
27 |       shouldNotBeCalled(res)
28 |     } catch (err) {
29 |       err.message.should.containEql('invalid title')
30 |       err.statusCode.should.equal(400)
31 |     }
32 |   })
33 | 
34 |   it('should accept with a null title to delete the sitelink', () => {
35 |     const { action, data } = setSitelink({
36 |       id: 'Q123',
37 |       site: 'frwiki',
38 |       title: null,
39 |     })
40 |     action.should.equal('wbsetsitelink')
41 |     data.id.should.equal('Q123')
42 |     data.linksite.should.equal('frwiki')
43 |     should(data.linktitle).be.Undefined()
44 |   })
45 | 
46 |   it('should accept badges as a string', () => {
47 |     const { action, data } = setSitelink({
48 |       id: 'Q123',
49 |       site: 'frwiki',
50 |       title: 'Septembre',
51 |       badges: 'Q17437796|Q17437798',
52 |     })
53 |     action.should.equal('wbsetsitelink')
54 |     data.id.should.equal('Q123')
55 |     data.linksite.should.equal('frwiki')
56 |     data.linktitle.should.equal('Septembre')
57 |     assert('badges' in data)
58 |     data.badges.should.equal('Q17437796|Q17437798')
59 |   })
60 | 
61 |   it('should accept badges as an array', () => {
62 |     const { action, data } = setSitelink({
63 |       id: 'Q123',
64 |       site: 'frwiki',
65 |       title: 'Septembre',
66 |       badges: 'Q17437796|Q17437798',
67 |     })
68 |     action.should.equal('wbsetsitelink')
69 |     data.id.should.equal('Q123')
70 |     data.linksite.should.equal('frwiki')
71 |     data.linktitle.should.equal('Septembre')
72 |     assert('badges' in data)
73 |     data.badges.should.equal('Q17437796|Q17437798')
74 |   })
75 | })
76 | 


--------------------------------------------------------------------------------
/src/lib/entity/validate_reconciliation_object.ts:
--------------------------------------------------------------------------------
 1 | import { isPropertyId, type PropertyId } from 'wikibase-sdk'
 2 | import { newError } from '../error.js'
 3 | import { arrayIncludes } from '../utils.js'
 4 | 
 5 | const validReconciliationKeys = [ 'mode', 'matchingQualifiers', 'matchingReferences' ] as const
 6 | const validReconciliationModes = [ 'skip-on-value-match', 'skip-on-any-value', 'merge' ] as const
 7 | const validOptions = [ 'all', 'any' ] as const
 8 | 
 9 | export type ReconciliationMode = typeof validReconciliationModes[number]
10 | 
11 | type ReconciliationKeyOption = typeof validOptions[number]
12 | type ReconciliationKey = PropertyId | `${PropertyId}:${ReconciliationKeyOption}`
13 | 
14 | /**
15 |  * See https://github.com/maxlath/wikibase-edit/blob/main/docs/how_to.md#reconciliation
16 |  */
17 | export interface Reconciliation {
18 |   mode?: ReconciliationMode
19 |   matchingQualifiers?: ReconciliationKey[]
20 |   matchingReferences?: ReconciliationKey[]
21 | }
22 | 
23 | export function validateReconciliationObject (reconciliation: Reconciliation, claim) {
24 |   if (typeof reconciliation !== 'object') throw newError('reconciliation should be an object', { reconciliation })
25 |   for (const key of Object.keys(reconciliation)) {
26 |     if (!arrayIncludes(validReconciliationKeys, key)) {
27 |       throw newError('invalid reconciliation object key', { key, reconciliation, validReconciliationKeys })
28 |     }
29 |   }
30 |   const { mode, matchingQualifiers, matchingReferences } = reconciliation
31 |   if (!claim.remove && !validReconciliationModes.includes(mode)) {
32 |     throw newError('invalid reconciliation mode', { mode, validReconciliationModes })
33 |   }
34 | 
35 |   validateMatchingPropertyArray('matchingQualifiers', matchingQualifiers)
36 |   validateMatchingPropertyArray('matchingReferences', matchingReferences)
37 | }
38 | 
39 | function validateMatchingPropertyArray (name: string, array: ReconciliationKey[]) {
40 |   if (array) {
41 |     if (!(array instanceof Array)) {
42 |       throw newError(`invalid ${name} array`, { [name]: array })
43 |     }
44 |     for (const id of array) {
45 |       const [ pid, option ] = id.split(':')
46 |       if (!isPropertyId(pid)) {
47 |         throw newError(`invalid ${name} property id`, { property: pid })
48 |       }
49 |       if (option && !arrayIncludes(validOptions, option)) {
50 |         throw newError(`invalid ${name} property id option: ${option}`, { id, pid, option })
51 |       }
52 |     }
53 |   }
54 | }
55 | 


--------------------------------------------------------------------------------
/src/lib/validate_and_enrich_config.ts:
--------------------------------------------------------------------------------
 1 | import { name, version, homepage } from '../assets/metadata.js'
 2 | import { newError } from './error.js'
 3 | import parseInstance from './parse_instance.js'
 4 | import { forceArray } from './utils.js'
 5 | import type { GeneralConfig, RequestConfig, SerializedConfig } from './types/config.js'
 6 | 
 7 | export function validateAndEnrichConfig (generalConfig: GeneralConfig, requestConfig?: RequestConfig): SerializedConfig {
 8 |   generalConfig.userAgent = generalConfig.userAgent || `${name}/v${version} (${homepage})`
 9 | 
10 |   let config
11 |   if (requestConfig) {
12 |     config = Object.assign({}, generalConfig, requestConfig)
13 |   } else {
14 |     config = generalConfig
15 |     if (config._validatedAndEnriched) return config
16 |   }
17 | 
18 |   parseInstance(config)
19 |   if (config.instance == null) throw newError('invalid config object', { config })
20 | 
21 |   config.anonymous = config.anonymous === true
22 | 
23 |   if (!config.credentials && !config.anonymous) throw newError('missing credentials', { config })
24 | 
25 |   if (config.credentials) {
26 |     if (!config.credentials.oauth && !config.credentials.browserSession && (!config.credentials.username || !config.credentials.password)) {
27 |       throw newError('missing credentials')
28 |     }
29 | 
30 |     if (config.credentials.oauth && (config.credentials.username || config.credentials.password)) {
31 |       throw newError('credentials can not be both oauth tokens, and a username and password')
32 |     }
33 | 
34 |     // Making sure that the 'bot' flag was explicitly set to true
35 |     config.bot = config.bot === true
36 |   }
37 | 
38 |   let { summary, tags, baserevid } = config
39 | 
40 |   if (summary != null) checkType('summary', summary, 'string')
41 | 
42 |   // on wikidata.org: set tag to wikibase-edit by default
43 |   if (config.instance === 'https://www.wikidata.org') {
44 |     tags = tags || 'WikibaseJS-edit'
45 |   }
46 | 
47 |   if (tags != null) {
48 |     tags = forceArray(tags)
49 |     tags.forEach(tag => checkType('tags', tag, 'string'))
50 |     config.tags = tags
51 |   }
52 | 
53 |   if (baserevid != null) checkType('baserevid', baserevid, 'number')
54 | 
55 |   config._validatedAndEnriched = true
56 | 
57 |   return config
58 | }
59 | 
60 | function checkType (name: string, value: string | number, type: 'string' | 'number') {
61 |   if (typeof value !== type) {
62 |     throw newError(`invalid config ${name}`, { [name]: value, type: typeof value })
63 |   }
64 | }
65 | 


--------------------------------------------------------------------------------
/src/lib/request/login.ts:
--------------------------------------------------------------------------------
 1 | import { newError } from '../error.js'
 2 | import { stringifyQuery } from '../utils.js'
 3 | import { customFetch } from './fetch.js'
 4 | import { parseResponseBody } from './parse_response_body.js'
 5 | import parseSessionCookies from './parse_session_cookies.js'
 6 | 
 7 | const contentType = 'application/x-www-form-urlencoded'
 8 | 
 9 | export default config => {
10 |   const headers = {
11 |     'user-agent': config.userAgent,
12 |     'content-type': contentType,
13 |   }
14 | 
15 |   const loginUrl = `${config.instanceApiEndpoint}?action=login&format=json`
16 | 
17 |   return login(loginUrl, config, headers)
18 | }
19 | 
20 | const login = async (loginUrl, config, headers) => {
21 |   const loginCookies = await getLoginToken(loginUrl, config, headers)
22 |   return getSessionCookies(loginUrl, config, headers, loginCookies)
23 | }
24 | 
25 | const getLoginToken = async (loginUrl, config, headers) => {
26 |   const { username: lgname, password: lgpassword } = config.credentials
27 |   const body = stringifyQuery({ lgname, lgpassword })
28 |   const res = await customFetch(loginUrl, { method: 'post', headers, body })
29 |   return parseLoginCookies(res)
30 | }
31 | 
32 | const getSessionCookies = async (loginUrl, config, headers, loginCookies) => {
33 |   const { cookies, token: lgtoken } = loginCookies
34 |   const { username: lgname, password: lgpassword } = config.credentials
35 |   const body = stringifyQuery({ lgname, lgpassword, lgtoken })
36 | 
37 |   const headersWithCookies = Object.assign({}, headers, { Cookie: cookies })
38 | 
39 |   const res = await customFetch(loginUrl, {
40 |     method: 'post',
41 |     headers: headersWithCookies,
42 |     body,
43 |   })
44 | 
45 |   const resBody = await parseResponseBody(res)
46 |   if (resBody.login.result !== 'Success') {
47 |     throw newError('failed to login: invalid username/password')
48 |   }
49 | 
50 |   const resCookies = res.headers.get('set-cookie')
51 | 
52 |   if (!resCookies) {
53 |     throw newError('login error', res.status, { body: resBody })
54 |   }
55 | 
56 |   if (!sessionCookiePattern.test(resCookies)) {
57 |     throw newError('invalid login cookies', { cookies: resCookies })
58 |   }
59 | 
60 |   return parseSessionCookies(resCookies)
61 | }
62 | 
63 | const sessionCookiePattern = /[sS]ession=\w{32}/
64 | 
65 | const parseLoginCookies = async res => {
66 |   const body = await res.json()
67 |   const token = body.login.token
68 |   const cookies = res.headers.get('set-cookie')
69 |   return { token, cookies }
70 | }
71 | 


--------------------------------------------------------------------------------
/src/lib/claim/get_time_object.ts:
--------------------------------------------------------------------------------
 1 | import { isPlainObject } from '../utils.js'
 2 | import { parseCalendar } from './parse_calendar.js'
 3 | import type { CustomEditableTimeSnakValue } from '../types/snaks.js'
 4 | 
 5 | export function getTimeObject (value: CustomEditableTimeSnakValue | string | number) {
 6 |   let time, precision, calendar, calendarmodel, timezone, before, after
 7 |   if (isPlainObject(value)) {
 8 |     ({ time, precision, calendar, calendarmodel, timezone, before, after } = value)
 9 |     calendarmodel = calendarmodel || calendar
10 |   } else {
11 |     time = value
12 |   }
13 |   time = time
14 |     // It could be a year passed as an integer
15 |     .toString()
16 |     // Drop milliseconds from ISO time strings as those aren't represented in Wikibase anyway
17 |     // ex: '2019-04-01T00:00:00.000Z' -> '2019-04-01T00:00:00Z'
18 |     .replace('.000Z', 'Z')
19 |     .replace(/^\+/, '')
20 |   if (precision == null) precision = getPrecision(time)
21 |   const timeStringBase = getTimeStringBase(time, precision)
22 |   return getPrecisionTimeObject(timeStringBase, precision, calendarmodel, timezone, before, after)
23 | }
24 | 
25 | function getTimeStringBase (time: string, precision: number) {
26 |   if (precision > 10) return time
27 |   if (precision === 10) {
28 |     if (time.match(/^-?\d+-\d+$/)) return time + '-00'
29 |     else return time
30 |   }
31 |   // From the year (9) to the billion years (0)
32 |   // See https://www.wikidata.org/wiki/Help:Dates#Precision
33 |   const yearMatch = time.match(/^(-?\d+)/)
34 |   if (yearMatch == null) throw new Error(`couldn't identify year: ${time}`)
35 |   const year = yearMatch[0]
36 |   return year + '-00-00'
37 | }
38 | 
39 | // Guess precision from time string
40 | // 2018 (year): 9
41 | // 2018-03 (month): 10
42 | // 2018-03-03 (day): 11
43 | function getPrecision (time: string) {
44 |   const unsignedTime = time.replace(/^-/, '')
45 |   return unsignedTime.split('-').length + 8
46 | }
47 | 
48 | function getPrecisionTimeObject (time: string, precision: number, calendarmodel?: string, timezone = 0, before = 0, after = 0) {
49 |   const sign = time[0]
50 | 
51 |   // The Wikidata API expects signed years
52 |   // Default to a positive year sign
53 |   if (sign !== '-' && sign !== '+') time = `+${time}`
54 | 
55 |   if (precision <= 11 && !time.match('T')) time += 'T00:00:00Z'
56 | 
57 |   return {
58 |     time,
59 |     timezone,
60 |     before,
61 |     after,
62 |     precision,
63 |     calendarmodel: parseCalendar(calendarmodel, time),
64 |   }
65 | }
66 | 


--------------------------------------------------------------------------------
/tests/integration/claim/reconciliation_remove.ts:
--------------------------------------------------------------------------------
 1 | import 'should'
 2 | import config from 'config'
 3 | import { simplify } from 'wikibase-sdk'
 4 | import { getSandboxPropertyId, getReservedItemId } from '#tests/integration/utils/sandbox_entities'
 5 | import { waitForInstance } from '#tests/integration/utils/wait_for_instance'
 6 | import WBEdit from '#root'
 7 | import { shouldNotBeCalled } from '../utils/utils.js'
 8 | 
 9 | const wbEdit = WBEdit(config)
10 | 
11 | describe('reconciliation:remove claims', function () {
12 |   this.timeout(20 * 1000)
13 |   before('wait for instance', waitForInstance)
14 | 
15 |   it('should remove matching claims', async () => {
16 |     const [ id, property ] = await Promise.all([
17 |       getReservedItemId(),
18 |       getSandboxPropertyId('string'),
19 |     ])
20 |     await wbEdit.entity.edit({
21 |       id,
22 |       claims: {
23 |         [property]: [
24 |           { value: 'foo' },
25 |           { value: 'foo' },
26 |           { value: 'bar', qualifiers: { [property]: [ 'buzz' ] } },
27 |           { value: 'bar', qualifiers: { [property]: [ 'bla' ] } },
28 |         ],
29 |       },
30 |     })
31 |     const res2 = await wbEdit.entity.edit({
32 |       id,
33 |       claims: {
34 |         [property]: [
35 |           { value: 'foo', remove: true, reconciliation: {} },
36 |           {
37 |             value: 'bar',
38 |             qualifiers: { [property]: [ 'bla' ] },
39 |             remove: true,
40 |             reconciliation: { matchingQualifiers: [ property ] },
41 |           },
42 |         ],
43 |       },
44 |     })
45 |     simplify.claims(res2.entity.claims, { keepQualifiers: true }).should.deepEqual({
46 |       [property]: [
47 |         { value: 'bar', qualifiers: { [property]: [ 'buzz' ] } },
48 |       ],
49 |     })
50 |   })
51 | 
52 |   it('should reject matching several times the same claim', async () => {
53 |     const [ id, property ] = await Promise.all([
54 |       getReservedItemId(),
55 |       getSandboxPropertyId('string'),
56 |     ])
57 |     await wbEdit.entity.edit({
58 |       id,
59 |       claims: {
60 |         [property]: [
61 |           { value: 'foo' },
62 |         ],
63 |       },
64 |     })
65 |     await wbEdit.entity.edit({
66 |       id,
67 |       claims: {
68 |         [property]: [
69 |           { value: 'foo', remove: true, reconciliation: {} },
70 |           { value: 'foo', remove: true, reconciliation: {} },
71 |         ],
72 |       },
73 |     })
74 |     .then(shouldNotBeCalled)
75 |     .catch(err => {
76 |       err.message.should.equal('can not match several times the same claim')
77 |     })
78 |   })
79 | })
80 | 


--------------------------------------------------------------------------------
/tests/integration/utils/sandbox_snaks.ts:
--------------------------------------------------------------------------------
 1 | import config from 'config'
 2 | import type { EditEntitySimplifiedModeParams } from '#lib/entity/edit'
 3 | import type { SetQualifierParams } from '#lib/qualifier/set'
 4 | import type { SetReferenceParams } from '#lib/reference/set'
 5 | import type { SimplifiedEditableClaim, SimplifiedEditableQualifiers } from '#lib/types/edit_entity'
 6 | import { assert, randomString } from '#tests/unit/utils'
 7 | import WBEdit from '#root'
 8 | import { getSandboxItemId, getSandboxPropertyId, getSandboxClaimId } from './sandbox_entities.js'
 9 | import type { Datatype, Guid, ItemId, PropertyId } from 'wikibase-sdk'
10 | 
11 | const wbEdit = WBEdit(config)
12 | 
13 | interface AddClaimParams {
14 |   id?: ItemId
15 |   property?: PropertyId
16 |   datatype?: Datatype
17 |   value?: SimplifiedEditableClaim
18 |   qualifiers?: SimplifiedEditableQualifiers
19 | }
20 | 
21 | export async function addClaim (params: AddClaimParams = {}) {
22 |   let { id, property, datatype = 'string', value = randomString(), qualifiers } = params
23 |   id ??= await getSandboxItemId()
24 |   property ??= (await getSandboxPropertyId(datatype))
25 |   const res = await wbEdit.entity.edit({
26 |     id,
27 |     claims: {
28 |       [property]: {
29 |         value,
30 |         qualifiers,
31 |       },
32 |     },
33 |   } as EditEntitySimplifiedModeParams)
34 |   assert('claims' in res.entity)
35 |   const claim = res.entity.claims[property].slice(-1)[0]
36 |   return { id, property, claim, guid: claim.id as Guid }
37 | }
38 | 
39 | interface AddQualifierParams extends Partial> {
40 |   guid?: Guid
41 |   datatype?: Datatype
42 | }
43 | 
44 | export async function addQualifier ({ guid, property, datatype, value }: AddQualifierParams) {
45 |   guid ??= (await getSandboxClaimId()) as Guid
46 |   property ??= await getSandboxPropertyId(datatype)
47 |   const res = await wbEdit.qualifier.set({ guid, property, value })
48 |   const qualifier = res.claim.qualifiers[property].slice(-1)[0]
49 |   const { hash } = qualifier
50 |   return { guid, property, qualifier, hash }
51 | }
52 | 
53 | interface AddReferenceParams extends Partial> {
54 |   guid?: Guid
55 |   datatype?: Datatype
56 | }
57 | 
58 | export async function addReference ({ guid, property, datatype, value }: AddReferenceParams) {
59 |   guid ??= await getSandboxClaimId() as Guid
60 |   property = property || (await getSandboxPropertyId(datatype))
61 |   const { reference } = await wbEdit.reference.set({ guid, property, value })
62 |   const referenceSnak = reference.snaks[property].slice(-1)[0]
63 |   return { guid, property, reference, referenceSnak }
64 | }
65 | 


--------------------------------------------------------------------------------
/src/lib/claim/move_commons.ts:
--------------------------------------------------------------------------------
 1 | import { simplifySnak, type Claim, type Datatype, type PropertyId, type Snak, type SnakWithValue, type StringSnakDataValue } from 'wikibase-sdk'
 2 | import { newError } from '../error.js'
 3 | import { parseQuantity } from './quantity.js'
 4 | import type { AbsoluteUrl } from '../types/common.js'
 5 | 
 6 | const issuesUrl = 'https://github.com/maxlath/wikibase-edit/issues'
 7 | 
 8 | interface PropertiesDatatypesDontMatchParams {
 9 |   movedSnaks: Snak[] | Claim[]
10 |   originDatatype: Datatype
11 |   targetDatatype: Datatype
12 |   instance: AbsoluteUrl
13 |   failingSnak?: Snak
14 |   // For error context
15 |   originPropertyId?: PropertyId
16 |   targetPropertyId?: PropertyId
17 | }
18 | 
19 | export function propertiesDatatypesDontMatch (params: PropertiesDatatypesDontMatchParams) {
20 |   const { movedSnaks, originDatatype, targetDatatype, instance } = params
21 |   const typeConverterKey = `${originDatatype}->${targetDatatype}`
22 |   const convertType = snakTypeConversions[typeConverterKey]
23 |   if (convertType) {
24 |     for (let snak of movedSnaks) {
25 |       snak = 'mainsnak' in snak ? snak.mainsnak : snak
26 |       if (snakHasValue(snak)) {
27 |         try {
28 |           convertType(snak, instance)
29 |         } catch (err) {
30 |           const errMessage = `properties datatype don't match and ${typeConverterKey} type conversion failed: ${err.message}`
31 |           params.failingSnak = snak
32 |           const customErr = newError(errMessage, 400, params)
33 |           customErr.cause = err
34 |           throw customErr
35 |         }
36 |       }
37 |     }
38 |   } else {
39 |     const errMessage = `properties datatype don't match
40 |     No ${typeConverterKey} type converter found
41 |     If you think that should be possible, please open a ticket:
42 |     ${issuesUrl}/new?template=feature_request.md&title=${encodeURIComponent(`claim.move: add a ${typeConverterKey} type converter`)}&body=%20`
43 |     throw newError(errMessage, 400, params)
44 |   }
45 | }
46 | 
47 | function simplifyToString (snak: SnakWithValue) {
48 |   snak.datavalue.value = simplifySnak(snak, {}).toString()
49 |   snak.datatype = snak.datavalue.type = 'string'
50 | }
51 | 
52 | const snakTypeConversions = {
53 |   'string->external-id': (snak: SnakWithValue) => {
54 |     snak.datatype = 'string'
55 |   },
56 |   'string->quantity': (snak: SnakWithValue, instance: AbsoluteUrl) => {
57 |     const { value } = snak.datavalue as StringSnakDataValue
58 |     snak.datavalue.value = parseQuantity(value, instance)
59 |     snak.datatype = snak.datavalue.type = 'quantity'
60 |   },
61 |   'external-id->string': simplifyToString,
62 |   'monolingualtext->string': simplifyToString,
63 |   'quantity->string': simplifyToString,
64 | }
65 | 
66 | const snakHasValue = (snak: Snak) => snak.snaktype === 'value'
67 | 


--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "name": "wikibase-edit",
 3 |   "version": "8.0.6",
 4 |   "description": "Edit Wikibase from NodeJS",
 5 |   "type": "module",
 6 |   "main": "./dist/src/lib/index.js",
 7 |   "imports": {
 8 |     "#lib/*": "./dist/lib/*.js",
 9 |     "#tests/*": "./tests/*.js",
10 |     "#root": "./dist/lib/index.js"
11 |   },
12 |   "exports": {
13 |     ".": {
14 |       "types": "./dist/src/lib/index.d.ts",
15 |       "import": "./dist/src/lib/index.js"
16 |     }
17 |   },
18 |   "files": [
19 |     "dist",
20 |     "src"
21 |   ],
22 |   "scripts": {
23 |     "build": "tsc",
24 |     "git-pre-commit": "./scripts/githooks/pre-commit",
25 |     "lint": "eslint",
26 |     "lint:fix": "eslint --fix",
27 |     "test": "npm run test:unit && npm run test:integration",
28 |     "test:unit": "mocha $MOCHA_OPTIONS tests/unit/*.ts tests/unit/*/*.ts",
29 |     "test:integration": "mocha $MOCHA_OPTIONS tests/integration/*.ts tests/integration/*/*.ts",
30 |     "prepublishOnly": "npm run lint && MOCHA_OPTIONS='' npm test",
31 |     "prepack": "npm run build && npm run lint && npm test",
32 |     "postpublish": "git push --tags",
33 |     "postversion": "./scripts/postversion",
34 |     "update-toc": "./scripts/update_toc",
35 |     "watch": "tsc --watch"
36 |   },
37 |   "repository": {
38 |     "type": "git",
39 |     "url": "git+https://github.com/maxlath/wikibase-edit.git"
40 |   },
41 |   "keywords": [
42 |     "wikibase",
43 |     "wikidata",
44 |     "write",
45 |     "update",
46 |     "edit",
47 |     "API"
48 |   ],
49 |   "author": "maxlath",
50 |   "license": "MIT",
51 |   "bugs": {
52 |     "url": "https://github.com/maxlath/wikibase-edit/issues"
53 |   },
54 |   "homepage": "https://github.com/maxlath/wikibase-edit",
55 |   "dependencies": {
56 |     "@types/lodash-es": "^4.17.12",
57 |     "cross-fetch": "^4.1.0",
58 |     "crypto-js": "^4.1.1",
59 |     "lodash-es": "^4.17.21",
60 |     "oauth-1.0a": "^2.2.6",
61 |     "type-fest": "^4.41.0",
62 |     "typescript": "^5.9.2",
63 |     "wikibase-sdk": "^11.1.0"
64 |   },
65 |   "devDependencies": {
66 |     "@eslint/config-helpers": "^0.3.0",
67 |     "@eslint/js": "^9.32.0",
68 |     "@stylistic/eslint-plugin": "^5.2.2",
69 |     "@types/mocha": "^10.0.10",
70 |     "@types/node": "^24.3.1",
71 |     "@typescript-eslint/eslint-plugin": "^8.38.0",
72 |     "@typescript-eslint/parser": "^8.38.0",
73 |     "@vercel/git-hooks": "^1.0.0",
74 |     "config": "^4.1.1",
75 |     "eslint": "^9.34.0",
76 |     "eslint-import-resolver-typescript": "^4.4.4",
77 |     "eslint-plugin-import-x": "^4.16.1",
78 |     "eslint-plugin-n": "^17.21.3",
79 |     "eslint-plugin-promise": "^7.2.1",
80 |     "globals": "^16.3.0",
81 |     "mocha": "^10.2.0",
82 |     "nock": "^13.3.1",
83 |     "should": "^13.2.3",
84 |     "tiny-chalk": "^3.0.2",
85 |     "tsx": "^4.20.4",
86 |     "typescript-eslint": "^8.40.0"
87 |   },
88 |   "engines": {
89 |     "node": ">= 18"
90 |   }
91 | }
92 | 


--------------------------------------------------------------------------------
/tests/integration/claim/reconciliation_skip_on_value_match_mode.ts:
--------------------------------------------------------------------------------
 1 | import 'should'
 2 | import config from 'config'
 3 | import { simplify } from 'wikibase-sdk'
 4 | import { getSandboxPropertyId, getReservedItemId } from '#tests/integration/utils/sandbox_entities'
 5 | import { waitForInstance } from '#tests/integration/utils/wait_for_instance'
 6 | import WBEdit from '#root'
 7 | import { assert } from '../../unit/utils'
 8 | 
 9 | const wbEdit = WBEdit(config)
10 | 
11 | describe('reconciliation: skip-on-value-match mode', function () {
12 |   this.timeout(20 * 1000)
13 |   before('wait for instance', waitForInstance)
14 | 
15 |   it('should add a statement when no statement exists', async () => {
16 |     const [ id, property ] = await Promise.all([
17 |       getReservedItemId(),
18 |       getSandboxPropertyId('string'),
19 |     ])
20 |     const res = await wbEdit.claim.create({
21 |       id,
22 |       property,
23 |       value: 'foo',
24 |       reconciliation: {
25 |         mode: 'skip-on-value-match',
26 |       },
27 |     })
28 |     assert('datavalue' in res.claim.mainsnak)
29 |     res.claim.mainsnak.datavalue.value.should.equal('foo')
30 |   })
31 | 
32 |   it('should not re-add an existing statement', async () => {
33 |     const [ id, property ] = await Promise.all([
34 |       getReservedItemId(),
35 |       getSandboxPropertyId('string'),
36 |     ])
37 |     const res = await wbEdit.claim.create({ id, property, value: 'foo' })
38 |     const res2 = await wbEdit.claim.create({
39 |       id,
40 |       property,
41 |       value: 'foo',
42 |       reconciliation: {
43 |         mode: 'skip-on-value-match',
44 |       },
45 |     })
46 |     res2.claim.id.should.equal(res.claim.id)
47 |     assert('datavalue' in res2.claim.mainsnak)
48 |     res2.claim.mainsnak.datavalue.value.should.equal('foo')
49 |   })
50 | 
51 |   it('should not merge qualifiers and references', async () => {
52 |     const [ id, property ] = await Promise.all([
53 |       getReservedItemId(),
54 |       getSandboxPropertyId('string'),
55 |     ])
56 |     const res = await wbEdit.claim.create({
57 |       id,
58 |       property,
59 |       value: 'foo',
60 |       qualifiers: { [property]: 'bar' },
61 |       references: { [property]: 'buzz' },
62 |     })
63 |     const res2 = await wbEdit.claim.create({
64 |       id,
65 |       property,
66 |       value: 'foo',
67 |       qualifiers: { [property]: 'bla' },
68 |       references: { [property]: 'blu' },
69 |       reconciliation: {
70 |         mode: 'skip-on-value-match',
71 |       },
72 |     })
73 |     res2.claim.id.should.equal(res.claim.id)
74 |     assert('datavalue' in res2.claim.mainsnak)
75 |     res2.claim.mainsnak.datavalue.value.should.equal('foo')
76 |     simplify.propertyQualifiers(res2.claim.qualifiers[property]).should.deepEqual([ 'bar' ])
77 |     simplify.references(res2.claim.references).should.deepEqual([ { [property]: [ 'buzz' ] } ])
78 |   })
79 | })
80 | 


--------------------------------------------------------------------------------
/tests/integration/claim/reconciliation.ts:
--------------------------------------------------------------------------------
 1 | import 'should'
 2 | import config from 'config'
 3 | import { simplify } from 'wikibase-sdk'
 4 | import { getSandboxPropertyId, getReservedItemId } from '#tests/integration/utils/sandbox_entities'
 5 | import { waitForInstance } from '#tests/integration/utils/wait_for_instance'
 6 | import WBEdit from '#root'
 7 | import { shouldNotBeCalled } from '../utils/utils.js'
 8 | 
 9 | const wbEdit = WBEdit(config)
10 | 
11 | describe('reconciliation: general', function () {
12 |   this.timeout(20 * 1000)
13 |   before('wait for instance', waitForInstance)
14 | 
15 |   it('should reject a reconciliation object with a typo', async () => {
16 |     const [ id, property ] = await Promise.all([
17 |       getReservedItemId(),
18 |       getSandboxPropertyId('string'),
19 |     ])
20 |     // @ts-expect-error
21 |     await wbEdit.claim.create({ id, property, value: 'foo', reconciliationz: {} })
22 |     .then(shouldNotBeCalled)
23 |     .catch(err => {
24 |       err.message.should.equal('invalid parameter: reconciliationz')
25 |     })
26 | 
27 |     await wbEdit.entity.edit({
28 |       id,
29 |       claims: {
30 |         [property]: 'foo',
31 |       },
32 |       // @ts-expect-error
33 |       reconciliationz: {},
34 |     })
35 |     .then(shouldNotBeCalled)
36 |     .catch(err => {
37 |       err.message.should.equal('invalid parameter: reconciliationz')
38 |     })
39 | 
40 |     await wbEdit.entity.edit({
41 |       id,
42 |       claims: {
43 |         [property]: {
44 |           value: 'foo',
45 |           // @ts-expect-error
46 |           reconciliationz: {},
47 |         },
48 |       },
49 |     })
50 |     .then(shouldNotBeCalled)
51 |     .catch(err => {
52 |       err.message.should.equal('invalid claim parameter: reconciliationz')
53 |     })
54 |   })
55 | 
56 |   describe('per-claim reconciliation settings', () => {
57 |     it('should accept per-claim reconciliation settings', async () => {
58 |       const [ id, property ] = await Promise.all([
59 |         getReservedItemId(),
60 |         getSandboxPropertyId('string'),
61 |       ])
62 |       await wbEdit.entity.edit({
63 |         id,
64 |         claims: {
65 |           [property]: [
66 |             { value: 'foo', qualifiers: { [property]: 'buzz' } },
67 |             { value: 'bar', qualifiers: { [property]: 'bla' } },
68 |           ],
69 |         },
70 |       })
71 |       const res2 = await wbEdit.entity.edit({
72 |         id,
73 |         claims: {
74 |           [property]: [
75 |             { value: 'foo', qualifiers: { [property]: 'blo' } },
76 |             { value: 'bar', qualifiers: { [property]: 'bli' }, reconciliation: { mode: 'skip-on-value-match' } },
77 |           ],
78 |         },
79 |         reconciliation: {
80 |           mode: 'merge',
81 |         },
82 |       })
83 |       simplify.claims(res2.entity.claims, { keepQualifiers: true }).should.deepEqual({
84 |         [property]: [
85 |           { value: 'foo', qualifiers: { [property]: [ 'buzz', 'blo' ] } },
86 |           { value: 'bar', qualifiers: { [property]: [ 'bla' ] } },
87 |         ],
88 |       })
89 |     })
90 |   })
91 | })
92 | 


--------------------------------------------------------------------------------
/tests/integration/get_token.ts:
--------------------------------------------------------------------------------
 1 | import config from 'config'
 2 | import should from 'should'
 3 | import { getTokenFactory } from '#lib/request/get_token'
 4 | import { validateAndEnrichConfig } from '#lib/validate_and_enrich_config'
 5 | import { undesiredRes, isBotPassword, shouldNotBeCalled } from '#tests/integration/utils/utils'
 6 | 
 7 | const { instance, credentials, credentialsAlt } = config
 8 | 
 9 | const { username, password } = credentialsAlt
10 | 
11 | describe('get token', function () {
12 |   this.timeout(10000)
13 | 
14 |   it('should get token from username and password', async () => {
15 |     const config = validateAndEnrichConfig({ instance, credentials: { username, password } })
16 |     const getToken = getTokenFactory(config)
17 |     getToken.should.be.a.Function()
18 |     const { cookie, token } = await getToken()
19 |     token.length.should.be.above(40)
20 |     if (!isBotPassword(password)) {
21 |       should(/.+UserID=\d+/.test(cookie)).be.true('should contain user ID')
22 |     }
23 |     should(/.+[sS]ession=\w{32}/.test(cookie)).be.true('should contain session ID')
24 |   })
25 | 
26 |   it('should get token from oauth', async () => {
27 |     const config = validateAndEnrichConfig({ instance, credentials })
28 |     const getToken = getTokenFactory(config)
29 |     getToken.should.be.a.Function()
30 |     const { token } = await getToken()
31 |     token.length.should.be.above(40)
32 |   })
33 | 
34 |   // This test would need to run in a browser
35 |   xit('should get token from browser session', async () => {
36 |     const config = validateAndEnrichConfig({ instance, credentials: { browserSession: true } })
37 |     const getToken = getTokenFactory(config)
38 |     getToken.should.be.a.Function()
39 |     await getToken()
40 |     .then(shouldNotBeCalled)
41 |     .catch(err => {
42 |       // In absence of a valid browser session, getting the csrf token will fail
43 |       err.message.should.containEql('could not get tokens')
44 |     })
45 |   })
46 | 
47 |   it('should reject on invalid username/password credentials', done => {
48 |     const invalidCreds = { username: 'inva', password: 'lid' }
49 |     const config = validateAndEnrichConfig({ instance, credentials: invalidCreds })
50 |     const getToken = getTokenFactory(config)
51 |     getToken.should.be.a.Function()
52 |     getToken()
53 |     .then(undesiredRes(done))
54 |     .catch(err => {
55 |       err.message.should.equal('failed to login: invalid username/password')
56 |       done()
57 |     })
58 |     .catch(done)
59 |   })
60 | 
61 |   it('should reject on invalid oauth credentials', done => {
62 |     const invalidCreds = {
63 |       oauth: {
64 |         consumer_key: 'in',
65 |         consumer_secret: 'va',
66 |         token: 'li',
67 |         token_secret: 'd',
68 |       },
69 |     }
70 |     const config = validateAndEnrichConfig({ instance, credentials: invalidCreds })
71 |     const getToken = getTokenFactory(config)
72 |     getToken.should.be.a.Function()
73 |     getToken()
74 |     .then(undesiredRes(done))
75 |     .catch(err => {
76 |       err.message.should.endWith('Invalid consumer')
77 |       done()
78 |     })
79 |     .catch(done)
80 |   })
81 | })
82 | 


--------------------------------------------------------------------------------
/src/lib/qualifier/update.ts:
--------------------------------------------------------------------------------
 1 | import { flatten, values } from 'lodash-es'
 2 | import { getEntityIdFromGuid, type Claim, type Guid, type PropertyId, type Statement } from 'wikibase-sdk'
 3 | import { findSnak } from '../claim/find_snak.js'
 4 | import { formatUpdatedSnakValue } from '../claim/update.js'
 5 | import { newError } from '../error.js'
 6 | import { getEntityClaims } from '../get_entity.js'
 7 | import { validateGuid, validatePropertyId, validateSnakValue } from '../validate.js'
 8 | import type { WikibaseEditAPI } from '../index.js'
 9 | import type { SetQualifierResponse } from './set.js'
10 | import type { BaseRevId } from '../types/common.js'
11 | import type { SerializedConfig } from '../types/config.js'
12 | import type { EditableSnakValue } from '../types/snaks.js'
13 | 
14 | export interface UpdateQualifierParams {
15 |   guid: Guid
16 |   property: PropertyId
17 |   oldValue: EditableSnakValue
18 |   newValue: EditableSnakValue
19 |   summary?: string
20 |   baserevid?: BaseRevId
21 | }
22 | 
23 | export async function updateQualifier (params: UpdateQualifierParams, config: SerializedConfig, API: WikibaseEditAPI) {
24 |   const { guid, property, oldValue, newValue } = params
25 | 
26 |   validateGuid(guid)
27 |   validatePropertyId(property)
28 |   const datatype = config.properties[property]
29 |   validateSnakValue(property, datatype, oldValue)
30 | 
31 |   if (oldValue === newValue) {
32 |     throw newError('same value', 400, { oldValue, newValue })
33 |   }
34 | 
35 |   const qualifier = await getSnak(guid, property, oldValue, config)
36 |   const { hash } = qualifier
37 | 
38 |   const formattedNewValue = formatUpdatedSnakValue(newValue, qualifier)
39 | 
40 |   validateSnakValue(property, datatype, formattedNewValue)
41 | 
42 |   return API.qualifier.set({
43 |     guid,
44 |     hash,
45 |     property,
46 |     value: formattedNewValue,
47 |     summary: 'summary' in params ? params.summary : config.summary,
48 |     baserevid: params.baserevid || config.baserevid,
49 |   }, config)
50 | }
51 | 
52 | async function getSnak (guid: Guid, property: PropertyId, oldValue: UpdateQualifierParams['oldValue'], config: SerializedConfig) {
53 |   const entityId = getEntityIdFromGuid(guid)
54 |   const claims = await getEntityClaims(entityId, config)
55 |   const claim = findClaim(claims, guid)
56 | 
57 |   if (!claim) throw newError('claim not found', 400, { guid })
58 |   if (!claim.qualifiers) throw newError('claim qualifiers not found', 400, { guid })
59 | 
60 |   const propSnaks = claim.qualifiers[property]
61 | 
62 |   const qualifier = findSnak(property, propSnaks, oldValue)
63 | 
64 |   if (!qualifier) {
65 |     const actualValues = propSnaks ? propSnaks.map(getSnakValue) : null
66 |     throw newError('qualifier not found', 400, { guid, property, expectedValue: oldValue, actualValues })
67 |   }
68 |   return qualifier
69 | }
70 | 
71 | function findClaim  (claims: Record, guid: Guid): T | void {
72 |   const flattenedClaims = flatten(values(claims))
73 |   for (const claim of flattenedClaims) {
74 |     if (claim.id === guid) return claim
75 |   }
76 | }
77 | 
78 | const getSnakValue = snak => snak.datavalue?.value
79 | 
80 | export type UpdateQualifierResponse = SetQualifierResponse
81 | 


--------------------------------------------------------------------------------
/tests/integration/utils/utils.ts:
--------------------------------------------------------------------------------
 1 | import config from 'config'
 2 | import { yellow } from 'tiny-chalk'
 3 | import { WBK, type Entity, type EntityId } from 'wikibase-sdk'
 4 | import { newError, type ContextualizedError } from '#lib/error'
 5 | import { customFetch } from '#lib/request/fetch'
 6 | import { resolveTitle } from '#lib/resolve_title'
 7 | import type { AbsoluteUrl } from '#lib/types/common'
 8 | 
 9 | const { instance } = config
10 | 
11 | const wbk = WBK({ instance })
12 | 
13 | export interface GetRevisionsParams {
14 |   id: EntityId
15 |   customInstance?: AbsoluteUrl
16 |   limit?: number
17 |   props?: string | string[]
18 | }
19 | 
20 | async function getRevisions ({ id, customInstance, limit, props }) {
21 |   customInstance = customInstance || instance
22 |   const title = await resolveTitle(id, `${customInstance}/w/api.php` as AbsoluteUrl)
23 |   const customWbk = WBK({ instance: customInstance })
24 |   const url = customWbk.getRevisions({ ids: title, limit, prop: props }) as AbsoluteUrl
25 |   const { query } = await customFetch(url).then(res => res.json())
26 |   const page = Object.values(query.pages)[0]
27 |   if (!(typeof page === 'object' && 'revisions' in page)) {
28 |     throw newError('revisions not found', 400, { id, url, page })
29 |   }
30 |   return page.revisions
31 | }
32 | 
33 | export async function getLastRevision (id: EntityId, customInstance?: AbsoluteUrl) {
34 |   const revisions = await getRevisions({ id, customInstance, limit: 1, props: [ 'comment', 'tags' ] })
35 |   return revisions[0]
36 | }
37 | 
38 | export const wait = ms => new Promise(resolve => setTimeout(resolve, ms))
39 | 
40 | export async function getEntity (id: EntityId) {
41 |   const url = wbk.getEntities({ ids: id }) as AbsoluteUrl
42 |   const { entities } = await customFetch(url).then(res => res.json())
43 |   return entities[id]
44 | }
45 | 
46 | export async function getEntityHistory (id: EntityId, customInstance?: AbsoluteUrl) {
47 | // @ts-expect-error
48 |   const revisions = await getRevisions({ id, customInstance })
49 |   // @ts-expect-error
50 |   return revisions.sort(chronologically)
51 | }
52 | 
53 | export async function getLastEditSummary (_id: EntityId | { entity: Entity }) {
54 |   const id = typeof _id === 'string' ? _id : _id.entity.id
55 |   const revision = await getLastRevision(id)
56 |   return revision.comment
57 | }
58 | 
59 | // A function to quickly fail when a test gets an undesired positive answer
60 | export const undesiredRes = done => res => {
61 |   console.warn(yellow('undesired positive res:'), res)
62 |   done(new Error('.then function was expected not to be called'))
63 | }
64 | 
65 | // Same but for async/await tests that don't use done
66 | export function shouldNotBeCalled (res) {
67 |   console.warn(yellow('undesired positive res:'), res)
68 |   const err: ContextualizedError = new Error('function was expected not to be called')
69 |   err.name = 'shouldNotBeCalled'
70 |   err.context = { res }
71 |   throw err
72 | }
73 | 
74 | export function rethrowShouldNotBeCalledErrors (err) {
75 |   if (err.name === 'shouldNotBeCalled') throw err
76 | }
77 | 
78 | // See /wiki/Special:BotPasswords
79 | export const isBotPassword = password => password.match(/^\w+@[a-z0-9]{32}$/)
80 | 
81 | const chronologically = (a, b) => a.revid - b.revid
82 | 


--------------------------------------------------------------------------------
/tests/unit/reference/set.ts:
--------------------------------------------------------------------------------
 1 | import 'should'
 2 | import { setReference } from '#lib/reference/set'
 3 | import { guid, properties, hash, someInstance } from '#tests/unit/utils'
 4 | import type { SpecialSnak } from '../../../src/lib/claim/special_snaktype'
 5 | import type { PropertyId } from 'wikibase-sdk'
 6 | 
 7 | describe('reference set', () => {
 8 |   it('should rejected if not passed a claim guid', () => {
 9 |     // @ts-expect-error
10 |     setReference.bind(null, {}, properties).should.throw('missing guid')
11 |   })
12 | 
13 |   it('should rejected if passed an invalid claim guid', () => {
14 |     // @ts-expect-error
15 |     setReference.bind(null, { guid: 'some-invalid-guid' }, properties)
16 |     .should.throw('invalid guid')
17 |   })
18 | 
19 |   it('should rejected if not passed a property id', () => {
20 |     setReference.bind(null, { guid }, properties).should.throw('missing property id')
21 |   })
22 | 
23 |   it('should rejected if not passed a reference value', () => {
24 |     setReference.bind(null, { guid, property: 'P2' }, properties)
25 |     .should.throw('missing snak value')
26 |   })
27 | 
28 |   it('should rejected if passed an invalid reference', () => {
29 |     const params = { guid, property: 'P2', value: 'not-a-valid-reference' }
30 |     // @ts-expect-error
31 |     setReference.bind(null, params, properties).should.throw('invalid entity value')
32 |   })
33 | 
34 |   it('should set the action to wbsetreference', () => {
35 |     const params = { guid, property: 'P2' as PropertyId, value: 'Q1' }
36 |     setReference(params, properties, someInstance).action.should.equal('wbsetreference')
37 |   })
38 | 
39 |   it('should format the data for a url', () => {
40 |     const params = { guid, property: 'P7' as PropertyId, value: 'http://foo.bar' }
41 |     setReference(params, properties, someInstance).data.should.deepEqual({
42 |       statement: guid,
43 |       snaks: '{"P7":[{"property":"P7","snaktype":"value","datavalue":{"type":"string","value":"http://foo.bar"}}]}',
44 |     })
45 |   })
46 | 
47 |   it('should set a reference with a special snaktype', () => {
48 |     const params = { guid, property: 'P7' as PropertyId, value: { snaktype: 'somevalue' } }
49 |     setReference(params, properties, someInstance).data.should.deepEqual({
50 |       statement: guid,
51 |       snaks: '{"P7":[{"snaktype":"somevalue","property":"P7"}]}',
52 |     })
53 |   })
54 | 
55 |   it('should accept snaks', () => {
56 |     const snaks = {
57 |       P2: 'Q1',
58 |       P7: [
59 |         'http://foo.bar',
60 |         { snaktype: 'somevalue' } as SpecialSnak,
61 |       ],
62 |     }
63 |     const params = { guid, snaks }
64 |     setReference(params, properties, someInstance).data.should.deepEqual({
65 |       statement: guid,
66 |       snaks: '[{"property":"P2","snaktype":"value","datavalue":{"type":"wikibase-entityid","value":{"id":"Q1","entity-type":"item"}}},{"property":"P7","snaktype":"value","datavalue":{"type":"string","value":"http://foo.bar"}},{"snaktype":"somevalue","property":"P7"}]',
67 |     })
68 |   })
69 | 
70 |   it('should accept a hash', () => {
71 |     const params = { guid, property: 'P2' as PropertyId, value: 'Q1', hash }
72 |     setReference(params, properties, someInstance).data.reference.should.equal(hash)
73 |   })
74 | })
75 | 


--------------------------------------------------------------------------------
/tests/integration/credentials.ts:
--------------------------------------------------------------------------------
 1 | import 'should'
 2 | import config from 'config'
 3 | import type { EditEntitySimplifiedModeParams } from '#lib/entity/edit'
 4 | import { waitForInstance } from '#tests/integration/utils/wait_for_instance'
 5 | import { randomString } from '#tests/unit/utils'
 6 | import WBEdit from '#root'
 7 | import { undesiredRes, shouldNotBeCalled, rethrowShouldNotBeCalledErrors } from './utils/utils.js'
 8 | 
 9 | const { instance, credentials } = config
10 | 
11 | const params = () => ({ labels: { en: randomString() } } as EditEntitySimplifiedModeParams)
12 | 
13 | describe('credentials', function () {
14 |   this.timeout(20 * 1000)
15 |   before('wait for instance', waitForInstance)
16 | 
17 |   it('should accept config at initialization', async () => {
18 |     const wbEdit = WBEdit({ instance, credentials })
19 |     const res = await wbEdit.entity.create(params())
20 |     res.success.should.equal(1)
21 |   })
22 | 
23 |   it('should accept credentials at request time', async () => {
24 |     const wbEdit = WBEdit({ instance })
25 |     const res = await wbEdit.entity.create(params(), { credentials })
26 |     res.success.should.equal(1)
27 |   })
28 | 
29 |   it('should accept instance at request time', async () => {
30 |     const wbEdit = WBEdit()
31 |     const res = await wbEdit.entity.create(params(), { instance, credentials })
32 |     res.success.should.equal(1)
33 |   })
34 | 
35 |   it('should reject undefined credentials', async () => {
36 |     const creds = { username: null, password: null }
37 |     const wbEdit = WBEdit({ instance, credentials: creds })
38 |     try {
39 |       await wbEdit.entity.create(params()).then(shouldNotBeCalled)
40 |     } catch (err) {
41 |       rethrowShouldNotBeCalledErrors(err)
42 |       err.message.should.equal('missing credentials')
43 |     }
44 |   })
45 | 
46 |   it('should allow defining credentials both at initialization and request time', async () => {
47 |     const wbEdit = WBEdit({ credentials })
48 |     const res = await wbEdit.entity.create(params(), { instance, credentials })
49 |     res.success.should.equal(1)
50 |   })
51 | 
52 |   it('should reject defining both oauth and username:password credentials', async () => {
53 |     const creds = { username: 'abc', password: 'def', oauth: {} }
54 |     const wbEdit = WBEdit({ instance, credentials: creds })
55 |     try {
56 |       await wbEdit.entity.create(params()).then(shouldNotBeCalled)
57 |     } catch (err) {
58 |       rethrowShouldNotBeCalledErrors(err)
59 |       err.message.should.equal('credentials can not be both oauth tokens, and a username and password')
60 |     }
61 |   })
62 | 
63 |   // TODO: run a similar test for oauth
64 |   if (!('oauth' in credentials)) {
65 |     it('should re-generate credentials when re-using a pre-existing credentials object', done => {
66 |       const wbEdit = WBEdit({ instance })
67 |       const creds = Object.assign({}, credentials)
68 |       wbEdit.entity.create(params(), { credentials: creds })
69 |       .then(() => {
70 |         creds.username = 'foo'
71 |         return wbEdit.entity.create(params(), { credentials: creds })
72 |       })
73 |       .then(undesiredRes(done))
74 |       .catch(err => {
75 |         err.body.error.code.should.equal('assertuserfailed')
76 |         done()
77 |       })
78 |       .catch(done)
79 |     })
80 |   }
81 | })
82 | 


--------------------------------------------------------------------------------
/src/lib/claim/builders.ts:
--------------------------------------------------------------------------------
 1 | import { findEntityTypeFromId } from 'wikibase-sdk'
 2 | import { getTimeObject } from './get_time_object.js'
 3 | import { parseQuantity } from './quantity.js'
 4 | import type { AbsoluteUrl } from '../types/common.js'
 5 | import type { CustomEditableTimeSnakValue } from '../types/snaks.js'
 6 | import type { Claim, EntityId, MonolingualTextSnakDataValue, PropertyId, Snak, SnakDataValue, SnakType, WikibaseEntityIdSnakDataValue, SnakDatavalueType, SnakDataValueByDatavalueType } from 'wikibase-sdk'
 7 | 
 8 | // The difference in builders are due to the different expectations of the Wikibase API
 9 | 
10 | export const singleClaimBuilders = {
11 |   string (str: string) {
12 |     return `"${str}"`
13 |   },
14 |   entity (entityId: EntityId) {
15 |     return JSON.stringify(buildEntitySnakDataValue(entityId))
16 |   },
17 |   time (value: CustomEditableTimeSnakValue | string | number) {
18 |     return JSON.stringify(getTimeObject(value))
19 |   },
20 |   // Property type specific builders
21 |   monolingualtext (valueObj: MonolingualTextSnakDataValue['value']) {
22 |     return JSON.stringify(valueObj)
23 |   },
24 |   quantity (amount: number, instance: AbsoluteUrl) {
25 |     return JSON.stringify(parseQuantity(amount, instance))
26 |   },
27 |   globecoordinate (obj) {
28 |     return JSON.stringify(obj)
29 |   },
30 | }
31 | 
32 | export const entityEditBuilders = {
33 |   string (pid: PropertyId, value) {
34 |     return valueStatementBase(pid, 'string', value)
35 |   },
36 |   entity (pid: PropertyId, value: EntityId | WikibaseEntityIdSnakDataValue) {
37 |     const datavalue = buildEntitySnakDataValue(value)
38 |     return valueStatementBase(pid, 'wikibase-entityid', datavalue)
39 |   },
40 |   monolingualtext (pid: PropertyId, value) {
41 |     return valueStatementBase(pid, 'monolingualtext', value)
42 |   },
43 |   time (pid: PropertyId, value) {
44 |     return valueStatementBase(pid, 'time', getTimeObject(value))
45 |   },
46 |   quantity (pid: PropertyId, value, instance?: AbsoluteUrl) {
47 |     return valueStatementBase(pid, 'quantity', parseQuantity(value, instance))
48 |   },
49 |   globecoordinate (pid: PropertyId, value) {
50 |     return valueStatementBase(pid, 'globecoordinate', value)
51 |   },
52 |   specialSnaktype (pid: PropertyId, snaktype: SnakType) {
53 |     return statementBase(pid, snaktype)
54 |   },
55 | }
56 | 
57 | function buildEntitySnakDataValue (entityId: EntityId | WikibaseEntityIdSnakDataValue): WikibaseEntityIdSnakDataValue['value'] {
58 |   const id = typeof entityId === 'string' ? entityId : entityId.value.id
59 |   const type = findEntityTypeFromId(id)
60 |   // @ts-expect-error
61 |   return { id, 'entity-type': type }
62 | }
63 | 
64 | type ClaimDraft = Pick & {
65 |   mainsnak: Pick & { datavalue?: SnakDataValue }
66 | }
67 | 
68 | function statementBase (pid: PropertyId, snaktype: SnakType): ClaimDraft {
69 |   return {
70 |     rank: 'normal',
71 |     type: 'statement',
72 |     mainsnak: {
73 |       property: pid,
74 |       snaktype,
75 |     },
76 |   }
77 | }
78 | 
79 | function valueStatementBase  (pid: PropertyId, type: T, value: SnakDataValueByDatavalueType[T]['value']) {
80 |   const statement = statementBase(pid, 'value')
81 |   // @ts-expect-error
82 |   statement.mainsnak.datavalue = { type, value }
83 |   return statement
84 | }
85 | 


--------------------------------------------------------------------------------
/src/lib/request/get_final_token.ts:
--------------------------------------------------------------------------------
  1 | import { newError } from '../error.js'
  2 | import { stringifyQuery } from '../utils.js'
  3 | import { getJson } from './get_json.js'
  4 | import { getSignatureHeaders } from './oauth.js'
  5 | import type { HttpHeaders } from './fetch.js'
  6 | import type { APIResponseError } from './request.js'
  7 | import type { AbsoluteUrl } from '../types/common.js'
  8 | import type { SerializedConfig } from '../types/config.js'
  9 | 
 10 | const contentType = 'application/x-www-form-urlencoded'
 11 | 
 12 | interface TokenParams {
 13 |   headers: HttpHeaders
 14 | }
 15 | 
 16 | export function getFinalTokenFactory (config: SerializedConfig) {
 17 |   return async function getFinalToken (loginCookies: string) {
 18 |     const { instanceApiEndpoint, credentials, userAgent } = config
 19 |     const oauthTokens = 'oauth' in credentials ? credentials.oauth : undefined
 20 | 
 21 |     const query = { action: 'query', meta: 'tokens', type: 'csrf', format: 'json' }
 22 |     const url: AbsoluteUrl = `${instanceApiEndpoint}?${stringifyQuery(query)}`
 23 | 
 24 |     const params: TokenParams = {
 25 |       headers: {
 26 |         'user-agent': userAgent,
 27 |         'content-type': contentType,
 28 |       },
 29 |     }
 30 | 
 31 |     if (oauthTokens) {
 32 |       const signatureHeaders = getSignatureHeaders({
 33 |         url,
 34 |         method: 'GET',
 35 |         oauthTokens,
 36 |       })
 37 |       Object.assign(params.headers, signatureHeaders)
 38 |     } else {
 39 |       params.headers.cookie = loginCookies
 40 |     }
 41 | 
 42 |     const body = await getJson(url, params)
 43 |     return parseTokens(loginCookies, instanceApiEndpoint, body)
 44 |   }
 45 | }
 46 | 
 47 | interface TokenResponse {
 48 |   error?: APIResponseError
 49 |   query?: {
 50 |     tokens: {
 51 |       csrftoken: string
 52 |     }
 53 |   }
 54 | }
 55 | 
 56 | export interface ParsedTokenInfo {
 57 |   token: string
 58 |   cookie: string
 59 | }
 60 | 
 61 | async function parseTokens (loginCookies: string, instanceApiEndpoint: AbsoluteUrl, body: TokenResponse) {
 62 |   const { error, query } = body
 63 | 
 64 |   if (error) throw formatError(error, body, instanceApiEndpoint)
 65 | 
 66 |   if (!query?.tokens) {
 67 |     throw newError('could not get tokens', { body })
 68 |   }
 69 | 
 70 |   const { csrftoken } = query.tokens
 71 | 
 72 |   if (csrftoken.length < 40) {
 73 |     throw newError('invalid csrf token', { loginCookies, body })
 74 |   }
 75 | 
 76 |   return {
 77 |     token: csrftoken,
 78 |     cookie: loginCookies,
 79 |   }
 80 | }
 81 | 
 82 | function formatError (error: APIResponseError, body, instanceApiEndpoint) {
 83 |   const err = newError(`${instanceApiEndpoint} error response: ${error.info}`, { body })
 84 |   Object.assign(err, error)
 85 | 
 86 |   if (error.code === 'mwoauth-invalid-authorization' && error['*'] != null) {
 87 |     const domainMatch = error['*'].match(/(https?:\/\/.*\/w\/api.php)/)
 88 |     if (domainMatch != null && domainMatch[1] !== instanceApiEndpoint) {
 89 |       const domain = domainMatch[1]
 90 |       err.message += `\n\n***This might be caused by non-matching domains***
 91 |       between the server domain:\t${domain}
 92 |       and the domain in config:\t${instanceApiEndpoint}\n`
 93 |       // @ts-expect-error
 94 |       err.context.domain = domain
 95 |       // @ts-expect-error
 96 |       err.context.instanceApiEndpoint = instanceApiEndpoint
 97 |     }
 98 |   }
 99 | 
100 |   return err
101 | }
102 | 


--------------------------------------------------------------------------------
/src/lib/types/config.ts:
--------------------------------------------------------------------------------
  1 | import type { PropertiesDatatypes } from '#lib/properties/fetch_properties_datatypes'
  2 | import type { HttpRequestAgent } from '#lib/request/fetch'
  3 | import type { getAuthDataFactory } from '#lib/request/get_auth_data'
  4 | import type { AbsoluteUrl, BaseRevId, MaxLag, Tags } from '#lib/types/common'
  5 | import type { OverrideProperties } from 'type-fest'
  6 | 
  7 | export interface UsernameAndPassword {
  8 |   username: string
  9 |   password: string
 10 | }
 11 | 
 12 | export interface OAuthCredentials {
 13 |   oauth: {
 14 |     consumer_key: string
 15 |     consumer_secret: string
 16 |     token: string
 17 |     token_secret: string
 18 |   }
 19 | }
 20 | 
 21 | export interface GeneralConfig {
 22 |   /**
 23 |    * A Wikibase instance
 24 |    * @example https://www.wikidata.org
 25 |    */
 26 |   instance?: AbsoluteUrl
 27 | 
 28 |   /**
 29 |    * The instance script path, used to find the API endpoint
 30 |    * @default "/w"
 31 |    */
 32 |   wgScriptPath?: string
 33 | 
 34 |   /**
 35 |    * One authorization mean is required (unless in anonymous mode)
 36 |    * Either a username and password, or OAuth tokens.
 37 |    *
 38 |    * You may generate a dedicated password with tailored rights on the wikibase instance /wiki/Special:BotPasswords
 39 |    */
 40 |   credentials?: UsernameAndPassword | OAuthCredentials | { browserSession: true }
 41 | 
 42 |   /**
 43 |    * Flag to activate the 'anonymous' mode
 44 |    * which actually isn't anonymous as it signs with your IP
 45 |    * @default false
 46 |    */
 47 |   anonymous?: boolean
 48 | 
 49 |   /**
 50 |    * A string to describe the edit
 51 |    * See https://meta.wikimedia.org/wiki/Help:Edit_summary
 52 |    * @exampe 'some edit summary common to all the edits'
 53 |    */
 54 |   summary?: string
 55 | 
 56 |   /**
 57 |    * A string that will appended to the config summary
 58 |    * This can be useful, for instance, when making a batch of edits with different requests having different summaries, but a common batch identifier
 59 |    */
 60 |   summarySuffix?: string
 61 | 
 62 |   /**
 63 |    * See https://www.mediawiki.org/wiki/Manual:Tags
 64 |    */
 65 |   tags?: Tags
 66 | 
 67 |   /**
 68 |    * @default `wikidata-edit/${version} (https://github.com/maxlath/wikidata-edit)`
 69 |    */
 70 |   userAgent?: string
 71 | 
 72 |   /**
 73 |    * See https://www.mediawiki.org/wiki/Manual:Bots
 74 |    * @default false
 75 |    */
 76 |   bot?: boolean
 77 | 
 78 |   /**
 79 |    * See https://www.mediawiki.org/wiki/Manual:Maxlag_parameter
 80 |    * @default 5
 81 |    */
 82 |   maxlag?: MaxLag
 83 | 
 84 |   /**
 85 |    * If the Wikibase server returns a `maxlag` error, the request will automatically be re-executed after the amount of seconds recommended by the Wikibase server via the `Retry-After` header. This automatic retry can be disabled by setting `autoRetry` to `false` in the general config or the request config.
 86 |    * @default true
 87 |    */
 88 |   autoRetry?: boolean
 89 | 
 90 |   httpRequestAgent?: HttpRequestAgent
 91 | }
 92 | 
 93 | export interface RequestConfig extends GeneralConfig {
 94 |   baserevid?: BaseRevId
 95 | }
 96 | 
 97 | export type SerializedConfig = OverrideProperties
100 |     _credentialsKey: string
101 |   }
102 | }> & {
103 |   _validatedAndEnriched?: boolean
104 |   instanceApiEndpoint: AbsoluteUrl
105 |   properties: PropertiesDatatypes
106 |   statementsKey: 'claims' | 'statements'
107 | }
108 | 


--------------------------------------------------------------------------------
/tests/unit/entity/create.ts:
--------------------------------------------------------------------------------
  1 | import 'should'
  2 | import config from 'config'
  3 | import { createEntity as _createEntity } from '#lib/entity/create'
  4 | import { shouldNotBeCalled } from '#tests/integration/utils/utils'
  5 | import { randomString, properties } from '#tests/unit/utils'
  6 | 
  7 | const { instance } = config
  8 | 
  9 | const createEntity = params => _createEntity(params, properties, instance, config)
 10 | 
 11 | describe('entity create', async () => {
 12 |   it('should reject parameters with an id', async () => {
 13 |     const params = { id: 'Q3' }
 14 |     await createEntity(params)
 15 |     .then(shouldNotBeCalled)
 16 |     .catch(err => err.message.should.equal("a new entity can't already have an id"))
 17 |   })
 18 | 
 19 |   it('should set the action to wbeditentity', async () => {
 20 |     const params = { labels: { fr: 'foo' } }
 21 |     const { action } = await createEntity(params)
 22 |     action.should.equal('wbeditentity')
 23 |   })
 24 | 
 25 |   it('should then use entity.edit validation features', async () => {
 26 |     const params = { claims: { P2: 'bla' } }
 27 |     await createEntity(params)
 28 |     .then(shouldNotBeCalled)
 29 |     .catch(err => err.message.should.equal('invalid entity value'))
 30 |   })
 31 | 
 32 |   it('should format an item', async () => {
 33 |     const label = randomString()
 34 |     const description = randomString()
 35 |     const frAlias = randomString()
 36 |     const enAlias = randomString()
 37 |     const params = {
 38 |       labels: { en: label },
 39 |       aliases: { fr: frAlias, en: [ enAlias ] },
 40 |       descriptions: { fr: description },
 41 |       claims: { P2: 'Q166376' },
 42 |     }
 43 |     const { data } = await createEntity(params)
 44 |     data.new.should.equal('item')
 45 |     JSON.parse(data.data).should.deepEqual({
 46 |       labels: { en: { language: 'en', value: label } },
 47 |       aliases: {
 48 |         fr: [ { language: 'fr', value: frAlias } ],
 49 |         en: [ { language: 'en', value: enAlias } ],
 50 |       },
 51 |       descriptions: {
 52 |         fr: { language: 'fr', value: description },
 53 |       },
 54 |       claims: {
 55 |         P2: [
 56 |           {
 57 |             rank: 'normal',
 58 |             type: 'statement',
 59 |             mainsnak: {
 60 |               property: 'P2',
 61 |               snaktype: 'value',
 62 |               datavalue: {
 63 |                 type: 'wikibase-entityid',
 64 |                 value: { id: 'Q166376', 'entity-type': 'item' },
 65 |               },
 66 |             },
 67 |           },
 68 |         ],
 69 |       },
 70 |     })
 71 |   })
 72 | 
 73 |   it('should reject a property creation without type', async () => {
 74 |     await createEntity({ datatype: 'string' })
 75 |     .then(shouldNotBeCalled)
 76 |     .catch(err => err.message.should.equal("an item can't have a datatype"))
 77 |   })
 78 | 
 79 |   it('should reject a property creation without datatype', async () => {
 80 |     await createEntity({ type: 'property' })
 81 |     .then(shouldNotBeCalled)
 82 |     .catch(err => err.message.should.equal('missing property datatype'))
 83 |   })
 84 | 
 85 |   it('should create a property', async () => {
 86 |     const label = randomString()
 87 |     const params = {
 88 |       type: 'property',
 89 |       datatype: 'string',
 90 |       labels: { en: label },
 91 |     }
 92 |     const { data } = await createEntity(params)
 93 |     data.new.should.equal('property')
 94 |     JSON.parse(data.data).should.deepEqual({
 95 |       datatype: 'string',
 96 |       labels: { en: { language: 'en', value: label } },
 97 |     })
 98 |   })
 99 | })
100 | 


--------------------------------------------------------------------------------
/src/lib/datatype_tests.ts:
--------------------------------------------------------------------------------
 1 | import { isEntityId, isItemId } from 'wikibase-sdk'
 2 | import { parseCalendar } from './claim/parse_calendar.js'
 3 | import { parseUnit } from './claim/quantity.js'
 4 | import { newError } from './error.js'
 5 | import { isNonEmptyString, isNumber, isStringNumber } from './utils.js'
 6 | 
 7 | export const string = isNonEmptyString
 8 | export const entity = isEntityId
 9 | // See https://www.mediawiki.org/wiki/Wikibase/DataModel#Dates_and_times
10 | // The positive years will be signed later
11 | export const time = time => {
12 |   time = time.value || time
13 |   let precision, calendar, calendarmodel
14 |   if (typeof time === 'object') {
15 |     const dateObject = time
16 |     ;({ precision, calendar, calendarmodel, time } = time)
17 |     if (typeof precision === 'number' && (precision < 0 || precision > 14)) {
18 |       return false
19 |     }
20 |     if (precision > 11) throw newError('time precision not supported by the Wikibase API', dateObject)
21 |     calendarmodel = calendarmodel || calendar
22 |     if (calendarmodel && !parseCalendar(calendarmodel, time)) {
23 |       throw newError('invalid calendar', dateObject)
24 |     }
25 |   }
26 |   time = time.toString()
27 |   const sign = time[0] === '-' ? '-' : '+'
28 |   const year = time.replace(/^(-|\+)/, '').split('-')[0]
29 |   // Parsing as an ISO String should not throw an Invalid time value error
30 |   // Only trying to parse 5-digit years or below, as higher years
31 |   // will fail, even when valid Wikibase dates
32 |   // Excluding negative years as the format expected by Wikibase
33 |   // doesn't have the padding zeros expected by ISO
34 |   if (sign === '+' && year.length <= 5) {
35 |     try {
36 |       time = time.replace(/^\+/, '')
37 |       let isoTime = time
38 |       // ISO validation would fail if either date or month are 0
39 |       // Replace date or date and month digits with 01
40 |       // if precision is less than 11 or 10, respectively
41 |       if (precision != null) {
42 |         if (precision < 10) {
43 |           isoTime = isoTime.replace(/^(\d{4})-\d{1,2}-\d{1,2}/, '$1-01-01')
44 |         } else if (precision < 11) {
45 |           isoTime = isoTime.replace(/^(\d{4}-\d{1,2})-\d{1,2}/, '$1-01')
46 |         }
47 |       }
48 |       new Date(isoTime).toISOString()
49 |     } catch (err) {
50 |       if (err.name === 'RangeError') return false
51 |       else throw err
52 |     }
53 |   }
54 |   if (precision != null && precision > 11) {
55 |     return /^(-|\+)?\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2,3}Z$/.test(time)
56 |   } else if (time.match('T')) {
57 |     return /^(-|\+)?\d{4,16}-\d{2}-\d{2}T00:00:00(\.000)?Z$/.test(time)
58 |   } else {
59 |     return /^(-|\+)?\d{4,16}(-\d{2}){0,2}$/.test(time)
60 |   }
61 | }
62 | export const monolingualtext = value => {
63 |   value = value.value || value
64 |   const { text, language } = value
65 |   return isNonEmptyString(text) && isNonEmptyString(language)
66 | }
67 | // cf https://www.mediawiki.org/wiki/Wikibase/DataModel#Quantities
68 | export const quantity = amount => {
69 |   amount = amount.value || amount
70 |   if (typeof amount === 'object') {
71 |     let unit
72 |     ;({ unit, amount } = amount)
73 |     if (unit && !isItemId(parseUnit(unit)) && unit !== '1') return false
74 |   }
75 |   // Accepting both numbers or string numbers as the amount will be
76 |   // turned as a string lib/claim/builders.js signAmount function anyway
77 |   return isNumber(amount) || isStringNumber(amount)
78 | }
79 | export const globecoordinate = obj => {
80 |   obj = obj.value || obj
81 |   if (typeof obj !== 'object') return false
82 |   const { latitude, longitude, precision } = obj
83 |   return isNumber(latitude) && isNumber(longitude) && isNumber(precision)
84 | }
85 | 


--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
 1 | # wikibase-edit
 2 | Edit [Wikibase](https://wikiba.se) from [NodeJS](https://nodejs.org). That can be [Wikidata](https://www.wikidata.org) or whatever Wikibase instance you have.
 3 | 
 4 | This project has received a [Wikimedia Project Grant](https://meta.wikimedia.org/wiki/Grants:Project/WikidataJS).
 5 | 
 6 | 
7 | wikibase 8 | 9 |           10 | wikidata 11 |
12 | 13 | [![NPM](https://nodei.co/npm/wikibase-edit.png?stars&downloads&downloadRank)](https://npmjs.com/package/wikibase-edit/) 14 | 15 | [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT) 16 | [![Node](https://img.shields.io/badge/node-%3E=%20v7.6.0-brightgreen.svg)](http://nodejs.org) 17 | 18 | [Download stats](https://npm-stat.com/charts.html?package=wikibase-edit) 19 | 20 | ## Summary 21 | - [Changelog](CHANGELOG.md) 22 | - [Install](#install) 23 | - [How-To](https://github.com/maxlath/wikibase-edit/blob/main/docs/how_to.md) 24 | - [Development setup](https://github.com/maxlath/wikibase-edit/blob/main/docs/development_setup.md) 25 | - [Contributing](#contributing) 26 | - [See Also](#see-also) 27 | - [You may also like](#you-may-also-like) 28 | - [License](#license) 29 | 30 | ## Changelog 31 | See [CHANGELOG.md](CHANGELOG.md) for version info 32 | 33 | ## Install 34 | ```sh 35 | npm install wikibase-edit 36 | ``` 37 | 38 | Since `v6.0.0`, `wikibase-edit` uses the [ES module](https://nodejs.org/api/esm.html) syntax. If you would like to use CommonJS instead, you can install the latest version before that change: 39 | ```sh 40 | npm install wikibase-edit@v5 41 | ``` 42 | 43 | ## How-To 44 | See [How-to](docs/how_to.md) doc 45 | 46 | ## Development 47 | See [Development](docs/development.md) doc 48 | 49 | ## Contributing 50 | 51 | Code contributions and propositions are very welcome, here are some design constraints you should be aware of: 52 | * `wikibase-edit` focuses on exposing Wikibase write operations. Features about getting and parsing data should rather go to [`wikibase-sdk`](https://github.com/maxlath/wikibase-sdk) 53 | 54 | ## See Also 55 | * [wikibase-sdk](https://github.com/maxlath/wikibase-sdk): a javascript tool suite to query and work with any Wikibase data, heavily used by wikibase-edit and wikibase-cli 56 | * [wikibase-cli](https://github.com/maxlath/wikibase-cli): The friendly command-line interface to Wikibase 57 | * [wikibase-dump-filter](https://npmjs.com/package/wikibase-dump-filter): Filter and format a newline-delimited JSON stream of Wikibase entities 58 | * [wikidata-subset-search-engine](https://github.com/inventaire/entities-search-engine/tree/wikidata-subset-search-engine): Tools to setup an ElasticSearch instance fed with subsets of Wikidata 59 | * [wikidata-taxonomy](https://github.com/nichtich/wikidata-taxonomy): Command-line tool to extract taxonomies from Wikidata 60 | * [Other Wikidata external tools](https://www.wikidata.org/wiki/Wikidata:Tools/External_tools) 61 | 62 | ## You may also like 63 | 64 | [![inventaire banner](https://inventaire.io/public/images/inventaire-brittanystevens-13947832357-CC-BY-lighter-blue-4-banner-500px.png)](https://inventaire.io) 65 | 66 | Do you know [Inventaire](https://inventaire.io/)? It's a web app to share books with your friends, built on top of Wikidata! And its [libre software](http://github.com/inventaire/inventaire) too. 67 | 68 | ## License 69 | [MIT](LICENSE.md) 70 | -------------------------------------------------------------------------------- /src/lib/request_wrapper.ts: -------------------------------------------------------------------------------- 1 | import { newError } from './error.js' 2 | import { fetchUsedPropertiesDatatypes } from './properties/fetch_used_properties_datatypes.js' 3 | import { initializeConfigAuth } from './request/initialize_config_auth.js' 4 | import { post } from './request/post.js' 5 | import { resolveTitle } from './resolve_title.js' 6 | import { isNonEmptyString } from './utils.js' 7 | import { validateAndEnrichConfig } from './validate_and_enrich_config.js' 8 | import validateParameters from './validate_parameters.js' 9 | import type { addAlias } from './alias/add.js' 10 | import type { removeAlias } from './alias/remove.js' 11 | import type { setAlias } from './alias/set.js' 12 | import type { removeClaim } from './claim/remove.js' 13 | import type { setClaim } from './claim/set.js' 14 | import type { setDescription } from './description/set.js' 15 | import type { createEntity } from './entity/create.js' 16 | import type { deleteEntity } from './entity/delete.js' 17 | import type { _rawEditEntity, editEntity } from './entity/edit.js' 18 | import type { mergeEntity } from './entity/merge.js' 19 | import type { setLabel } from './label/set.js' 20 | import type { removeQualifier } from './qualifier/remove.js' 21 | import type { setQualifier } from './qualifier/set.js' 22 | import type { removeReference } from './reference/remove.js' 23 | import type { setReference } from './reference/set.js' 24 | import type { setSitelink } from './sitelink/set.js' 25 | import type { BaseRevId } from './types/common.js' 26 | import type { GeneralConfig, RequestConfig, SerializedConfig } from './types/config.js' 27 | 28 | type ActionFunction = typeof addAlias | typeof removeAlias | typeof setAlias | typeof removeClaim | typeof setClaim | typeof setDescription | typeof createEntity | typeof deleteEntity | typeof editEntity | typeof mergeEntity | typeof setLabel | typeof removeQualifier | typeof setQualifier | typeof removeReference | typeof setReference | typeof setSitelink | typeof _rawEditEntity 29 | 30 | // Params could be captured with Parameters[0], but the resulting typing isn't great 31 | export function requestWrapper (actionFn: F, generalConfig: GeneralConfig) { 32 | return async function request (params: Params, reqConfig?: RequestConfig) { 33 | const config = validateAndEnrichConfig(generalConfig, reqConfig) 34 | validateParameters(params) 35 | initializeConfigAuth(config) 36 | 37 | await fetchUsedPropertiesDatatypes(params, config) 38 | 39 | if (!config.properties) throw newError('properties not found', config) 40 | 41 | const { action, data } = await actionFn(params, config.properties, config.instance, config) 42 | 43 | const { summarySuffix } = config 44 | let summary = 'summary' in params ? params.summary : config.summary 45 | if (summarySuffix) { 46 | if (typeof summary === 'string') { 47 | summary = `${summary.trim()} ${summarySuffix.trim()}` 48 | } else { 49 | summary = summarySuffix 50 | } 51 | } 52 | 53 | const baserevid = 'baserevid' in params ? params.baserevid : config.baserevid 54 | 55 | const extraData: Partial> = {} 56 | 57 | if (isNonEmptyString(summary)) { 58 | extraData.summary = summary.trim() 59 | } 60 | 61 | if (baserevid != null) extraData.baserevid = baserevid as BaseRevId 62 | 63 | if ('title' in data) { 64 | const title = await resolveTitle(data.title, config.instanceApiEndpoint) 65 | return post(action, { ...data, ...extraData, title }, config) as Response 66 | } else { 67 | return post(action, { ...data, ...extraData }, config) as Response 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/lib/request/fetch.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'cross-fetch' 2 | import { debug, debugMode } from '../debug.js' 3 | import type { AbsoluteUrl } from '../types/common.js' 4 | import type { Agent as HttpAgent } from 'node:http' 5 | import type { Agent as HttpsAgent } from 'node:https' 6 | 7 | const isNode = globalThis.process?.versions?.node != null 8 | 9 | let agent 10 | 11 | if (isNode) { 12 | // Using a custom agent to set keepAlive=true 13 | // https://nodejs.org/api/http.html#http_class_http_agent 14 | // https://github.com/bitinn/node-fetch#custom-agent 15 | const http = await import('node:http') 16 | const https = await import('node:https') 17 | const httpAgent = new http.Agent({ keepAlive: true }) 18 | const httpsAgent = new https.Agent({ keepAlive: true }) 19 | agent = ({ protocol }) => protocol === 'http:' ? httpAgent : httpsAgent 20 | } 21 | 22 | export type HttpHeaderKey = 'content-type' | 'cookie' | 'user-agent' 23 | export type HttpHeaders = Partial> 24 | export type HttpMethodLowerCased = 'get' | 'post' | 'put' | 'delete' 25 | export type HttpMethod = HttpMethodLowerCased | Uppercase 26 | 27 | export interface CustomFetchOptions { 28 | method?: HttpMethod 29 | headers?: HttpHeaders 30 | timeout?: number 31 | agent?: HttpAgent | HttpsAgent 32 | body?: string 33 | } 34 | 35 | export type HttpRequestAgent = HttpAgent | HttpsAgent 36 | 37 | export async function customFetch (url: AbsoluteUrl, { timeout, ...options }: CustomFetchOptions = {}) { 38 | options.agent = options.agent || agent 39 | options.headers['accept-encoding'] = 'gzip,deflate' 40 | if (debugMode) { 41 | const { method = 'get', headers, body } = options 42 | debug('request', method.toUpperCase(), url, { 43 | headers: obfuscateHeaders(headers), 44 | body: obfuscateBody({ url, body }), 45 | }) 46 | } 47 | try { 48 | return await fetchWithTimeout(url, options, timeout) 49 | } catch (err) { 50 | if (err.type === 'aborted') { 51 | const rephrasedErr = new Error('request timeout') 52 | rephrasedErr.cause = err 53 | throw rephrasedErr 54 | } else { 55 | throw err 56 | } 57 | } 58 | } 59 | 60 | // Based on https://stackoverflow.com/questions/46946380/fetch-api-request-timeout#57888548 61 | function fetchWithTimeout (url: AbsoluteUrl, options: CustomFetchOptions, timeoutMs = 120_000) { 62 | const controller = new AbortController() 63 | const promise = fetch(url, { 64 | keepalive: true, 65 | signal: controller.signal, 66 | credentials: 'include', 67 | mode: 'cors', 68 | ...options, 69 | }) 70 | const timeout = setTimeout(() => controller.abort(), timeoutMs) 71 | return promise.finally(() => clearTimeout(timeout)) 72 | }; 73 | 74 | function obfuscateHeaders (headers: HttpHeaders) { 75 | const obfuscatedHeadersEntries = Object.entries(headers).map(([ name, value ]) => [ name.toLowerCase(), value ]) 76 | const obfuscatedHeaders = Object.fromEntries(obfuscatedHeadersEntries) 77 | if (obfuscatedHeaders.authorization) { 78 | obfuscatedHeaders.authorization = obfuscatedHeaders.authorization.replace(/"[^"]+"/g, '"***"') 79 | } 80 | if (obfuscatedHeaders.cookie) { 81 | obfuscatedHeaders.cookie = obfuscateParams(obfuscatedHeaders.cookie) 82 | } 83 | return obfuscatedHeaders 84 | } 85 | 86 | function obfuscateBody ({ url, body = '' }: { url: string, body?: string }) { 87 | const { searchParams } = new URL(url) 88 | if (searchParams.get('action') === 'login') { 89 | return obfuscateParams(body) 90 | } else { 91 | return body.replace(/token=[^=\s;&]+([=\s;&]?)/g, 'token=***$1') 92 | } 93 | } 94 | 95 | function obfuscateParams (urlEncodedStr: string) { 96 | return urlEncodedStr.replace(/=[^=\s;&]+([=\s;&]?)/g, '=***$1') 97 | } 98 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import type { PostData, PostQuery } from './request/post.js' 2 | import type { AbsoluteUrl } from './types/common.js' 3 | import type { ObjectEntries } from 'type-fest/source/entries.js' 4 | 5 | const stringNumberPattern = /^(-|\+)?\d+(\.\d+)?$/ 6 | const signedStringNumberPattern = /^(-|\+)\d+(\.\d+)?$/ 7 | 8 | export type Query = Record 9 | 10 | export function stringifyQuery (query: Query | PostQuery | PostData) { 11 | // @ts-expect-error 12 | return new URLSearchParams(query).toString() 13 | } 14 | 15 | export type NonEmptyString = Exclude 16 | export function isNonEmptyString (str: unknown): str is NonEmptyString { 17 | return typeof str === 'string' && str.length > 0 18 | } 19 | 20 | export function buildUrl (base: AbsoluteUrl, query: Query | PostQuery) { 21 | return `${base}?${stringifyQuery(query)}` as AbsoluteUrl 22 | } 23 | // helpers to simplify polymorphisms 24 | export function forceArray (obj: T | T[]): T[] { 25 | if (obj == null) return [] 26 | if (!(obj instanceof Array)) return [ obj ] 27 | return obj 28 | } 29 | export const isString = (str: unknown) => typeof str === 'string' 30 | export const isNumber = (num: unknown) => typeof num === 'number' 31 | export function isStringNumber (str: string): str is `${number}` { 32 | return stringNumberPattern.test(str) 33 | } 34 | 35 | type Sign = '-' | '+' 36 | type SignNumber = `${Sign}${number}` 37 | export function isSignedStringNumber (str: unknown): str is SignNumber { 38 | return typeof str === 'string' && signedStringNumberPattern.test(str) 39 | } 40 | export const isArray = (array: unknown) => array instanceof Array 41 | 42 | type PlainObject = Exclude 43 | export function isPlainObject (obj: unknown): obj is PlainObject { 44 | if (obj instanceof Array) return false 45 | if (obj === null) return false 46 | return typeof obj === 'object' 47 | } 48 | export function isntEmpty (value: unknown): value is Exclude { 49 | return value != null 50 | } 51 | export function mapValues (obj, fn) { 52 | function aggregator (index, key) { 53 | index[key] = fn(key, obj[key]) 54 | return index 55 | } 56 | return Object.keys(obj).reduce(aggregator, {}) 57 | } 58 | 59 | export const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) 60 | 61 | // Work around the TS2345 error when using Array include method 62 | // https://stackoverflow.com/questions/55906553/typescript-unexpected-error-when-using-includes-with-a-typed-array/70532727#70532727 63 | // Implementation inspired by https://8hob.io/posts/elegant-safe-solution-for-typing-array-includes/#elegant-and-safe-solution 64 | export function arrayIncludes (array: readonly T[], value: string | number): value is T { 65 | const arrayT: readonly (string | number)[] = array 66 | return arrayT.includes(value) 67 | } 68 | 69 | // Same as `arrayIncludes` but for sets 70 | export function setHas (set: Set, value: string | number): value is T { 71 | const setT: Set = set 72 | return setT.has(value) 73 | } 74 | 75 | export function objectEntries (obj: Obj) { 76 | return Object.entries(obj) as ObjectEntries 77 | } 78 | 79 | export function objectFromEntries (entries: [ K, V ][]) { 80 | return Object.fromEntries(entries) as Record 81 | } 82 | 83 | export function objectValues (obj: Obj) { 84 | return Object.values(obj) as Obj[keyof Obj][] 85 | } 86 | 87 | // Source: https://www.totaltypescript.com/tips/create-your-own-objectkeys-function-using-generics-and-the-keyof-operator 88 | export function objectKeys (obj: Obj): (keyof Obj)[] { 89 | return Object.keys(obj) as (keyof Obj)[] 90 | } 91 | 92 | export function hasTruthy (params: object, attribute: string): params is Exclude<{ [attribute]: unknown }, { [attribute]: false | undefined | null | 0 | '' }> { 93 | return attribute in params && params[attribute] 94 | } 95 | -------------------------------------------------------------------------------- /docs/development_setup.md: -------------------------------------------------------------------------------- 1 | # Development setup 2 | 3 | Development setup is mostly about getting confortable with [automated tests](https://en.wikipedia.org/wiki/Test_automation): it's nice to add new features, it's better to know that those features won't be broken or removed by mistake. This can be done by adding automated tests: those tests will be run before publishing any new version to guarantee that the new version doesn't introduce any regression. 4 | 5 | ## Summary 6 | 7 | 8 | 9 | 10 | - [Install tests dependencies](#install-tests-dependencies) 11 | - [Unit tests](#unit-tests) 12 | - [Run a single unit test file](#run-a-single-unit-test-file) 13 | - [Run all the unit tests](#run-all-the-unit-tests) 14 | - [Integration tests](#integration-tests) 15 | - [Setup a test Wikibase instance](#setup-a-test-wikibase-instance) 16 | - [Use test.wikidata.org](#use-testwikidataorg) 17 | - [Install a local Wikibase with Docker](#install-a-local-wikibase-with-docker) 18 | - [Run a single integration test file](#run-a-single-integration-test-file) 19 | - [Run all the integration tests](#run-all-the-integration-tests) 20 | 21 | 22 | 23 | ## Install tests dependencies 24 | ```sh 25 | npm install 26 | ``` 27 | This will install the dependencies we need to run tests, especially: 28 | * [mocha](https://mochajs.org/): the executable to which we pass test files, and that defines the following global functions used in test files: `describe`, `it`, `beforeEach`, etc 29 | * [should.js](https://shouldjs.github.io/): a lib to easily make assertions and throw errors when those assertions aren't true: 30 | ```js 31 | (12).should.be.above(10) // will not throw 32 | (12).should.be.below(10) // will throw and thus make the test fail 33 | ``` 34 | 35 | ## Unit tests 36 | [Unit tests](https://en.wikipedia.org/wiki/Unit_testing) are used to test a single function at once, and can be run without any other setup. 37 | 38 | ### Run a single unit test file 39 | ```sh 40 | ./node_modules/.bin/mocha ./tests/unit/parse_instance.js 41 | ``` 42 | Just try to run it, you can't break anything! And then try to modify the function it tests, `lib/parse_instance.js`, and see how that makes the tests fail. 43 | 44 | To run only one test in that file, replace `it(` by `it.only(` 45 | 46 | ### Run all the unit tests 47 | ```sh 48 | npm run test:unit 49 | ``` 50 | 51 | ## Integration tests 52 | [Integration tests](https://en.wikipedia.org/wiki/Integration_testing) are used to check that the function produce the desired behaviour on a Wikibase instance. We thus need to have a Wikibase instance at hand to run our functions against, thus the more elaborated setup. 53 | 54 | ### Setup a test Wikibase instance 55 | 56 | Create a `./config/local.js` file overriding values in `./config/default.js` with your credentials on the Wikibase instance you want to use: either [test.wikidata.org](https://test.wikidata.org) or your own local Wikibase. 57 | 58 | #### Use [test.wikidata.org](https://test.wikidata.org) 59 | That's the easiest option. 60 | 61 | **Pros**: 62 | * zero setup 63 | 64 | **Cons**: 65 | * tests are slowed down by network latency 66 | * doesn't work when you're offline/on a bad connection 67 | 68 | Tests should pass as any user can create properties on that instance. That's probably the easiest setup to get started. 69 | 70 | #### Install a local Wikibase with Docker 71 | 72 | ```sh 73 | git clone https://github.com/wmde/wikibase-docker 74 | cd wikibase-docker 75 | docker-compose up -d wikibase 76 | ``` 77 | 78 | See [`Docker documentation`](https://docs.docker.com/compose/install/) 79 | 80 | ### Run a single integration test file 81 | ```sh 82 | ./node_modules/.bin/mocha ./tests/integration/label/set.js 83 | ``` 84 | To run only one test in that file, replace `it(` by `it.only(` 85 | 86 | ### Run all the integration tests 87 | ```sh 88 | npm run test:integration 89 | ``` 90 | -------------------------------------------------------------------------------- /src/lib/claim/update.ts: -------------------------------------------------------------------------------- 1 | import { isGuid, getEntityIdFromGuid, type Guid, type PropertyId, type Rank, type Claim, type SnakBase } from 'wikibase-sdk' 2 | import { newError } from '../error.js' 3 | import { getEntityClaims } from '../get_entity.js' 4 | import { findSnak } from './find_snak.js' 5 | import { findClaimByGuid, isGuidClaim, simplifyClaimForEdit } from './helpers.js' 6 | import { hasSpecialSnaktype } from './special_snaktype.js' 7 | import type { EditEntitySimplifiedModeParams } from '../entity/edit.js' 8 | import type { WikibaseEditAPI } from '../index.js' 9 | import type { BaseRevId } from '../types/common.js' 10 | import type { SerializedConfig } from '../types/config.js' 11 | import type { RawEditableEntity } from '../types/edit_entity.js' 12 | import type { EditableMonolingualTextSnakValue, EditableSnakValue } from '../types/snaks.js' 13 | 14 | export interface UpdateClaimParams { 15 | id?: RawEditableEntity['id'] 16 | guid?: Guid 17 | property?: PropertyId 18 | oldValue?: EditableSnakValue 19 | newValue?: EditableSnakValue 20 | rank?: Rank 21 | summary?: string 22 | baserevid?: BaseRevId 23 | } 24 | 25 | export async function updateClaim (params: UpdateClaimParams, config: SerializedConfig, API: WikibaseEditAPI) { 26 | let { id, guid, property } = params 27 | const { oldValue, newValue, rank } = params 28 | const { statementsKey } = config 29 | 30 | if (!(rank != null || newValue != null)) { 31 | throw newError('expected a rank or a newValue', 400, params) 32 | } 33 | 34 | if (isGuid(guid)) { 35 | id = getEntityIdFromGuid(guid) as RawEditableEntity['id'] 36 | } else { 37 | const values = { oldValue, newValue } 38 | if (oldValue === newValue) { 39 | throw newError("old and new claim values can't be the same", 400, values) 40 | } 41 | if (typeof oldValue !== typeof newValue) { 42 | throw newError('old and new claim should have the same type', 400, values) 43 | } 44 | } 45 | 46 | const claims = await getEntityClaims(id, config) 47 | 48 | let claim 49 | if (guid) { 50 | claim = findClaimByGuid(claims, guid) 51 | property = claim?.mainsnak.property 52 | } else { 53 | claim = findSnak(property, claims[property] as Claim[], oldValue) 54 | } 55 | 56 | if (!claim) { 57 | throw newError('claim not found', 400, params) 58 | } 59 | 60 | const simplifiedClaim = simplifyClaimForEdit(claim) 61 | 62 | guid = claim.id 63 | 64 | if (rank) simplifiedClaim.rank = rank 65 | 66 | if (newValue != null) { 67 | if (hasSpecialSnaktype(newValue)) { 68 | simplifiedClaim.snaktype = newValue.snaktype 69 | delete simplifiedClaim.value 70 | } else { 71 | simplifiedClaim.value = formatUpdatedSnakValue(newValue, claim.mainsnak) 72 | } 73 | } 74 | 75 | const data: EditEntitySimplifiedModeParams = { 76 | id, 77 | [statementsKey]: { 78 | [property]: simplifiedClaim, 79 | }, 80 | // Using wbeditentity, as the endpoint is more complete, so we need to recover the summary 81 | summary: params.summary || config.summary || `update ${property} claim`, 82 | baserevid: params.baserevid || config.baserevid, 83 | } 84 | 85 | const { entity, success } = await API.entity.edit(data, config) 86 | 87 | const updatedClaim = entity[statementsKey][property].find(isGuidClaim(guid)) 88 | // Mimick claim actions responses 89 | return { claim: updatedClaim, success } 90 | } 91 | 92 | export function formatUpdatedSnakValue (newValue: EditableSnakValue, updatedSnak: SnakBase) { 93 | if (updatedSnak.snaktype !== 'value') return newValue 94 | const { type } = updatedSnak.datavalue 95 | if (type === 'monolingualtext' && typeof newValue === 'string') { 96 | const { language } = updatedSnak.datavalue.value 97 | return { text: newValue, language } as EditableMonolingualTextSnakValue 98 | } else { 99 | return newValue 100 | } 101 | } 102 | 103 | export interface UpdateClaimResponse { 104 | claim: Claim 105 | success: 1 106 | } 107 | -------------------------------------------------------------------------------- /tests/integration/reference/set.ts: -------------------------------------------------------------------------------- 1 | import 'should' 2 | import config from 'config' 3 | import { getSandboxClaimId, getSandboxPropertyId, getRefreshedClaim } from '#tests/integration/utils/sandbox_entities' 4 | import { waitForInstance } from '#tests/integration/utils/wait_for_instance' 5 | import { assert, randomString } from '#tests/unit/utils' 6 | import WBEdit from '#root' 7 | import type { SpecialSnak } from '../../../src/lib/claim/special_snaktype' 8 | 9 | const wbEdit = WBEdit(config) 10 | const setReference = wbEdit.reference.set 11 | 12 | describe('reference set', function () { 13 | this.timeout(20 * 1000) 14 | before('wait for instance', waitForInstance) 15 | 16 | it('should set a reference with the property/value interface', async () => { 17 | const [ guid, property ] = await Promise.all([ 18 | getSandboxClaimId(), 19 | getSandboxPropertyId('string'), 20 | ]) 21 | const value = randomString() 22 | const res = await setReference({ guid, property, value }) 23 | res.success.should.equal(1) 24 | assert('datavalue' in res.reference.snaks[property][0]) 25 | res.reference.snaks[property][0].datavalue.value.should.equal(value) 26 | }) 27 | 28 | it('should set a reference with the snaks object interface', async () => { 29 | const [ guid, stringProperty, quantityProperty ] = await Promise.all([ 30 | getSandboxClaimId(), 31 | getSandboxPropertyId('string'), 32 | getSandboxPropertyId('quantity'), 33 | ]) 34 | const stringValue = randomString() 35 | const quantityValue = Math.random() 36 | const snaks = { 37 | [stringProperty]: [ 38 | { snaktype: 'novalue' } as SpecialSnak, 39 | stringValue, 40 | ], 41 | [quantityProperty]: quantityValue, 42 | } 43 | const res = await setReference({ guid, snaks }) 44 | res.success.should.equal(1) 45 | res.reference.snaks[stringProperty][0].snaktype.should.equal('novalue') 46 | assert('datavalue' in res.reference.snaks[stringProperty][1]) 47 | assert('datavalue' in res.reference.snaks[quantityProperty][0]) 48 | res.reference.snaks[stringProperty][1].datavalue.value.should.equal(stringValue) 49 | assert(typeof res.reference.snaks[quantityProperty][0].datavalue.value === 'object') 50 | assert('amount' in res.reference.snaks[quantityProperty][0].datavalue.value) 51 | res.reference.snaks[quantityProperty][0].datavalue.value.amount.should.equal(`+${quantityValue}`) 52 | }) 53 | 54 | it('should update a reference by passing a hash', async () => { 55 | const [ guid, stringProperty, quantityProperty ] = await Promise.all([ 56 | getSandboxClaimId(), 57 | getSandboxPropertyId('string'), 58 | getSandboxPropertyId('quantity'), 59 | ]) 60 | const initialClaim = await getRefreshedClaim(guid) 61 | const stringValue = randomString() 62 | const quantityValue = Math.random() 63 | const initialSnaks = { 64 | [stringProperty]: { snaktype: 'novalue' } as SpecialSnak, 65 | } 66 | const res1 = await setReference({ guid, snaks: initialSnaks }) 67 | res1.reference.snaks[stringProperty][0].snaktype.should.equal('novalue') 68 | const { hash } = res1.reference 69 | const updatedSnaks = { 70 | [stringProperty]: stringValue, 71 | [quantityProperty]: quantityValue, 72 | } 73 | const res2 = await setReference({ guid, hash, snaks: updatedSnaks }, { summary: 'here' }) 74 | assert('datavalue' in res2.reference.snaks[stringProperty][0]) 75 | assert('datavalue' in res2.reference.snaks[quantityProperty][0]) 76 | res2.reference.snaks[stringProperty][0].datavalue.value.should.equal(stringValue) 77 | assert(typeof res2.reference.snaks[quantityProperty][0].datavalue.value === 'object') 78 | assert('amount' in res2.reference.snaks[quantityProperty][0].datavalue.value) 79 | res2.reference.snaks[quantityProperty][0].datavalue.value.amount.should.equal(`+${quantityValue}`) 80 | const claim = await getRefreshedClaim(guid) 81 | claim.references.length.should.equal(initialClaim.references.length + 1) 82 | claim.references.slice(-1)[0].hash.should.equal(res2.reference.hash) 83 | }) 84 | }) 85 | -------------------------------------------------------------------------------- /src/lib/request/request.ts: -------------------------------------------------------------------------------- 1 | import { stringifyQuery, wait, type Query } from '../utils.js' 2 | import checkKnownIssues from './check_known_issues.js' 3 | import { customFetch, type HttpHeaders, type HttpMethod, type HttpRequestAgent } from './fetch.js' 4 | import { getSignatureHeaders } from './oauth.js' 5 | import { parseResponseBody } from './parse_response_body.js' 6 | import type { PostData } from './post.js' 7 | import type { AbsoluteUrl } from '../types/common.js' 8 | import type { OAuthCredentials } from '../types/config.js' 9 | 10 | const timeout = 30000 11 | 12 | export interface RequestParams { 13 | url: AbsoluteUrl 14 | body?: Query | PostData 15 | oauth?: OAuthCredentials['oauth'] 16 | headers?: HttpHeaders 17 | autoRetry?: boolean 18 | httpRequestAgent?: HttpRequestAgent 19 | } 20 | 21 | export async function request (verb: HttpMethod, params: RequestParams) { 22 | const method = verb || 'get' 23 | const { url, body, oauth: oauthTokens, headers, autoRetry = true, httpRequestAgent } = params 24 | let maxlag 25 | if (typeof body === 'object' && 'maxlag' in body) { 26 | maxlag = body.maxlag 27 | } 28 | let attempts = 1 29 | 30 | let bodyStr 31 | if (method === 'post' && body != null) { 32 | bodyStr = stringifyQuery(body) 33 | headers['Content-Type'] = 'application/x-www-form-urlencoded' 34 | } 35 | 36 | async function tryRequest () { 37 | if (oauthTokens) { 38 | const signatureHeaders = getSignatureHeaders({ 39 | url, 40 | method, 41 | data: body, 42 | oauthTokens, 43 | }) 44 | Object.assign(headers, signatureHeaders) 45 | } 46 | 47 | try { 48 | const res = await customFetch(url, { method, body: bodyStr, headers, timeout, agent: httpRequestAgent }) 49 | return await parseResponseBody(res) 50 | } catch (err) { 51 | checkKnownIssues(url, err) 52 | if (autoRetry === false) throw err 53 | if (errorIsWorthARetry(err)) { 54 | const delaySeconds = getRetryDelay(err.headers) * attempts 55 | retryWarn(verb, url, err, delaySeconds, attempts++, maxlag) 56 | await wait(delaySeconds * 1000) 57 | return tryRequest() 58 | } else { 59 | err.context ??= {} 60 | err.context.request = { url, body } 61 | throw err 62 | } 63 | } 64 | } 65 | 66 | return tryRequest() 67 | } 68 | 69 | export interface APIResponseError { 70 | code: string 71 | info: string 72 | } 73 | 74 | function errorIsWorthARetry (err) { 75 | if (errorsWorthARetry.has(err.name) || errorsWorthARetry.has(err.type) || errorsCodeWorthARetry.has(err.code || err.cause?.code)) return true 76 | // failed-save might be a recoverable error from the server 77 | // See https://github.com/maxlath/wikibase-cli/issues/150 78 | if (err.name === 'failed-save') { 79 | const { messages } = err.body.error 80 | return !messages.some(isNonRecoverableFailedSave) 81 | } 82 | return false 83 | } 84 | 85 | const isNonRecoverableFailedSave = message => message.name.startsWith('wikibase-validator') || nonRecoverableFailedSaveMessageNames.has(message.name) 86 | 87 | const errorsWorthARetry = new Set([ 88 | 'maxlag', 89 | 'TimeoutError', 90 | 'request-timeout', 91 | 'wrong response format', 92 | ]) 93 | 94 | const errorsCodeWorthARetry = new Set([ 95 | 'ECONNREFUSED', 96 | 'UND_ERR_CONNECT_TIMEOUT', 97 | ]) 98 | 99 | const nonRecoverableFailedSaveMessageNames = new Set([ 100 | 'protectedpagetext', 101 | 'permissionserrors', 102 | ]) 103 | 104 | const defaultRetryDelay = 5 105 | function getRetryDelay (headers) { 106 | const retryAfterSeconds = headers?.['retry-after'] 107 | if (/^\d+$/.test(retryAfterSeconds)) return parseInt(retryAfterSeconds) 108 | else return defaultRetryDelay 109 | } 110 | 111 | function retryWarn (verb, url, err, delaySeconds, attempts, maxlag) { 112 | verb = verb.toUpperCase() 113 | const maxlagStr = typeof maxlag === 'number' ? `${maxlag}s` : maxlag 114 | console.warn(`[wikibase-edit][WARNING] ${verb} ${url} 115 | ${err.message} 116 | retrying in ${delaySeconds}s (attempt: ${attempts}, maxlag: ${maxlagStr})`) 117 | } 118 | --------------------------------------------------------------------------------