├── .eslintrc.cjs
├── .github
├── FUNDING.yml
└── ISSUE_TEMPLATE
│ ├── bug_report.md
│ ├── config.yml
│ ├── feature_request.md
│ └── question.md
├── .gitignore
├── CHANGELOG.md
├── README.md
├── assets
└── metadata.js
├── browser-demo
├── README.md
├── build.sh
├── index.html
├── main.js
└── package.json
├── config
├── default.cjs
└── local.cjs
├── docs
├── development_setup.md
└── how_to.md
├── jsconfig.json
├── lib
├── alias
│ ├── action.js
│ ├── add.js
│ ├── remove.js
│ └── set.js
├── badge
│ ├── add.js
│ └── remove.js
├── bundle_wrapper.js
├── claim
│ ├── builders.js
│ ├── claim_parsers.js
│ ├── create.js
│ ├── find_snak.js
│ ├── format_claim_value.js
│ ├── get_time_object.js
│ ├── helpers.js
│ ├── is_matching_claim.js
│ ├── is_matching_snak.js
│ ├── move.js
│ ├── move_commons.js
│ ├── parse_calendar.js
│ ├── quantity.js
│ ├── remove.js
│ ├── set.js
│ ├── snak.js
│ ├── snak_post_data.js
│ ├── special_snaktype.js
│ └── update.js
├── datatype_tests.js
├── debug.js
├── description
│ └── set.js
├── entity
│ ├── build_claim.js
│ ├── create.js
│ ├── delete.js
│ ├── edit.js
│ ├── format.js
│ ├── id_alias.js
│ ├── merge.js
│ ├── reconcile_claim.js
│ └── validate_reconciliation_object.js
├── error.js
├── get_entity.js
├── get_instance_wikibase_sdk.js
├── index.js
├── issues.js
├── label
│ └── set.js
├── label_or_description
│ └── set.js
├── parse_instance.js
├── properties
│ ├── datatypes_to_builder_datatypes.js
│ ├── fetch_properties_datatypes.js
│ ├── fetch_used_properties_datatypes.js
│ └── find_snaks_properties.js
├── qualifier
│ ├── move.js
│ ├── remove.js
│ ├── set.js
│ └── update.js
├── reference
│ ├── remove.js
│ └── set.js
├── request
│ ├── check_known_issues.js
│ ├── fetch.js
│ ├── get_auth_data.js
│ ├── get_final_token.js
│ ├── get_json.js
│ ├── get_token.js
│ ├── initialize_config_auth.js
│ ├── login.js
│ ├── oauth.js
│ ├── parse_response_body.js
│ ├── parse_session_cookies.js
│ ├── post.js
│ └── request.js
├── request_wrapper.js
├── resolve_title.js
├── sitelink
│ └── set.js
├── utils.js
├── validate.js
├── validate_and_enrich_config.js
└── validate_parameters.js
├── package-lock.json
├── package.json
├── scripts
├── githooks
│ └── pre-commit
├── postversion
└── update_toc
└── tests
├── .eslintrc.cjs
├── integration
├── alias
│ ├── add.js
│ ├── remove.js
│ └── set.js
├── anonymous_edit.js
├── badge
│ ├── add.js
│ └── remove.js
├── baserevid.js
├── claim
│ ├── create.js
│ ├── move_claim.js
│ ├── move_property_claims.js
│ ├── reconciliation.js
│ ├── reconciliation_matching.js
│ ├── reconciliation_merge_mode.js
│ ├── reconciliation_per_datatypes.js
│ ├── reconciliation_remove.js
│ ├── reconciliation_skip_on_any_value.js
│ ├── reconciliation_skip_on_value_match_mode.js
│ ├── remove.js
│ ├── set.js
│ └── update.js
├── credentials.js
├── description
│ └── set.js
├── entity
│ ├── create.js
│ ├── delete.js
│ ├── edit.js
│ └── merge.js
├── fetch_properties_datatypes.js
├── get_auth_data.js
├── get_token.js
├── label
│ └── set.js
├── maxlag.js
├── multi_users.js
├── qualifier
│ ├── move.js
│ ├── remove.js
│ ├── set.js
│ └── update.js
├── reference
│ ├── remove.js
│ └── set.js
├── sitelink
│ └── set.js
├── summary.js
├── tags.js
├── token_expiration.js
└── utils
│ ├── get_property.js
│ ├── sandbox_entities.js
│ ├── sandbox_snaks.js
│ ├── utils.js
│ └── wait_for_instance.js
└── unit
├── alias
├── add.js
├── remove.js
└── set.js
├── claim
├── remove.js
├── set.js
└── time.js
├── description
└── set.js
├── entity
├── create.js
├── delete.js
├── edit.js
└── merge.js
├── general.js
├── label
└── set.js
├── parse_instance.js
├── qualifier
├── remove.js
└── set.js
├── reference
├── remove.js
└── set.js
├── request.js
├── sitelink
└── set.js
└── utils.js
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | // This config file is used by eslint
2 | // See package.json scripts: lint*
3 | // Rules documentation: https://eslint.org/docs/rules/
4 | // Inspect the generated config:
5 | // eslint --print-config .eslintrc.cjs
6 | module.exports = {
7 | root: true,
8 | extends: [
9 | // See https://github.com/standard/eslint-config-standard/blob/master/.eslintrc.json
10 | 'standard',
11 | ],
12 | env: {
13 | es2022: true,
14 | },
15 | parserOptions: {
16 | ecmaVersion: 2022,
17 | ecmaFeatures: {
18 | jsx: false,
19 | },
20 | },
21 | rules: {
22 | 'array-bracket-spacing': [ 'error', 'always' ],
23 | 'arrow-parens': [ 'error', 'as-needed' ],
24 | 'comma-dangle': [ 'error', {
25 | arrays: 'always-multiline',
26 | objects: 'always-multiline',
27 | imports: 'always-multiline',
28 | exports: 'always-multiline',
29 | functions: 'never',
30 | } ],
31 | eqeqeq: [ 'error', 'smart' ],
32 | 'implicit-arrow-linebreak': [ 'error', 'beside' ],
33 | 'import/newline-after-import': 'error',
34 | 'import/order': [
35 | 'error',
36 | {
37 | pathGroups: [
38 | { pattern: '#*/**', group: 'internal', position: 'before' },
39 | ],
40 | groups: [ 'builtin', 'external', 'internal', 'parent', 'sibling' ],
41 | 'newlines-between': 'never',
42 | alphabetize: { order: 'asc' },
43 | },
44 | ],
45 | indent: [ 'error', 2, { MemberExpression: 'off' } ],
46 | 'linebreak-style': [ 'error', 'unix' ],
47 | 'no-ex-assign': [ 'off' ],
48 | 'no-var': [ 'error' ],
49 | 'nonblock-statement-body-position': [ 'error', 'beside' ],
50 | 'object-curly-spacing': [ 'error', 'always' ],
51 | 'object-shorthand': [ 'error', 'properties' ],
52 | 'one-var': [ 'off' ],
53 | 'prefer-arrow-callback': [ 'error' ],
54 | 'prefer-const': [ 'error' ],
55 | },
56 | }
57 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | liberapay: Association_Inventaire
2 |
--------------------------------------------------------------------------------
/.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:
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: true
2 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this tool
4 |
5 | ---
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/question.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Question
3 | about: Ask anything about this tool
4 |
5 | ---
6 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | config/local.js
3 | assets
4 | pnpm-lock.yaml
5 | browser-demo/wikibase-edit.js
6 |
7 |
--------------------------------------------------------------------------------
/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 |
8 |
9 |
10 |
11 |
12 |
13 | [](https://npmjs.com/package/wikibase-edit/)
14 |
15 | [](https://opensource.org/licenses/MIT)
16 | [](http://nodejs.org)
17 | [](http://standardjs.com/)
18 |
19 | [Download stats](https://npm-stat.com/charts.html?package=wikibase-edit)
20 |
21 | ## Summary
22 | - [Changelog](CHANGELOG.md)
23 | - [Install](#install)
24 | - [How-To](https://github.com/maxlath/wikibase-edit/blob/main/docs/how_to.md)
25 | - [Development setup](https://github.com/maxlath/wikibase-edit/blob/main/docs/development_setup.md)
26 | - [Contributing](#contributing)
27 | - [See Also](#see-also)
28 | - [You may also like](#you-may-also-like)
29 | - [License](#license)
30 |
31 | ## Changelog
32 | See [CHANGELOG.md](CHANGELOG.md) for version info
33 |
34 | ## Install
35 | ```sh
36 | npm install wikibase-edit
37 | ```
38 |
39 | 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:
40 | ```sh
41 | npm install wikibase-edit@v5
42 | ```
43 |
44 | ## How-To
45 | See [How-to](docs/how_to.md) doc
46 |
47 | ## Development
48 | See [Development](docs/development.md) doc
49 |
50 | ## Contributing
51 |
52 | Code contributions and propositions are very welcome, here are some design constraints you should be aware of:
53 | * `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)
54 |
55 | ## See Also
56 | * [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
57 | * [wikibase-cli](https://github.com/maxlath/wikibase-cli): The friendly command-line interface to Wikibase
58 | * [wikibase-dump-filter](https://npmjs.com/package/wikibase-dump-filter): Filter and format a newline-delimited JSON stream of Wikibase entities
59 | * [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
60 | * [wikidata-taxonomy](https://github.com/nichtich/wikidata-taxonomy): Command-line tool to extract taxonomies from Wikidata
61 | * [Other Wikidata external tools](https://www.wikidata.org/wiki/Wikidata:Tools/External_tools)
62 |
63 | ## You may also like
64 |
65 | [](https://inventaire.io)
66 |
67 | 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.
68 |
69 | ## License
70 | [MIT](LICENSE.md)
71 |
--------------------------------------------------------------------------------
/assets/metadata.js:
--------------------------------------------------------------------------------
1 |
2 | // Generated by scripts/postversion
3 | export const name = "wikibase-edit"
4 | export const version = "7.2.4"
5 | export const homepage = "https://github.com/maxlath/wikibase-edit"
6 | export const issues = "https://github.com/maxlath/wikibase-edit/issues"
7 |
8 |
--------------------------------------------------------------------------------
/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.
--------------------------------------------------------------------------------
/browser-demo/build.sh:
--------------------------------------------------------------------------------
1 | esbuild ../lib/index.js --bundle --outfile=./wikibase-edit.js --format=esm
--------------------------------------------------------------------------------
/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 |
17 | Add an English Alias to Wikidata Sandbox 4 : Q112795079
18 |
19 |
20 |
21 | Add
22 |
23 |
24 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2022",
4 | "module": "esnext",
5 | "moduleResolution": "node",
6 | "baseUrl": ".",
7 | "paths": {
8 | "#lib/*": [
9 | "./lib/*.js"
10 | ],
11 | "#tests/*": [
12 | "./tests/*.js"
13 | ],
14 | "#root/*": [
15 | "./*"
16 | ],
17 | }
18 | },
19 | "include": [
20 | "lib/**/*",
21 | "tests/**/*"
22 | ]
23 | }
24 |
--------------------------------------------------------------------------------
/lib/alias/action.js:
--------------------------------------------------------------------------------
1 | import * as validate from '../validate.js'
2 |
3 | export default action => params => {
4 | const { id, language, value } = params
5 |
6 | validate.entity(id)
7 | validate.language(language)
8 | validate.aliases(value)
9 |
10 | const data = { id, language }
11 |
12 | data[action] = value instanceof Array ? value.join('|') : value
13 |
14 | return { action: 'wbsetaliases', data }
15 | }
16 |
--------------------------------------------------------------------------------
/lib/alias/add.js:
--------------------------------------------------------------------------------
1 | import actionFactory from './action.js'
2 |
3 | export default actionFactory('add')
4 |
--------------------------------------------------------------------------------
/lib/alias/remove.js:
--------------------------------------------------------------------------------
1 | import actionFactory from './action.js'
2 |
3 | export default actionFactory('remove')
4 |
--------------------------------------------------------------------------------
/lib/alias/set.js:
--------------------------------------------------------------------------------
1 | import actionFactory from './action.js'
2 |
3 | export default actionFactory('set')
4 |
--------------------------------------------------------------------------------
/lib/badge/add.js:
--------------------------------------------------------------------------------
1 | import * as format from '../entity/format.js'
2 | import error_ from '../error.js'
3 | import { getEntitySitelinks } from '../get_entity.js'
4 | import { uniq } from '../utils.js'
5 | import * as validate from '../validate.js'
6 |
7 | export default async (params, config, API) => {
8 | let { id, site, badges } = params
9 | validate.entity(id)
10 | validate.site(site)
11 | badges = format.badges(badges)
12 |
13 | const sitelinks = await getEntitySitelinks(id, config)
14 | const siteObj = sitelinks[site]
15 |
16 | if (!siteObj) {
17 | throw error_.new('sitelink does not exist', 400, params)
18 | }
19 |
20 | const { title, badges: currentBadges } = siteObj
21 | return API.sitelink.set({
22 | id,
23 | site,
24 | title,
25 | badges: uniq(currentBadges.concat(badges)),
26 | })
27 | }
28 |
--------------------------------------------------------------------------------
/lib/badge/remove.js:
--------------------------------------------------------------------------------
1 | // Doc https://www.wikidata.org/w/api.php?action=help&modules=wbsetsitelink
2 |
3 | import * as format from '../entity/format.js'
4 | import error_ from '../error.js'
5 | import { getEntitySitelinks } from '../get_entity.js'
6 | import { difference } from '../utils.js'
7 | import * as validate from '../validate.js'
8 |
9 | export default async (params, config, API) => {
10 | let { id, site, badges } = params
11 | validate.entity(id)
12 | validate.site(site)
13 | badges = format.badges(badges)
14 |
15 | const sitelinks = await getEntitySitelinks(id, config)
16 | const siteObj = sitelinks[site]
17 |
18 | if (!siteObj) {
19 | throw error_.new('sitelink does not exist', 400, params)
20 | }
21 |
22 | const { title, badges: currentBadges } = siteObj
23 | return API.sitelink.set({
24 | id,
25 | site,
26 | title,
27 | badges: difference(currentBadges, badges),
28 | })
29 | }
30 |
--------------------------------------------------------------------------------
/lib/bundle_wrapper.js:
--------------------------------------------------------------------------------
1 | import error_ from './error.js'
2 | import fetchUsedPropertiesDatatypes from './properties/fetch_used_properties_datatypes.js'
3 | import validateAndEnrichConfig from './validate_and_enrich_config.js'
4 |
5 | export default (fn, generalConfig, API) => async (params, reqConfig) => {
6 | validateParams(params)
7 | const config = validateAndEnrichConfig(generalConfig, reqConfig)
8 | await fetchUsedPropertiesDatatypes(params, config)
9 | return fn(params, config, API)
10 | }
11 |
12 | const validateParams = params => {
13 | for (const parameter in params) {
14 | if (!validParametersKeysSet.has(parameter)) {
15 | throw error_.new(`invalid parameter: ${parameter}`, { parameter, validParametersKeys })
16 | }
17 | }
18 | }
19 |
20 | const validParametersKeys = [
21 | 'baserevid',
22 | 'guid',
23 | 'hash',
24 | 'id',
25 | 'newProperty',
26 | 'newValue',
27 | 'oldProperty',
28 | 'oldValue',
29 | 'property',
30 | 'propertyClaimsId',
31 | 'qualifiers',
32 | 'rank',
33 | 'reconciliation',
34 | 'references',
35 | 'summary',
36 | 'value',
37 | 'site',
38 | 'badges',
39 | ]
40 |
41 | const validParametersKeysSet = new Set(validParametersKeys)
42 |
--------------------------------------------------------------------------------
/lib/claim/builders.js:
--------------------------------------------------------------------------------
1 | import { getNumericId } from 'wikibase-sdk'
2 | import getTimeObject from './get_time_object.js'
3 | import { parseQuantity } from './quantity.js'
4 |
5 | // The difference in builders are due to the different expectations of the Wikibase API
6 |
7 | export const singleClaimBuilders = {
8 | string: str => `"${str}"`,
9 | entity: entityId => JSON.stringify(buildEntity(entityId)),
10 | time: value => JSON.stringify(getTimeObject(value)),
11 | // Property type specific builders
12 | monolingualtext: valueObj => JSON.stringify(valueObj),
13 | quantity: (amount, instance) => JSON.stringify(parseQuantity(amount, instance)),
14 | globecoordinate: obj => JSON.stringify(obj),
15 | }
16 |
17 | export const entityEditBuilders = {
18 | string: (pid, value) => valueStatementBase(pid, 'string', value),
19 | entity: (pid, value) => {
20 | return valueStatementBase(pid, 'wikibase-entityid', buildEntity(value))
21 | },
22 | monolingualtext: (pid, value) => {
23 | return valueStatementBase(pid, 'monolingualtext', value)
24 | },
25 | time: (pid, value) => valueStatementBase(pid, 'time', getTimeObject(value)),
26 | quantity: (pid, value, instance) => valueStatementBase(pid, 'quantity', parseQuantity(value, instance)),
27 | globecoordinate: (pid, value) => valueStatementBase(pid, 'globecoordinate', value),
28 | specialSnaktype: (pid, snaktype) => statementBase(pid, snaktype),
29 | }
30 |
31 | const buildEntity = entityId => {
32 | entityId = entityId.value || entityId
33 | const id = getNumericId(entityId)
34 | const type = entityId[0] === 'Q' ? 'item' : 'property'
35 | return { 'entity-type': type, 'numeric-id': parseInt(id) }
36 | }
37 |
38 | const statementBase = (pid, snaktype) => {
39 | return {
40 | rank: 'normal',
41 | type: 'statement',
42 | mainsnak: {
43 | property: pid,
44 | snaktype,
45 | },
46 | }
47 | }
48 |
49 | const valueStatementBase = (pid, type, value) => {
50 | const statement = statementBase(pid, 'value')
51 | statement.mainsnak.datavalue = { type, value }
52 | return statement
53 | }
54 |
--------------------------------------------------------------------------------
/lib/claim/claim_parsers.js:
--------------------------------------------------------------------------------
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 |
--------------------------------------------------------------------------------
/lib/claim/create.js:
--------------------------------------------------------------------------------
1 | import error_ from '../error.js'
2 |
3 | export default async (params, config, API) => {
4 | const { id, property, value, qualifiers, references, rank, reconciliation } = params
5 | const { statementsKey } = config
6 |
7 | if (value == null) throw error_.new('missing value', 400, params)
8 |
9 | const claim = { rank, qualifiers, references }
10 | if (value.snaktype && value.snaktype !== 'value') {
11 | claim.snaktype = value.snaktype
12 | } else {
13 | claim.value = value
14 | }
15 |
16 | let summary = params.summary || config.summary
17 |
18 | if (!summary) {
19 | const stringifiedValue = typeof value === 'string' ? value : JSON.stringify(value)
20 | summary = `add ${property} claim: ${stringifiedValue}`
21 | }
22 |
23 | const data = {
24 | id,
25 | [statementsKey]: {
26 | [property]: claim,
27 | },
28 | summary,
29 | baserevid: params.baserevid || config.baserevid,
30 | reconciliation,
31 | }
32 |
33 | // Using wbeditentity, as the endpoint is more complete, so we need to recover the summary
34 | const { entity, success } = await API.entity.edit(data, config)
35 |
36 | const newClaim = entity[statementsKey][property].slice(-1)[0]
37 | // Mimick claim actions responses
38 | return { claim: newClaim, success }
39 | }
40 |
--------------------------------------------------------------------------------
/lib/claim/find_snak.js:
--------------------------------------------------------------------------------
1 | import error_ from '../error.js'
2 | import isMatchingSnak from './is_matching_snak.js'
3 |
4 | export default (property, propSnaks, searchedValue) => {
5 | if (!propSnaks) return
6 |
7 | const matchingSnaks = propSnaks.filter(snak => isMatchingSnak(snak, searchedValue))
8 |
9 | if (matchingSnaks.length === 0) return
10 | if (matchingSnaks.length === 1) return matchingSnaks[0]
11 |
12 | const context = { property, propSnaks, searchedValue }
13 | throw error_.new('snak not found: too many matching snaks', 400, context)
14 | }
15 |
--------------------------------------------------------------------------------
/lib/claim/format_claim_value.js:
--------------------------------------------------------------------------------
1 | import { parseQuantity } from './quantity.js'
2 | import { hasSpecialSnaktype } from './special_snaktype.js'
3 |
4 | export default (datatype, value, instance) => {
5 | if (hasSpecialSnaktype(value)) return value
6 | // Try to recover data passed in a different type than the one expected:
7 | // - Quantities should be of type number
8 | if (datatype === 'Quantity') return parseQuantity(value, instance)
9 | return value
10 | }
11 |
--------------------------------------------------------------------------------
/lib/claim/get_time_object.js:
--------------------------------------------------------------------------------
1 | import { isPlainObject } from '../utils.js'
2 | import parseCalendar from './parse_calendar.js'
3 |
4 | export default value => {
5 | let time, precision, calendar, calendarmodel, timezone, before, after
6 | if (isPlainObject(value)) {
7 | ({ time, precision, calendar, calendarmodel, timezone, before, after } = value)
8 | calendarmodel = calendarmodel || calendar
9 | } else {
10 | time = value
11 | }
12 | time = time
13 | // It could be a year passed as an integer
14 | .toString()
15 | // Drop milliseconds from ISO time strings as those aren't represented in Wikibase anyway
16 | // ex: '2019-04-01T00:00:00.000Z' -> '2019-04-01T00:00:00Z'
17 | .replace('.000Z', 'Z')
18 | .replace(/^\+/, '')
19 | if (precision == null) precision = getPrecision(time)
20 | const timeStringBase = getTimeStringBase(time, precision)
21 | return getPrecisionTimeObject(timeStringBase, precision, calendarmodel, timezone, before, after)
22 | }
23 |
24 | const getTimeStringBase = (time, precision) => {
25 | if (precision > 10) return time
26 | if (precision === 10) {
27 | if (time.match(/^-?\d+-\d+$/)) return time + '-00'
28 | else return time
29 | }
30 | // From the year (9) to the billion years (0)
31 | // See https://www.wikidata.org/wiki/Help:Dates#Precision
32 | const yearMatch = time.match(/^(-?\d+)/)
33 | if (yearMatch == null) throw new Error(`couldn't identify year: ${time}`)
34 | const year = yearMatch[0]
35 | return year + '-00-00'
36 | }
37 |
38 | // Guess precision from time string
39 | // 2018 (year): 9
40 | // 2018-03 (month): 10
41 | // 2018-03-03 (day): 11
42 | const getPrecision = time => {
43 | const unsignedTime = time.replace(/^-/, '')
44 | return unsignedTime.split('-').length + 8
45 | }
46 |
47 | const getPrecisionTimeObject = (time, precision, calendarmodel, timezone = 0, before = 0, after = 0) => {
48 | const sign = time[0]
49 |
50 | // The Wikidata API expects signed years
51 | // Default to a positive year sign
52 | if (sign !== '-' && sign !== '+') time = `+${time}`
53 |
54 | if (precision <= 11 && !time.match('T')) time += 'T00:00:00Z'
55 |
56 | return {
57 | time,
58 | timezone,
59 | before,
60 | after,
61 | precision,
62 | calendarmodel: parseCalendar(calendarmodel, time),
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/lib/claim/helpers.js:
--------------------------------------------------------------------------------
1 | import { simplifyClaim } from 'wikibase-sdk'
2 | import { flatten, values } from '../utils.js'
3 |
4 | const simplifyOptions = {
5 | keepIds: true,
6 | keepSnaktypes: true,
7 | keepQualifiers: true,
8 | keepReferences: true,
9 | keepRanks: true,
10 | keepRichValues: true,
11 | }
12 |
13 | export const findClaimByGuid = (claims, guid) => {
14 | for (const claim of flatten(values(claims))) {
15 | if (claim.id.toLowerCase() === guid.toLowerCase()) return claim
16 | }
17 | }
18 |
19 | export const isGuidClaim = guid => claim => claim.id === guid
20 |
21 | export const simplifyClaimForEdit = claim => simplifyClaim(claim, simplifyOptions)
22 |
--------------------------------------------------------------------------------
/lib/claim/is_matching_claim.js:
--------------------------------------------------------------------------------
1 | import isMatchingSnak from './is_matching_snak.js'
2 |
3 | export default (newClaim, matchingQualifiers) => existingClaim => {
4 | const { mainsnak, qualifiers = {} } = existingClaim
5 | if (!isMatchingSnak(mainsnak, newClaim.mainsnak)) return false
6 | if (matchingQualifiers) {
7 | for (const property of matchingQualifiers) {
8 | const [ pid, option = 'all' ] = property.split(':')
9 | if (newClaim.qualifiers[pid] != null && qualifiers[pid] == null) return false
10 | if (newClaim.qualifiers[pid] == null && qualifiers[pid] != null) return false
11 | const propertyQualifiersMatch = matchFunctions[option](newClaim.qualifiers[pid], qualifiers[pid])
12 | if (!propertyQualifiersMatch) return false
13 | }
14 | }
15 | return true
16 | }
17 |
18 | const matchFunctions = {
19 | all: (newPropertyQualifiers, existingPropertyQualifiers) => {
20 | for (const newQualifier of newPropertyQualifiers) {
21 | for (const existingQualifier of existingPropertyQualifiers) {
22 | if (!isMatchingSnak(existingQualifier, newQualifier)) return false
23 | }
24 | }
25 | return true
26 | },
27 | any: (newPropertyQualifiers, existingPropertyQualifiers) => {
28 | for (const newQualifier of newPropertyQualifiers) {
29 | for (const existingQualifier of existingPropertyQualifiers) {
30 | if (isMatchingSnak(existingQualifier, newQualifier)) return true
31 | }
32 | }
33 | return false
34 | },
35 | }
36 |
--------------------------------------------------------------------------------
/lib/claim/move_commons.js:
--------------------------------------------------------------------------------
1 | import { simplifySnak } from 'wikibase-sdk'
2 | import error_ from '../error.js'
3 | import { parseQuantity } from './quantity.js'
4 |
5 | const issuesUrl = 'https://github.com/maxlath/wikibase-edit/issues'
6 |
7 | export const propertiesDatatypesDontMatch = params => {
8 | const { movedSnaks, originDatatype, targetDatatype } = params
9 | const typeConverterKey = `${originDatatype}->${targetDatatype}`
10 | const convertType = snakTypeConversions[typeConverterKey]
11 | if (convertType) {
12 | for (let snak of movedSnaks) {
13 | snak = snak.mainsnak || snak
14 | if (snakHasValue(snak)) {
15 | try {
16 | convertType(snak)
17 | } catch (err) {
18 | const errMessage = `properties datatype don't match and ${typeConverterKey} type conversion failed: ${err.message}`
19 | params.failingSnak = snak
20 | const customErr = error_.new(errMessage, 400, params)
21 | customErr.cause = err
22 | throw customErr
23 | }
24 | }
25 | }
26 | } else {
27 | const errMessage = `properties datatype don't match
28 | No ${typeConverterKey} type converter found
29 | If you think that should be possible, please open a ticket:
30 | ${issuesUrl}/new?template=feature_request.md&title=${encodeURIComponent(`claim.move: add a ${typeConverterKey} type converter`)}&body=%20`
31 | throw error_.new(errMessage, 400, params)
32 | }
33 | }
34 |
35 | const simplifyToString = snak => {
36 | snak.datavalue.value = simplifySnak(snak, {}).toString()
37 | snak.datatype = snak.datavalue.type = 'string'
38 | }
39 |
40 | const snakTypeConversions = {
41 | 'string->external-id': snak => {
42 | snak.datatype = 'string'
43 | },
44 | 'string->quantity': snak => {
45 | const { value } = snak.datavalue
46 | snak.datavalue.value = parseQuantity(value)
47 | snak.datatype = snak.datavalue.type = 'quantity'
48 | },
49 | 'external-id->string': simplifyToString,
50 | 'monolingualtext->string': simplifyToString,
51 | 'quantity->string': simplifyToString,
52 | }
53 |
54 | const snakHasValue = snak => snak.snaktype === 'value'
55 |
--------------------------------------------------------------------------------
/lib/claim/parse_calendar.js:
--------------------------------------------------------------------------------
1 | const wdUrlBase = 'http://www.wikidata.org/entity/'
2 | const gregorian = `${wdUrlBase}Q1985727`
3 | const julian = `${wdUrlBase}Q1985786`
4 | const calendarAliases = {
5 | julian,
6 | gregorian,
7 | Q1985727: gregorian,
8 | Q1985786: julian,
9 | }
10 |
11 | export default (calendar, wikidataTimeString) => {
12 | if (!calendar) return getDefaultCalendar(wikidataTimeString)
13 | const normalizedCalendar = calendar.replace(wdUrlBase, '')
14 | return calendarAliases[normalizedCalendar]
15 | }
16 |
17 | const getDefaultCalendar = wikidataTimeString => {
18 | if (wikidataTimeString[0] === '-') return julian
19 | const [ year ] = wikidataTimeString
20 | .replace('+', '')
21 | .split('-')
22 | .map(num => parseInt(num))
23 |
24 | if (year > 1582) return gregorian
25 | else return julian
26 | }
27 |
--------------------------------------------------------------------------------
/lib/claim/quantity.js:
--------------------------------------------------------------------------------
1 | import { isItemId } from 'wikibase-sdk'
2 | import error_ from '../error.js'
3 | import { isPlainObject, isSignedStringNumber, isString, isStringNumber } from '../utils.js'
4 |
5 | const itemUnitPattern = /^http.*\/entity\/(Q\d+)$/
6 |
7 | export const parseQuantity = (amount, instance) => {
8 | let unit, upperBound, lowerBound
9 | if (isPlainObject(amount)) ({ amount, unit, upperBound, lowerBound } = amount)
10 | if (isItemId(unit)) unit = `${forceHttp(instance)}/entity/${unit}`
11 | validateNumber('amount', amount)
12 | validateNumber('upperBound', upperBound)
13 | validateNumber('lowerBound', lowerBound)
14 | unit = unit || '1'
15 | return { amount: signAmount(amount), unit, upperBound, lowerBound }
16 | }
17 | export const parseUnit = unit => {
18 | if (unit.match(itemUnitPattern)) unit = unit.replace(itemUnitPattern, '$1')
19 | return unit
20 | }
21 |
22 | const signAmount = amount => {
23 | if (isSignedStringNumber(amount)) return `${amount}`
24 | if (isStringNumber(amount)) amount = parseFloat(amount)
25 | if (amount === 0) return '0'
26 | return amount > 0 ? `+${amount}` : `${amount}`
27 | }
28 |
29 | const forceHttp = instance => instance.replace('https:', 'http:')
30 |
31 | const validateNumber = (label, num) => {
32 | if (isString(num) && !isStringNumber(num)) {
33 | throw error_.new('invalid string number', { [label]: num })
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/lib/claim/remove.js:
--------------------------------------------------------------------------------
1 | import buildClaim from '../entity/build_claim.js'
2 | import error_ from '../error.js'
3 | import { getEntityClaims } from '../get_entity.js'
4 | import { forceArray } from '../utils.js'
5 | import * as validate from '../validate.js'
6 | import isMatchingClaim from './is_matching_claim.js'
7 |
8 | export default async (params, properties, instance, config) => {
9 | let { guid } = params
10 | const { id, property, value, qualifiers, reconciliation = {} } = params
11 | if (!(guid || (id && property && value))) {
12 | throw error_.new('missing guid or id/property/value', params)
13 | }
14 |
15 | if (!guid) {
16 | const existingClaims = await getEntityClaims(id, config)
17 | const claimData = { value, qualifiers }
18 | const claim = buildClaim(property, properties, claimData, instance)
19 | const { matchingQualifiers } = reconciliation
20 | const matchingClaims = existingClaims[property].filter(isMatchingClaim(claim, matchingQualifiers))
21 | if (matchingClaims.length === 0) throw error_.new('claim not found', params)
22 | guid = matchingClaims.map(({ id }) => id)
23 | }
24 |
25 | const guids = forceArray(guid)
26 | guids.forEach(validate.guid)
27 |
28 | return {
29 | action: 'wbremoveclaims',
30 | data: {
31 | claim: guids.join('|'),
32 | },
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/lib/claim/set.js:
--------------------------------------------------------------------------------
1 | import * as validate from '../validate.js'
2 | import formatClaimValue from './format_claim_value.js'
3 | import { buildSnak } from './snak.js'
4 |
5 | export default (params, properties, instance) => {
6 | const { guid, property, value: rawValue } = params
7 | const datatype = properties[property]
8 |
9 | validate.guid(guid)
10 | validate.property(property)
11 |
12 | // Format before testing validity to avoid throwing on type errors
13 | // that could be recovered
14 | const value = formatClaimValue(datatype, rawValue, instance)
15 |
16 | validate.snakValue(property, datatype, value)
17 |
18 | const claim = {
19 | id: guid,
20 | type: 'statement',
21 | mainsnak: buildSnak(property, datatype, value),
22 | }
23 |
24 | return { action: 'wbsetclaim', data: { claim: JSON.stringify(claim) } }
25 | }
26 |
--------------------------------------------------------------------------------
/lib/claim/snak.js:
--------------------------------------------------------------------------------
1 | import datatypesToBuilderDatatypes from '../properties/datatypes_to_builder_datatypes.js'
2 | import { flatten, forceArray, map, values } from '../utils.js'
3 | import * as validate from '../validate.js'
4 | import { entityEditBuilders as builders } from './builders.js'
5 |
6 | export const buildSnak = (property, datatype, value, instance) => {
7 | value = value.value || value
8 | if (value && value.snaktype && value.snaktype !== 'value') {
9 | return { snaktype: value.snaktype, property }
10 | }
11 | const builderDatatype = datatypesToBuilderDatatypes(datatype)
12 | return builders[builderDatatype](property, value, instance).mainsnak
13 | }
14 |
15 | export const buildReference = (properties, instance) => reference => {
16 | const hash = reference.hash
17 | const referenceSnaks = reference.snaks || reference
18 | const snaksPerProperty = map(referenceSnaks, buildPropSnaks(properties, instance))
19 | const snaks = flatten(values(snaksPerProperty))
20 | return { snaks, hash }
21 | }
22 |
23 | export const buildPropSnaks = (properties, instance) => (prop, propSnakValues) => {
24 | validate.property(prop)
25 | return forceArray(propSnakValues).map(snakValue => {
26 | const datatype = properties[prop]
27 | validate.snakValue(prop, datatype, snakValue)
28 | return buildSnak(prop, datatype, snakValue, instance)
29 | })
30 | }
31 |
--------------------------------------------------------------------------------
/lib/claim/snak_post_data.js:
--------------------------------------------------------------------------------
1 | import error_ from '../error.js'
2 | import datatypesToBuilderDatatypes from '../properties/datatypes_to_builder_datatypes.js'
3 | import { singleClaimBuilders as builders } from './builders.js'
4 | import { hasSpecialSnaktype } from './special_snaktype.js'
5 |
6 | export default params => {
7 | const { action, data, datatype, value, instance } = params
8 |
9 | if (!datatype) throw error_.new('missing datatype', params)
10 |
11 | if (hasSpecialSnaktype(value)) {
12 | data.snaktype = value.snaktype
13 | } else {
14 | data.snaktype = 'value'
15 | const builderDatatype = datatypesToBuilderDatatypes(datatype) || datatype
16 | data.value = builders[builderDatatype](value, instance)
17 | }
18 |
19 | return { action, data }
20 | }
21 |
--------------------------------------------------------------------------------
/lib/claim/special_snaktype.js:
--------------------------------------------------------------------------------
1 | import error_ from '../error.js'
2 |
3 | export const hasSpecialSnaktype = value => {
4 | if (typeof value !== 'object') return false
5 | const { snaktype } = value
6 | if (snaktype == null || snaktype === 'value') return false
7 | if (snaktype === 'novalue' || snaktype === 'somevalue') return true
8 | else throw error_.new('invalid snaktype', { snaktype })
9 | }
10 |
--------------------------------------------------------------------------------
/lib/claim/update.js:
--------------------------------------------------------------------------------
1 | import { isGuid, getEntityIdFromGuid } from 'wikibase-sdk'
2 | import error_ 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 |
7 | export default async (params, config, API) => {
8 | let { id, guid, property } = params
9 | const { oldValue, newValue, rank } = params
10 | const { statementsKey } = config
11 |
12 | if (!(rank != null || newValue != null)) {
13 | throw error_.new('expected a rank or a newValue', 400, params)
14 | }
15 |
16 | if (isGuid(guid)) {
17 | id = getEntityIdFromGuid(guid)
18 | } else {
19 | const values = { oldValue, newValue }
20 | if (oldValue === newValue) {
21 | throw error_.new("old and new claim values can't be the same", 400, values)
22 | }
23 | if (typeof oldValue !== typeof newValue) {
24 | throw error_.new('old and new claim should have the same type', 400, values)
25 | }
26 | }
27 |
28 | const claims = await getEntityClaims(id, config)
29 |
30 | let claim
31 | if (guid) {
32 | claim = findClaimByGuid(claims, guid)
33 | property = claim && claim.mainsnak.property
34 | } else {
35 | claim = findSnak(property, claims[property], oldValue)
36 | }
37 |
38 | if (!claim) {
39 | throw error_.new('claim not found', 400, params)
40 | }
41 |
42 | const simplifiedClaim = simplifyClaimForEdit(claim)
43 |
44 | guid = claim.id
45 |
46 | if (rank) simplifiedClaim.rank = rank
47 |
48 | if (newValue != null) {
49 | if (newValue.snaktype && newValue.snaktype !== 'value') {
50 | simplifiedClaim.snaktype = newValue.snaktype
51 | delete simplifiedClaim.value
52 | } else {
53 | simplifiedClaim.value = newValue
54 | }
55 | }
56 |
57 | const data = {
58 | id,
59 | [statementsKey]: {
60 | [property]: simplifiedClaim,
61 | },
62 | // Using wbeditentity, as the endpoint is more complete, so we need to recover the summary
63 | summary: params.summary || config.summary || `update ${property} claim`,
64 | baserevid: params.baserevid || config.baserevid,
65 | }
66 |
67 | const { entity, success } = await API.entity.edit(data, config)
68 |
69 | const updatedClaim = entity[statementsKey][property].find(isGuidClaim(guid))
70 | // Mimick claim actions responses
71 | return { claim: updatedClaim, success }
72 | }
73 |
--------------------------------------------------------------------------------
/lib/datatype_tests.js:
--------------------------------------------------------------------------------
1 | import { isEntityId, isItemId } from 'wikibase-sdk'
2 | import parseCalendar from './claim/parse_calendar.js'
3 | import { parseUnit } from './claim/quantity.js'
4 | import error_ from './error.js'
5 | import { isNonEmptyString, isNumber, isPlainObject, 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 (isPlainObject(time)) {
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 error_.new('time precision not supported by the Wikibase API', dateObject)
21 | calendarmodel = calendarmodel || calendar
22 | if (calendarmodel && !parseCalendar(calendarmodel, time)) {
23 | throw error_.new('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 | return false
51 | }
52 | }
53 | if (precision != null && precision > 11) {
54 | return /^(-|\+)?\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2,3}Z$/.test(time)
55 | } else if (time.match('T')) {
56 | return /^(-|\+)?\d{4,16}-\d{2}-\d{2}T00:00:00(\.000)?Z$/.test(time)
57 | } else {
58 | return /^(-|\+)?\d{4,16}(-\d{2}){0,2}$/.test(time)
59 | }
60 | }
61 | export const monolingualtext = value => {
62 | value = value.value || value
63 | const { text, language } = value
64 | return isNonEmptyString(text) && isNonEmptyString(language)
65 | }
66 | // cf https://www.mediawiki.org/wiki/Wikibase/DataModel#Quantities
67 | export const quantity = amount => {
68 | amount = amount.value || amount
69 | if (isPlainObject(amount)) {
70 | let unit
71 | ;({ unit, amount } = amount)
72 | if (unit && !isItemId(parseUnit(unit)) && unit !== '1') return false
73 | }
74 | // Accepting both numbers or string numbers as the amount will be
75 | // turned as a string lib/claim/builders.js signAmount function anyway
76 | return isNumber(amount) || isStringNumber(amount)
77 | }
78 | export const globecoordinate = obj => {
79 | obj = obj.value || obj
80 | if (!isPlainObject(obj)) return false
81 | const { latitude, longitude, precision } = obj
82 | return isNumber(latitude) && isNumber(longitude) && isNumber(precision)
83 | }
84 |
--------------------------------------------------------------------------------
/lib/debug.js:
--------------------------------------------------------------------------------
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 |
--------------------------------------------------------------------------------
/lib/description/set.js:
--------------------------------------------------------------------------------
1 | import setLabelOrDescriptionFactory from '../label_or_description/set.js'
2 |
3 | export default setLabelOrDescriptionFactory('description')
4 |
--------------------------------------------------------------------------------
/lib/entity/build_claim.js:
--------------------------------------------------------------------------------
1 | import { entityEditBuilders as builders } from '../claim/builders.js'
2 | import { buildReference, buildPropSnaks } from '../claim/snak.js'
3 | import { hasSpecialSnaktype } from '../claim/special_snaktype.js'
4 | import error_ from '../error.js'
5 | import datatypesToBuilderDatatypes from '../properties/datatypes_to_builder_datatypes.js'
6 | import { isString, isNumber, isPlainObject, map, forceArray } from '../utils.js'
7 | import * as validate from '../validate.js'
8 |
9 | export default (property, properties, claimData, instance) => {
10 | const datatype = properties[property]
11 |
12 | const builderDatatype = datatypesToBuilderDatatypes(datatype)
13 | const builder = builders[builderDatatype]
14 |
15 | const params = { properties, datatype, property, claimData, builder, instance }
16 |
17 | if (isString(claimData) || isNumber(claimData)) {
18 | return simpleClaimBuilder(params)
19 | } else {
20 | if (!isPlainObject(claimData)) throw error_.new('invalid claim data', { property, claimData })
21 | return fullClaimBuilder(params)
22 | }
23 | }
24 |
25 | const simpleClaimBuilder = params => {
26 | const { property, datatype, claimData: value, builder, instance } = params
27 | validate.snakValue(property, datatype, value)
28 | return builder(property, value, instance)
29 | }
30 |
31 | const fullClaimBuilder = params => {
32 | const { properties, datatype, property, claimData, builder, instance } = params
33 | validateClaimParameters(claimData)
34 | let { id, value, snaktype, rank, qualifiers, references, remove, reconciliation } = claimData
35 |
36 | if (remove === true) {
37 | if (!(id || reconciliation)) throw error_.new("can't remove a claim without an id or reconciliation settings", claimData)
38 | if (id) return { id, remove: true }
39 | }
40 |
41 | let claim
42 | if (value && value.snaktype) {
43 | claimData.snaktype = snaktype = value.snaktype
44 | }
45 | if (hasSpecialSnaktype(claimData)) {
46 | claim = builders.specialSnaktype(property, snaktype)
47 | } else {
48 | // In case of a rich value (monolingual text, quantity, globe coordinate, or time)
49 | if (value == null && (claimData.text || claimData.amount || claimData.latitude || claimData.time)) {
50 | value = claimData
51 | }
52 | validate.snakValue(property, datatype, value)
53 | claim = builder(property, value, instance)
54 | }
55 |
56 | if (id) {
57 | validate.guid(id)
58 | claim.id = id
59 | }
60 |
61 | if (rank) {
62 | validate.rank(rank)
63 | claim.rank = rank
64 | }
65 |
66 | if (qualifiers) {
67 | claim.qualifiers = map(qualifiers, buildPropSnaks(properties, instance))
68 | }
69 |
70 | if (references) {
71 | claim.references = forceArray(references).map(buildReference(properties, instance))
72 | }
73 |
74 | if (reconciliation) claim.reconciliation = reconciliation
75 | if (remove) claim.remove = remove
76 |
77 | return claim
78 | }
79 |
80 | const validClaimParameters = [
81 | 'id',
82 | 'type',
83 | 'value',
84 | 'snaktype',
85 | 'rank',
86 | 'qualifiers',
87 | 'references',
88 | 'remove',
89 | 'reconciliation',
90 | 'text',
91 | 'language',
92 | 'amount',
93 | 'lowerBound',
94 | 'upperBound',
95 | 'unit',
96 | 'latitude',
97 | 'longitude',
98 | 'precision',
99 | 'globe',
100 | 'altitude',
101 | 'time',
102 | 'timezone',
103 | 'before',
104 | 'after',
105 | 'precision',
106 | 'calendarmodel',
107 | ]
108 |
109 | const validClaimParametersSet = new Set(validClaimParameters)
110 |
111 | const validateClaimParameters = claimData => {
112 | for (const key in claimData) {
113 | if (!validClaimParametersSet.has(key)) {
114 | throw error_.new(`invalid claim parameter: ${key}`, { claimData, validClaimParameters })
115 | }
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/lib/entity/create.js:
--------------------------------------------------------------------------------
1 | import error_ from '../error.js'
2 | import edit from './edit.js'
3 |
4 | export default async (params, properties, instance, config) => {
5 | const { id } = params
6 | if (id) throw error_.new("a new entity can't already have an id", { id })
7 | params.create = true
8 | return edit(params, properties, instance, config)
9 | }
10 |
--------------------------------------------------------------------------------
/lib/entity/delete.js:
--------------------------------------------------------------------------------
1 | import { isEntityId } from 'wikibase-sdk'
2 | import error_ from '../error.js'
3 |
4 | export default params => {
5 | const { id } = params
6 |
7 | if (!isEntityId(id)) throw error_.new('invalid entity id', params)
8 |
9 | return {
10 | action: 'delete',
11 | data: {
12 | // The title will be prefixified if needed by ./lib/resolve_title.js
13 | title: id,
14 | },
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/lib/entity/edit.js:
--------------------------------------------------------------------------------
1 | import { isEntityId } from 'wikibase-sdk'
2 | import error_ from '../error.js'
3 | import { getEntityClaims } from '../get_entity.js'
4 | import { forceArray } from '../utils.js'
5 | import * as format from './format.js'
6 | import { isIdAliasPattern, resolveIdAlias } from './id_alias.js'
7 |
8 | export default async (data, properties, instance, config) => {
9 | validateParameters(data)
10 |
11 | let { id } = data
12 | const { create, type, datatype, clear, rawMode, reconciliation } = data
13 | const params = { data: {} }
14 | let existingClaims
15 |
16 | if (type && type !== 'property' && type !== 'item') {
17 | throw error_.new('invalid entity type', { type })
18 | }
19 |
20 | const statements = data.claims || data.statements
21 |
22 | if (create) {
23 | if (type === 'property') {
24 | if (!datatype) throw error_.new('missing property datatype', { datatype })
25 | if (!datatypes.has(datatype)) {
26 | throw error_.new('invalid property datatype', { datatype, knownDatatypes: datatypes })
27 | }
28 | params.new = 'property'
29 | params.data.datatype = datatype
30 | } else {
31 | if (datatype) {
32 | throw error_.new("an item can't have a datatype", { datatype })
33 | }
34 | params.new = 'item'
35 | }
36 | } else if (isEntityId(id) || isIdAliasPattern(id)) {
37 | if (isIdAliasPattern(id)) {
38 | id = await resolveIdAlias(id, instance)
39 | }
40 | params.id = id
41 | if (hasReconciliationSettings(reconciliation, statements)) {
42 | existingClaims = await getEntityClaims(id, config)
43 | }
44 | } else {
45 | throw error_.new('invalid entity id', { id })
46 | }
47 |
48 | const { labels, aliases, descriptions, sitelinks } = data
49 |
50 | if (rawMode) {
51 | if (labels) params.data.labels = labels
52 | if (aliases) params.data.aliases = aliases
53 | if (descriptions) params.data.descriptions = descriptions
54 | if (statements) params.data.claims = statements
55 | if (sitelinks) params.data.sitelinks = sitelinks
56 | } else {
57 | if (labels) params.data.labels = format.values('label', labels)
58 | if (aliases) params.data.aliases = format.values('alias', aliases)
59 | if (descriptions) params.data.descriptions = format.values('description', descriptions)
60 | if (statements) params.data.claims = format.claims(statements, properties, instance, reconciliation, existingClaims)
61 | if (sitelinks) params.data.sitelinks = format.sitelinks(sitelinks)
62 | }
63 |
64 | if (clear === true) params.clear = true
65 |
66 | if (!clear && Object.keys(params.data).length === 0) {
67 | throw error_.new('no data was passed', { id })
68 | }
69 |
70 | // stringify as it will be passed as form data
71 | params.data = JSON.stringify(params.data)
72 |
73 | return {
74 | action: 'wbeditentity',
75 | data: params,
76 | }
77 | }
78 |
79 | const datatypes = new Set([
80 | 'commonsMedia',
81 | 'edtf',
82 | 'external-id',
83 | 'geo-shape',
84 | 'globe-coordinate',
85 | // datatype from https://github.com/ProfessionalWiki/WikibaseLocalMedia
86 | 'localMedia',
87 | 'math',
88 | 'monolingualtext',
89 | 'musical-notation',
90 | 'quantity',
91 | 'string',
92 | 'tabular-data',
93 | 'time',
94 | 'url',
95 | 'wikibase-form',
96 | 'wikibase-item',
97 | 'wikibase-property',
98 | 'wikibase-lexeme',
99 | ])
100 |
101 | const allowedParameters = new Set([
102 | 'id', 'create', 'type', 'datatype', 'clear', 'rawMode', 'summary', 'baserevid',
103 | 'labels', 'aliases', 'descriptions', 'claims', 'statements', 'sitelinks', 'reconciliation',
104 | 'origin',
105 | ])
106 |
107 | const validateParameters = data => {
108 | for (const parameter in data) {
109 | if (!allowedParameters.has(parameter)) {
110 | throw error_.new(`invalid parameter: ${parameter}`, 400, { parameter, allowedParameters, data })
111 | }
112 | }
113 | }
114 |
115 | const hasReconciliationSettings = (reconciliation, claims) => {
116 | if (reconciliation != null) return true
117 | for (const property in claims) {
118 | for (const claim of forceArray(claims[property])) {
119 | if (claim.reconciliation != null) return true
120 | }
121 | }
122 | return false
123 | }
124 |
--------------------------------------------------------------------------------
/lib/entity/format.js:
--------------------------------------------------------------------------------
1 | import error_ from '../error.js'
2 | import { isString, forceArray, isntEmpty, flatten } from '../utils.js'
3 | import * as validate from '../validate.js'
4 | import buildClaim from './build_claim.js'
5 | import reconcileClaim from './reconcile_claim.js'
6 |
7 | const formatBadgesArray = badges => {
8 | if (isString(badges)) {
9 | badges = badges.split('|')
10 | }
11 | validate.badges(badges)
12 | return badges
13 | }
14 |
15 | export const values = (name, values) => {
16 | const obj = {}
17 | Object.keys(values).forEach(lang => {
18 | let value = values[lang]
19 | validate.language(lang)
20 | if (name === 'alias') {
21 | value = forceArray(value)
22 | validate.aliases(value, { allowEmptyArray: true })
23 | obj[lang] = value.map(alias => buildLanguageValue(alias, lang))
24 | } else {
25 | validate.labelOrDescription(name, value)
26 | obj[lang] = buildLanguageValue(value, lang)
27 | }
28 | })
29 | return obj
30 | }
31 | export const claims = (claims, properties, instance, reconciliation, existingClaims) => {
32 | if (!properties) throw error_.new('expected properties')
33 | return Object.keys(claims)
34 | .reduce(formatClaim(claims, properties, instance, reconciliation, existingClaims), {})
35 | }
36 | export const sitelinks = sitelinks => {
37 | const obj = {}
38 | Object.keys(sitelinks).forEach(site => {
39 | validate.site(site)
40 | const title = sitelinks[site]
41 | if (title === null) {
42 | // Passing an empty string removes the sitelink
43 | obj[site] = buildSiteTitle('', site)
44 | } else {
45 | validate.siteTitle(title)
46 | obj[site] = buildSiteTitle(title, site)
47 | }
48 | })
49 | return obj
50 | }
51 | export const badges = formatBadgesArray
52 |
53 | const formatClaim = (claims, properties, instance, reconciliation, existingClaims) => (obj, property) => {
54 | if (!properties) throw error_.new('expected properties')
55 | if (!instance) throw error_.new('expected instance')
56 | validate.property(property)
57 | const values = forceArray(claims[property])
58 | obj[property] = obj[property] || []
59 | obj[property] = values.map(value => buildClaim(property, properties, value, instance))
60 | if (existingClaims != null && existingClaims[property] != null) {
61 | obj[property] = obj[property]
62 | .map(reconcileClaim(reconciliation, existingClaims[property]))
63 | .filter(isntEmpty)
64 | obj[property] = flatten(obj[property])
65 | validateReconciledClaims(obj[property])
66 | }
67 | return obj
68 | }
69 |
70 | const buildLanguageValue = (value, language) => {
71 | // Re-building an object to avoid passing any undesired key/value
72 | const valueObj = { language }
73 | if (isString(value)) {
74 | valueObj.value = value
75 | } else if (value === null) {
76 | valueObj.remove = true
77 | } else {
78 | valueObj.value = value.value || value.title
79 | if (value.remove === true) valueObj.remove = true
80 | if (value.add != null) valueObj.add = ''
81 | }
82 | return valueObj
83 | }
84 |
85 | const buildSiteTitle = (title, site) => {
86 | // Re-building an object to avoid passing any undesired key/value
87 | const valueObj = { site }
88 | if (isString(title)) {
89 | valueObj.title = title
90 | } else {
91 | valueObj.title = title.title || title.value
92 | if (title.badges) {
93 | valueObj.badges = formatBadgesArray(title.badges)
94 | }
95 | if (title.remove === true) valueObj.remove = true
96 | }
97 | return valueObj
98 | }
99 |
100 | const validateReconciledClaims = propertyClaims => {
101 | const claimsByGuid = {}
102 | for (const claim of propertyClaims) {
103 | const { id } = claim
104 | if (id) {
105 | if (claimsByGuid[id] != null) {
106 | throw error_.new('can not match several times the same claim', { claim })
107 | } else {
108 | claimsByGuid[id] = claim
109 | }
110 | }
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/lib/entity/id_alias.js:
--------------------------------------------------------------------------------
1 | import { WBK, isPropertyId } from 'wikibase-sdk'
2 | import error_ from '../error.js'
3 | import request from '../request/request.js'
4 | import { isNonEmptyString } from '../utils.js'
5 |
6 | export const isIdAliasPattern = str => {
7 | if (typeof str !== 'string') return false
8 | const [ property, id ] = str.split(/[=:]/)
9 | return isPropertyId(property) && isNonEmptyString(id)
10 | }
11 |
12 | export const resolveIdAlias = async (idAlias, instance) => {
13 | const wbk = WBK({ instance })
14 | if (!idAlias.includes('=')) {
15 | // Accept both ':' and '=' as separators (as the Wikidata Hub uses one and haswbstatement the other)
16 | // but only replace the first instance of ':' to avoid corrupting valid ids containing ':'
17 | idAlias = idAlias.replace(':', '=')
18 | }
19 | const url = wbk.cirrusSearchPages({ haswbstatement: idAlias })
20 | const res = await request('get', { url })
21 | const ids = res.query.search.map(result => result.title)
22 | if (ids.length === 1) return ids[0]
23 | else throw error_.new('id alias could not be resolved', 400, { idAlias, instance, foundIds: ids })
24 | }
25 |
--------------------------------------------------------------------------------
/lib/entity/merge.js:
--------------------------------------------------------------------------------
1 | import { isEntityId, isItemId } from 'wikibase-sdk'
2 | import error_ from '../error.js'
3 |
4 | export default params => {
5 | const { from, to } = params
6 |
7 | if (!isEntityId(from)) throw error_.new('invalid "from" entity id', params)
8 | if (!isEntityId(to)) throw error_.new('invalid "to" entity id', params)
9 |
10 | if (!isItemId(from)) throw error_.new('unsupported entity type', params)
11 | if (!isItemId(to)) throw error_.new('unsupported entity type', params)
12 |
13 | return {
14 | action: 'wbmergeitems',
15 | data: { fromid: from, toid: to },
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/lib/entity/validate_reconciliation_object.js:
--------------------------------------------------------------------------------
1 | import { isPropertyId } from 'wikibase-sdk'
2 | import error_ from '../error.js'
3 |
4 | export default (reconciliation, claim) => {
5 | if (typeof reconciliation !== 'object') throw error_.new('reconciliation should be an object', { reconciliation })
6 | for (const key of Object.keys(reconciliation)) {
7 | if (!validReconciliationKeys.includes(key)) {
8 | throw error_.new('invalid reconciliation object key', { key, reconciliation, validReconciliationKeys })
9 | }
10 | }
11 | const { mode, matchingQualifiers, matchingReferences } = reconciliation
12 | if (!claim.remove && !validReconciliationModes.includes(mode)) {
13 | throw error_.new('invalid reconciliation mode', { mode, validReconciliationModes })
14 | }
15 |
16 | validateMatchingPropertyArray('matchingQualifiers', matchingQualifiers)
17 | validateMatchingPropertyArray('matchingReferences', matchingReferences)
18 | }
19 |
20 | const validateMatchingPropertyArray = (name, array) => {
21 | if (array) {
22 | if (!(array instanceof Array)) {
23 | throw error_.new(`invalid ${name} array`, { [name]: array })
24 | }
25 | for (const id of array) {
26 | const [ pid, option ] = id.split(':')
27 | if (!isPropertyId(pid)) {
28 | throw error_.new(`invalid ${name} property id`, { property: pid })
29 | }
30 | if (option && !validOptions.includes(option)) {
31 | throw error_.new(`invalid ${name} property id option: ${option}`, { id, pid, option })
32 | }
33 | }
34 | }
35 | }
36 |
37 | const validReconciliationKeys = [ 'mode', 'matchingQualifiers', 'matchingReferences' ]
38 | const validReconciliationModes = [ 'skip-on-value-match', 'skip-on-any-value', 'merge' ]
39 | const validOptions = [ 'all', 'any' ]
40 |
--------------------------------------------------------------------------------
/lib/error.js:
--------------------------------------------------------------------------------
1 | const error_ = {
2 | new: (message, statusCode, context) => {
3 | const err = new Error(message)
4 | if (typeof statusCode !== 'number') {
5 | if (context == null) {
6 | context = statusCode
7 | statusCode = 400
8 | } else {
9 | throw error_.new('invalid error status code', 500, { message, statusCode, context })
10 | }
11 | }
12 | err.statusCode = statusCode
13 | if (context) {
14 | context = convertSetsIntoArrays(context)
15 | err.context = context
16 | err.stack += `\n[context] ${JSON.stringify(context)}`
17 | }
18 | return err
19 | },
20 | }
21 |
22 | export default error_
23 |
24 | const convertSetsIntoArrays = context => {
25 | const convertedContext = {}
26 | for (const key in context) {
27 | const value = context[key]
28 | convertedContext[key] = value instanceof Set ? Array.from(value) : value
29 | }
30 | return convertedContext
31 | }
32 |
--------------------------------------------------------------------------------
/lib/get_entity.js:
--------------------------------------------------------------------------------
1 | import error_ from './error.js'
2 | import WBK from './get_instance_wikibase_sdk.js'
3 | import getJson from './request/get_json.js'
4 |
5 | const getEntity = async (id, props, config) => {
6 | const { instance } = config
7 | const url = WBK(instance).getEntities({ ids: id, props })
8 | const headers = { 'user-agent': config.userAgent }
9 | const { entities } = await getJson(url, { headers })
10 | const entity = entities[id]
11 | if (!entity || entity.missing != null) {
12 | throw error_.new('entity not found', { id, props, instance: config.instance })
13 | }
14 | return entity
15 | }
16 |
17 | export const getEntityClaims = async (id, config) => {
18 | const entity = await getEntity(id, 'claims', config)
19 | const { statementsKey } = config
20 | return entity[statementsKey]
21 | }
22 |
23 | export const getEntitySitelinks = async (id, config) => {
24 | const entity = await getEntity(id, 'sitelinks', config)
25 | return entity.sitelinks
26 | }
27 |
--------------------------------------------------------------------------------
/lib/get_instance_wikibase_sdk.js:
--------------------------------------------------------------------------------
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 |
--------------------------------------------------------------------------------
/lib/index.js:
--------------------------------------------------------------------------------
1 | import addAlias from './alias/add.js'
2 | import removeAlias from './alias/remove.js'
3 | import setAlias from './alias/set.js'
4 | import addBadge from './badge/add.js'
5 | import removeBadge from './badge/remove.js'
6 | import bundleWrapper from './bundle_wrapper.js'
7 | import createClaim from './claim/create.js'
8 | import moveClaim from './claim/move.js'
9 | import removeClaim from './claim/remove.js'
10 | import setClaim from './claim/set.js'
11 | import updateClaim from './claim/update.js'
12 | import setDescription from './description/set.js'
13 | import createEntity from './entity/create.js'
14 | import deleteEntity from './entity/delete.js'
15 | import editEntity from './entity/edit.js'
16 | import mergeEntity from './entity/merge.js'
17 | import error_ from './error.js'
18 | import setLabel from './label/set.js'
19 | import moveQualifier from './qualifier/move.js'
20 | import removeQualifier from './qualifier/remove.js'
21 | import setQualifier from './qualifier/set.js'
22 | import updateQualifier from './qualifier/update.js'
23 | import removeReference from './reference/remove.js'
24 | import setReference from './reference/set.js'
25 | import GetAuthData from './request/get_auth_data.js'
26 | import requestWrapper from './request_wrapper.js'
27 | import setSitelink from './sitelink/set.js'
28 | import validateAndEnrichConfig from './validate_and_enrich_config.js'
29 |
30 | // Primitives: sync or async functions that return an { action, params } object
31 | // passed to request.post by requestWrapper
32 | const rawRequestBuilders = {
33 | label: {
34 | set: setLabel,
35 | },
36 | description: {
37 | set: setDescription,
38 | },
39 | alias: {
40 | set: setAlias,
41 | add: addAlias,
42 | remove: removeAlias,
43 | },
44 | claim: {
45 | set: setClaim,
46 | remove: removeClaim,
47 | },
48 | qualifier: {
49 | set: setQualifier,
50 | remove: removeQualifier,
51 | },
52 | reference: {
53 | set: setReference,
54 | remove: removeReference,
55 | },
56 | entity: {
57 | create: createEntity,
58 | edit: editEntity,
59 | merge: mergeEntity,
60 | delete: deleteEntity,
61 | },
62 | sitelink: {
63 | set: setSitelink,
64 | },
65 | badge: {},
66 | }
67 |
68 | // Bundles: async functions that make use of the primitives to offer more sophisticated behaviors
69 | const bundledRequestsBuilders = {
70 | claim: {
71 | create: createClaim,
72 | update: updateClaim,
73 | move: moveClaim,
74 | },
75 | qualifier: {
76 | update: updateQualifier,
77 | move: moveQualifier,
78 | },
79 | badge: {
80 | add: addBadge,
81 | remove: removeBadge,
82 | },
83 | }
84 |
85 | export default function WBEdit (generalConfig = {}) {
86 | if (typeof generalConfig !== 'object') {
87 | throw error_.new('invalid general config object', { generalConfig, type: typeof generalConfig })
88 | }
89 |
90 | const API = {}
91 |
92 | for (const sectionKey in rawRequestBuilders) {
93 | API[sectionKey] = {}
94 | for (const functionName in rawRequestBuilders[sectionKey]) {
95 | const fn = rawRequestBuilders[sectionKey][functionName]
96 | API[sectionKey][functionName] = requestWrapper(fn, generalConfig)
97 | }
98 | }
99 |
100 | for (const sectionKey in bundledRequestsBuilders) {
101 | for (const functionName in bundledRequestsBuilders[sectionKey]) {
102 | const fn = bundledRequestsBuilders[sectionKey][functionName]
103 | API[sectionKey][functionName] = bundleWrapper(fn, generalConfig, API)
104 | }
105 | }
106 |
107 | API.getAuthData = reqConfig => {
108 | const config = validateAndEnrichConfig(generalConfig, reqConfig)
109 | return GetAuthData(config)
110 | }
111 |
112 | // Legacy aliases
113 | API.claim.add = API.claim.create
114 | API.qualifier.add = API.qualifier.set
115 | API.reference.add = API.reference.set
116 |
117 | return API
118 | }
119 |
--------------------------------------------------------------------------------
/lib/issues.js:
--------------------------------------------------------------------------------
1 | import { issues } from '../assets/metadata.js'
2 |
3 | const newIssueUrl = `${issues}/new`
4 |
5 | const newIssue = ({ template, title = ' ', body = ' ', context }) => {
6 | title = encodeURIComponent(title)
7 | if (context != null) {
8 | body += 'Context:\n```json\n' + JSON.stringify(context, null, 2) + '\n```\n'
9 | }
10 | body = encodeURIComponent(body)
11 | return `Please open an issue at ${newIssueUrl}?template=${template}&title=${title}&body=${body}`
12 | }
13 |
14 | export const inviteToOpenAFeatureRequest = ({ title, body, context }) => {
15 | return newIssue({
16 | template: 'feature_request.md',
17 | title,
18 | body,
19 | context,
20 | })
21 | }
22 |
--------------------------------------------------------------------------------
/lib/label/set.js:
--------------------------------------------------------------------------------
1 | import setLabelOrDescriptionFactory from '../label_or_description/set.js'
2 |
3 | export default setLabelOrDescriptionFactory('label')
4 |
--------------------------------------------------------------------------------
/lib/label_or_description/set.js:
--------------------------------------------------------------------------------
1 | import error_ from '../error.js'
2 | import * as validate from '../validate.js'
3 |
4 | export default name => params => {
5 | const { id, language } = params
6 | let { value } = params
7 | const action = `wbset${name}`
8 |
9 | validate.entity(id)
10 | validate.language(language)
11 | if (value === undefined) throw error_.new(`missing ${name}`, params)
12 | if (value === null) value = ''
13 |
14 | return {
15 | action,
16 | data: { id, language, value },
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/lib/parse_instance.js:
--------------------------------------------------------------------------------
1 | import error_ from './error.js'
2 |
3 | export default config => {
4 | if (!config) throw error_.new('missing config object')
5 |
6 | let { instance, wikibaseInstance } = config
7 | // Accept config.wikibaseInstance for legacy support
8 | instance = instance || wikibaseInstance
9 |
10 | if (!instance) throw error_.new('missing config parameter: instance', { config })
11 |
12 | let { wgScriptPath = 'w' } = config
13 |
14 | wgScriptPath = wgScriptPath.replace(/^\//, '')
15 |
16 | config.instance = instance
17 | .replace(/\/$/, '')
18 | .replace(`/${wgScriptPath}/api.php`, '')
19 |
20 | config.instanceApiEndpoint = `${config.instance}/${wgScriptPath}/api.php`
21 | config.statementsKey = getStatementsKey(instance)
22 | }
23 |
24 | export function getStatementsKey (instance) {
25 | return instance.includes('commons.wikimedia.org') ? 'statements' : 'claims'
26 | }
27 |
--------------------------------------------------------------------------------
/lib/properties/datatypes_to_builder_datatypes.js:
--------------------------------------------------------------------------------
1 | const buildersByNormalizedDatatypes = {
2 | commonsmedia: 'string',
3 | edtf: 'string',
4 | externalid: 'string',
5 | globecoordinate: 'globecoordinate',
6 | geoshape: 'string',
7 | // datatype from https://github.com/ProfessionalWiki/WikibaseLocalMedia
8 | localmedia: 'string',
9 | math: 'string',
10 | monolingualtext: 'monolingualtext',
11 | musicalnotation: 'string',
12 | quantity: 'quantity',
13 | string: 'string',
14 | tabulardata: 'string',
15 | time: 'time',
16 | url: 'string',
17 | wikibaseform: 'entity',
18 | wikibaseitem: 'entity',
19 | wikibaselexeme: 'entity',
20 | wikibaseproperty: 'entity',
21 | wikibasesense: 'entity',
22 | }
23 |
24 | const allDashesPattern = /-/g
25 |
26 | export default datatype => {
27 | const normalizedDatype = datatype.toLowerCase().replace(allDashesPattern, '')
28 | return buildersByNormalizedDatatypes[normalizedDatype]
29 | }
30 |
--------------------------------------------------------------------------------
/lib/properties/fetch_properties_datatypes.js:
--------------------------------------------------------------------------------
1 | import { isPropertyId } from 'wikibase-sdk'
2 | import error_ from '../error.js'
3 | import WBK from '../get_instance_wikibase_sdk.js'
4 | import getJson from '../request/get_json.js'
5 |
6 | const propertiesByInstance = {}
7 |
8 | export default async (config, propertyIds = []) => {
9 | let { instance, properties } = config
10 |
11 | propertyIds.forEach(propertyId => {
12 | if (!isPropertyId(propertyId)) throw error_.new('invalid property id', { propertyId })
13 | })
14 |
15 | if (!properties) {
16 | propertiesByInstance[instance] = propertiesByInstance[instance] || {}
17 | config.properties = properties = propertiesByInstance[instance]
18 | }
19 |
20 | const missingPropertyIds = propertyIds.filter(notIn(properties))
21 |
22 | if (missingPropertyIds.length === 0) return
23 |
24 | const urls = WBK(instance).getManyEntities({ ids: missingPropertyIds, props: 'info' })
25 |
26 | const headers = { 'user-agent': config.userAgent }
27 | const responses = await Promise.all(urls.map(url => getJson(url, { headers })))
28 | const responsesEntities = responses.map(parseResponse)
29 | const allEntities = Object.assign(...responsesEntities)
30 | missingPropertyIds.forEach(addMissingProperty(allEntities, properties, instance))
31 | }
32 |
33 | const notIn = object => key => object[key] == null
34 |
35 | const parseResponse = ({ entities, error }) => {
36 | if (error) throw error_.new(error.info, 400, error)
37 | return entities
38 | }
39 |
40 | const addMissingProperty = (entities, properties, instance) => propertyId => {
41 | const property = entities[propertyId]
42 | if (!(property && property.datatype)) throw error_.new('property not found', { propertyId, instance })
43 | properties[propertyId] = property.datatype
44 | }
45 |
--------------------------------------------------------------------------------
/lib/properties/fetch_used_properties_datatypes.js:
--------------------------------------------------------------------------------
1 | import fetchPropertiesDatatypes from './fetch_properties_datatypes.js'
2 | import { findClaimsProperties, findSnaksProperties } from './find_snaks_properties.js'
3 |
4 | export default (params, config) => {
5 | const propertyIds = findUsedProperties(params)
6 | return fetchPropertiesDatatypes(config, propertyIds)
7 | }
8 |
9 | const findUsedProperties = params => {
10 | const ids = []
11 | if (!params.rawMode) {
12 | if (params.claims) ids.push(...findClaimsProperties(params.claims))
13 | if (params.statements) ids.push(...findClaimsProperties(params.statements))
14 | if (params.snaks) ids.push(...findSnaksProperties(params.snaks))
15 | if (params.property) ids.push(params.property)
16 | }
17 | if (params.newProperty) ids.push(params.newProperty)
18 | if (params.oldProperty) ids.push(params.oldProperty)
19 | if (params.propertyClaimsId) ids.push(params.propertyClaimsId.split('#')[1])
20 | return ids
21 | }
22 |
--------------------------------------------------------------------------------
/lib/properties/find_snaks_properties.js:
--------------------------------------------------------------------------------
1 | import { forceArray, uniq } from '../utils.js'
2 |
3 | export const findClaimsProperties = claims => {
4 | if (!claims) return []
5 |
6 | const claimsProperties = Object.keys(claims)
7 |
8 | const subSnaksProperties = []
9 |
10 | const addSnaksProperties = snaks => {
11 | const properties = findSnaksProperties(snaks)
12 | subSnaksProperties.push(...properties)
13 | }
14 |
15 | claimsProperties.forEach(addPropertyClaimsSnaksProperties(claims, addSnaksProperties))
16 |
17 | return uniq(claimsProperties.concat(subSnaksProperties))
18 | }
19 |
20 | const addPropertyClaimsSnaksProperties = (claims, addSnaksProperties) => claimProperty => {
21 | const propertyClaims = claims[claimProperty]
22 | forceArray(propertyClaims).forEach(claim => {
23 | const { qualifiers, references } = claim
24 | if (qualifiers) addSnaksProperties(qualifiers)
25 | if (references) {
26 | forceArray(references).forEach(reference => {
27 | const snaks = reference.snaks || reference
28 | addSnaksProperties(snaks)
29 | })
30 | }
31 | })
32 | }
33 |
34 | export const findSnaksProperties = snaks => Object.keys(snaks)
35 |
--------------------------------------------------------------------------------
/lib/qualifier/move.js:
--------------------------------------------------------------------------------
1 | import { isGuid, isPropertyId, isHash, getEntityIdFromGuid } from 'wikibase-sdk'
2 | import { findClaimByGuid } from '../claim/helpers.js'
3 | import { propertiesDatatypesDontMatch } from '../claim/move_commons.js'
4 | import error_ from '../error.js'
5 | import { getEntityClaims } from '../get_entity.js'
6 |
7 | export default async (params, config, API) => {
8 | const { guid, oldProperty, newProperty, hash } = params
9 |
10 | if (!guid) throw error_.new('missing claim guid', 400, params)
11 | if (!isGuid(guid)) throw error_.new('invalid claim guid', 400, params)
12 |
13 | if (!oldProperty) throw error_.new('missing old property', 400, params)
14 | if (!isPropertyId(oldProperty)) throw error_.new('invalid old property', 400, params)
15 |
16 | if (!newProperty) throw error_.new('missing new property', 400, params)
17 | if (!isPropertyId(newProperty)) throw error_.new('invalid new property', 400, params)
18 |
19 | if (hash != null && !isHash(hash)) throw error_.new('invalid hash', 400, params)
20 |
21 | const currentEntityId = getEntityIdFromGuid(guid)
22 | const claims = await getEntityClaims(currentEntityId, config)
23 | const claim = findClaimByGuid(claims, guid)
24 |
25 | if (!claim) throw error_.new('claim not found', 400, params)
26 |
27 | if (!claim.qualifiers[oldProperty]) {
28 | params.foundQualifiers = Object.keys(claim.qualifiers)
29 | throw error_.new('no qualifiers found for this property', 400, params)
30 | }
31 |
32 | const originDatatype = config.properties[oldProperty]
33 | const targetDatatype = config.properties[newProperty]
34 |
35 | const recoverDatatypesMismatch = movedSnaks => {
36 | if (originDatatype !== targetDatatype) {
37 | propertiesDatatypesDontMatch({
38 | movedSnaks,
39 | originDatatype,
40 | originPropertyId: oldProperty,
41 | targetDatatype,
42 | targetPropertyId: newProperty,
43 | })
44 | }
45 | }
46 |
47 | if (hash) {
48 | const qualifier = claim.qualifiers[oldProperty].find(findByHash(hash))
49 | if (!qualifier) {
50 | params.foundHashes = claim.qualifiers[oldProperty].map(qualifier => qualifier.hash)
51 | throw error_.new('qualifier not found', 400, params)
52 | }
53 | recoverDatatypesMismatch([ qualifier ])
54 | claim.qualifiers[newProperty] = claim.qualifiers[newProperty] || []
55 | claim.qualifiers[newProperty].push(changeProperty(newProperty, qualifier))
56 | claim.qualifiers[oldProperty] = claim.qualifiers[oldProperty].filter(filterOutByHash(hash))
57 | if (claim.qualifiers[oldProperty].length === 0) delete claim.qualifiers[oldProperty]
58 | } else {
59 | claim.qualifiers[newProperty] = claim.qualifiers[oldProperty]
60 | .map(changeProperty.bind(null, newProperty))
61 | recoverDatatypesMismatch(claim.qualifiers[newProperty])
62 | delete claim.qualifiers[oldProperty]
63 | }
64 |
65 | const { statementsKey } = config
66 |
67 | const entityData = {
68 | rawMode: true,
69 | id: currentEntityId,
70 | [statementsKey]: [ claim ],
71 | summary: params.summary || config.summary || generateSummary(guid, oldProperty, newProperty, hash),
72 | baserevid: params.baserevid || config.baserevid,
73 | }
74 |
75 | const res = await API.entity.edit(entityData, config)
76 | const updatedClaim = findClaimByGuid(res.entity[statementsKey], guid)
77 | return { claim: updatedClaim }
78 | }
79 |
80 | const findByHash = hash => qualifier => qualifier.hash === hash
81 | const filterOutByHash = hash => qualifier => qualifier.hash !== hash
82 |
83 | const changeProperty = (newProperty, qualifier) => {
84 | qualifier.property = newProperty
85 | return qualifier
86 | }
87 |
88 | const generateSummary = (guid, oldProperty, newProperty, hash) => {
89 | if (hash) {
90 | return `moving a ${oldProperty} qualifier of ${guid} to ${newProperty}`
91 | } else {
92 | return `moving ${guid} ${oldProperty} qualifiers to ${newProperty}`
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/lib/qualifier/remove.js:
--------------------------------------------------------------------------------
1 | import { isArray } from '../utils.js'
2 | import * as validate from '../validate.js'
3 |
4 | export default params => {
5 | let { guid, hash } = params
6 | validate.guid(guid)
7 |
8 | if (isArray(hash)) {
9 | hash.forEach(validate.hash)
10 | hash = hash.join('|')
11 | } else {
12 | validate.hash(hash)
13 | }
14 |
15 | return {
16 | action: 'wbremovequalifiers',
17 | data: {
18 | claim: guid,
19 | qualifiers: hash,
20 | },
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/lib/qualifier/set.js:
--------------------------------------------------------------------------------
1 | import snakPostData from '../claim/snak_post_data.js'
2 | import * as validate from '../validate.js'
3 |
4 | export default (params, properties, instance) => {
5 | const { guid, hash, property, value } = params
6 |
7 | validate.guid(guid)
8 | validate.property(property)
9 | const datatype = properties[property]
10 | validate.snakValue(property, datatype, value)
11 |
12 | const data = {
13 | claim: guid,
14 | property,
15 | }
16 |
17 | if (hash != null) {
18 | validate.hash(hash)
19 | data.snakhash = hash
20 | }
21 |
22 | return snakPostData({
23 | action: 'wbsetqualifier',
24 | data,
25 | datatype,
26 | value,
27 | instance,
28 | })
29 | }
30 |
--------------------------------------------------------------------------------
/lib/qualifier/update.js:
--------------------------------------------------------------------------------
1 | import { getEntityIdFromGuid } from 'wikibase-sdk'
2 | import findSnak from '../claim/find_snak.js'
3 | import error_ from '../error.js'
4 | import { getEntityClaims } from '../get_entity.js'
5 | import { flatten, values } from '../utils.js'
6 | import * as validate from '../validate.js'
7 |
8 | export default async (params, config, API) => {
9 | const { guid, property, oldValue, newValue } = params
10 |
11 | validate.guid(guid)
12 | validate.property(property)
13 | const datatype = config.properties[property]
14 | validate.snakValue(property, datatype, oldValue)
15 | validate.snakValue(property, datatype, newValue)
16 |
17 | if (oldValue === newValue) {
18 | throw error_.new('same value', 400, oldValue, newValue)
19 | }
20 |
21 | // Get current value snak hash
22 | const hash = await getSnakHash(guid, property, oldValue, config)
23 | return API.qualifier.set({
24 | guid,
25 | hash,
26 | property,
27 | value: newValue,
28 | summary: params.summary || config.summary,
29 | baserevid: params.baserevid || config.baserevid,
30 | }, config)
31 | }
32 |
33 | const getSnakHash = async (guid, property, oldValue, config) => {
34 | const entityId = getEntityIdFromGuid(guid)
35 | const claims = await getEntityClaims(entityId, config)
36 | const claim = findClaim(claims, guid)
37 |
38 | if (!claim) throw error_.new('claim not found', 400, guid)
39 | if (!claim.qualifiers) throw error_.new('claim qualifiers not found', 400, guid)
40 |
41 | const propSnaks = claim.qualifiers[property]
42 |
43 | const qualifier = findSnak(property, propSnaks, oldValue)
44 |
45 | if (!qualifier) {
46 | const actualValues = propSnaks ? propSnaks.map(getSnakValue) : null
47 | throw error_.new('qualifier not found', 400, { guid, property, expectedValue: oldValue, actualValues })
48 | }
49 | return qualifier.hash
50 | }
51 |
52 | const findClaim = (claims, guid) => {
53 | claims = flatten(values(claims))
54 | for (const claim of claims) {
55 | if (claim.id === guid) return claim
56 | }
57 | }
58 |
59 | const getSnakValue = snak => snak.datavalue && snak.datavalue.value
60 |
--------------------------------------------------------------------------------
/lib/reference/remove.js:
--------------------------------------------------------------------------------
1 | import { isArray } from '../utils.js'
2 | import * as validate from '../validate.js'
3 |
4 | export default params => {
5 | let { guid, hash } = params
6 | validate.guid(guid)
7 |
8 | if (isArray(hash)) {
9 | hash.forEach(validate.hash)
10 | hash = hash.join('|')
11 | } else {
12 | validate.hash(hash)
13 | }
14 |
15 | return {
16 | action: 'wbremovereferences',
17 | data: {
18 | statement: guid,
19 | references: hash,
20 | },
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/lib/reference/set.js:
--------------------------------------------------------------------------------
1 | import { buildSnak, buildReference } from '../claim/snak.js'
2 | import * as validate from '../validate.js'
3 |
4 | export default (params, properties) => {
5 | const { guid, property, value, hash } = params
6 | let { snaks } = params
7 | if (snaks) {
8 | snaks = buildReference(properties)(snaks).snaks
9 | } else {
10 | // Legacy interface
11 | validate.guid(guid)
12 | validate.property(property)
13 | const datatype = properties[property]
14 | validate.snakValue(property, datatype, value)
15 | snaks = {}
16 | snaks[property] = [ buildSnak(property, datatype, value) ]
17 | }
18 |
19 | const data = {
20 | statement: guid,
21 | snaks: JSON.stringify(snaks),
22 | }
23 |
24 | if (hash) {
25 | validate.hash(hash)
26 | data.reference = hash
27 | }
28 |
29 | return { action: 'wbsetreference', data }
30 | }
31 |
--------------------------------------------------------------------------------
/lib/request/check_known_issues.js:
--------------------------------------------------------------------------------
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] && 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 |
--------------------------------------------------------------------------------
/lib/request/fetch.js:
--------------------------------------------------------------------------------
1 | import fetch from 'cross-fetch'
2 | import { debug, debugMode } from '../debug.js'
3 |
4 | let isNode
5 | try {
6 | isNode = process.versions.node != null
7 | } catch (err) {
8 | isNode = false
9 | }
10 |
11 | let agent
12 |
13 | if (isNode) {
14 | // Using a custom agent to set keepAlive=true
15 | // https://nodejs.org/api/http.html#http_class_http_agent
16 | // https://github.com/bitinn/node-fetch#custom-agent
17 | const http = await import('node:http')
18 | const https = await import('node:https')
19 | const httpAgent = new http.Agent({ keepAlive: true })
20 | const httpsAgent = new https.Agent({ keepAlive: true })
21 | agent = ({ protocol }) => protocol === 'http:' ? httpAgent : httpsAgent
22 | }
23 |
24 | export async function customFetch (url, { timeout, ...options } = {}) {
25 | options.agent = options.agent || agent
26 | if (debugMode) {
27 | const { method = 'get', headers, body } = options
28 | debug('request', method.toUpperCase(), url, {
29 | headers: obfuscateHeaders(headers),
30 | body: obfuscateBody({ url, body }),
31 | })
32 | }
33 | return fetchWithTimeout(url, options, timeout)
34 | }
35 |
36 | // Based on https://stackoverflow.com/questions/46946380/fetch-api-request-timeout#57888548
37 | function fetchWithTimeout (url, options, timeoutMs = 120_000) {
38 | const controller = new AbortController()
39 | const promise = fetch(url, {
40 | keepalive: true,
41 | signal: controller.signal,
42 | credentials: 'include',
43 | mode: 'cors',
44 | ...options,
45 | })
46 | const timeout = setTimeout(() => controller.abort(), timeoutMs)
47 | return promise.finally(() => clearTimeout(timeout))
48 | };
49 |
50 | function obfuscateHeaders (headers) {
51 | const obfuscatedHeadersEntries = Object.entries(headers).map(([ name, value ]) => [ name.toLowerCase(), value ])
52 | const obfuscatedHeaders = Object.fromEntries(obfuscatedHeadersEntries)
53 | if (obfuscatedHeaders.authorization) {
54 | obfuscatedHeaders.authorization = obfuscatedHeaders.authorization.replace(/"[^"]+"/g, '"***"')
55 | }
56 | if (obfuscatedHeaders.cookie) {
57 | obfuscatedHeaders.cookie = obfuscateParams(obfuscatedHeaders.cookie)
58 | }
59 | return obfuscatedHeaders
60 | }
61 |
62 | function obfuscateBody ({ url, body = '' }) {
63 | const { searchParams } = new URL(url)
64 | if (searchParams.get('action') === 'login') {
65 | return obfuscateParams(body)
66 | } else {
67 | return body.replace(/token=[^=\s;&]+([=\s;&]?)/g, 'token=***$1')
68 | }
69 | }
70 |
71 | function obfuscateParams (urlEncodedStr) {
72 | return urlEncodedStr.replace(/=[^=\s;&]+([=\s;&]?)/g, '=***$1')
73 | }
74 |
--------------------------------------------------------------------------------
/lib/request/get_auth_data.js:
--------------------------------------------------------------------------------
1 | import error_ from '../error.js'
2 | import GetToken from './get_token.js'
3 |
4 | export default config => {
5 | const getToken = GetToken(config)
6 |
7 | let tokenPromise
8 | let lastTokenRefresh = 0
9 |
10 | const refreshToken = refresh => {
11 | const now = Date.now()
12 | if (!refresh && now - lastTokenRefresh < 5000) {
13 | throw error_.new("last token refreshed less than 10 seconds ago: won't retry", { config })
14 | }
15 | lastTokenRefresh = now
16 | tokenPromise = getToken()
17 | return tokenPromise
18 | }
19 |
20 | return params => {
21 | if (params && params.refresh) return refreshToken(true)
22 | else return tokenPromise || refreshToken()
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/lib/request/get_final_token.js:
--------------------------------------------------------------------------------
1 | import error_ from '../error.js'
2 | import { stringifyQuery } from '../utils.js'
3 | import getJson from './get_json.js'
4 | import { getSignatureHeaders } from './oauth.js'
5 |
6 | const contentType = 'application/x-www-form-urlencoded'
7 |
8 | export default config => async loginCookies => {
9 | const { instanceApiEndpoint, credentials, userAgent } = config
10 | const { oauth: oauthTokens } = credentials
11 |
12 | const query = { action: 'query', meta: 'tokens', type: 'csrf', format: 'json' }
13 | const url = `${instanceApiEndpoint}?${stringifyQuery(query)}`
14 |
15 | const params = {
16 | headers: {
17 | 'user-agent': userAgent,
18 | 'content-type': contentType,
19 | },
20 | }
21 |
22 | if (oauthTokens) {
23 | const signatureHeaders = getSignatureHeaders({
24 | url,
25 | method: 'GET',
26 | oauthTokens,
27 | })
28 | Object.assign(params.headers, signatureHeaders)
29 | } else {
30 | params.headers.cookie = loginCookies
31 | }
32 |
33 | const body = await getJson(url, params)
34 | return parseTokens(loginCookies, instanceApiEndpoint, body)
35 | }
36 |
37 | const parseTokens = async (loginCookies, instanceApiEndpoint, body) => {
38 | const { error, query } = body
39 |
40 | if (error) throw formatError(error, body, instanceApiEndpoint)
41 |
42 | if (!query?.tokens) {
43 | throw error_.new('could not get tokens', { body })
44 | }
45 |
46 | const { csrftoken } = query.tokens
47 |
48 | if (csrftoken.length < 40) {
49 | throw error_.new('invalid csrf token', { loginCookies, body })
50 | }
51 |
52 | return {
53 | token: csrftoken,
54 | cookie: loginCookies,
55 | }
56 | }
57 |
58 | const formatError = (error, body, instanceApiEndpoint) => {
59 | const err = error_.new(`${instanceApiEndpoint} error response: ${error.info}`, { body })
60 | Object.assign(err, error)
61 |
62 | if (error.code === 'mwoauth-invalid-authorization' && error['*'] != null) {
63 | const domainMatch = error['*'].match(/(https?:\/\/.*\/w\/api.php)/)
64 | if (domainMatch != null && domainMatch[1] !== instanceApiEndpoint) {
65 | const domain = domainMatch[1]
66 | err.message += `\n\n***This might be caused by non-matching domains***
67 | between the server domain:\t${domain}
68 | and the domain in config:\t${instanceApiEndpoint}\n`
69 | err.context.domain = domain
70 | err.context.instanceApiEndpoint = instanceApiEndpoint
71 | }
72 | }
73 |
74 | return err
75 | }
76 |
--------------------------------------------------------------------------------
/lib/request/get_json.js:
--------------------------------------------------------------------------------
1 | import request from './request.js'
2 |
3 | export default (url, params = {}) => {
4 | // Ignore cases were a map function passed an index as second argument
5 | // ex: Promise.all(urls.map(getJson))
6 | if (typeof params !== 'object') params = {}
7 |
8 | params.url = url
9 | return request('get', params)
10 | }
11 |
--------------------------------------------------------------------------------
/lib/request/get_token.js:
--------------------------------------------------------------------------------
1 | import GetFinalToken from './get_final_token.js'
2 | import login from './login.js'
3 |
4 | export default config => {
5 | const getFinalToken = GetFinalToken(config)
6 |
7 | if (config.credentials.oauth || config.credentials.browserSession) {
8 | return getFinalToken
9 | } else {
10 | return async () => {
11 | const loginCookies = await login(config)
12 | return getFinalToken(loginCookies)
13 | }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/lib/request/initialize_config_auth.js:
--------------------------------------------------------------------------------
1 | import GetAuthData from './get_auth_data.js'
2 |
3 | export default config => {
4 | if (!config) throw new Error('missing config')
5 | if (config.anonymous) return
6 |
7 | const credentialsKey = getCredentialsKey(config)
8 | const { credentials } = config
9 |
10 | // Generate the function only once per credentials
11 | if (credentials._getAuthData && credentialsKey === credentials._credentialsKey) return
12 |
13 | credentials._getAuthData = GetAuthData(config)
14 | credentials._credentialsKey = credentialsKey
15 | }
16 |
17 | const getCredentialsKey = config => {
18 | const { instance } = config
19 | const { oauth, username, browserSession } = config.credentials
20 | if (browserSession) return instance
21 | // Namespacing keys as a oauth.consumer_key could theoretically be a username
22 | return username ? `${instance}|u|${username}` : `${instance}|o|${oauth.consumer_key}`
23 | }
24 |
--------------------------------------------------------------------------------
/lib/request/login.js:
--------------------------------------------------------------------------------
1 | import error_ 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 error_.new('failed to login: invalid username/password')
48 | }
49 |
50 | const resCookies = res.headers.get('set-cookie')
51 |
52 | if (!resCookies) {
53 | throw error_.new('login error', res.statusCode, { body: resBody })
54 | }
55 |
56 | if (!sessionCookiePattern.test(resCookies)) {
57 | throw error_.new('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 |
--------------------------------------------------------------------------------
/lib/request/oauth.js:
--------------------------------------------------------------------------------
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 |
5 | const hashFunction = (baseString, key) => base64.stringify(hmacSHA1(baseString, key))
6 |
7 | const getOAuthData = ({ consumer_key: key, consumer_secret: secret }) => {
8 | return OAuth({
9 | consumer: { key, secret },
10 | signature_method: 'HMAC-SHA1',
11 | hash_function: hashFunction,
12 | })
13 | }
14 |
15 | export const getSignatureHeaders = ({ url, method, data, oauthTokens }) => {
16 | const { token: key, token_secret: secret } = oauthTokens
17 | // Do not extract { authorize, toHeaders } functions as they need their context
18 | const oauth = getOAuthData(oauthTokens)
19 | const signature = oauth.authorize({ url, method, data }, { key, secret })
20 | return oauth.toHeader(signature)
21 | }
22 |
--------------------------------------------------------------------------------
/lib/request/parse_response_body.js:
--------------------------------------------------------------------------------
1 | import { debug } from '../debug.js'
2 |
3 | export default async res => {
4 | const raw = await res.text()
5 | let data
6 | try {
7 | data = JSON.parse(raw)
8 | } catch (err) {
9 | const customErr = new Error('Could not parse response: ' + raw)
10 | customErr.cause = err
11 | customErr.name = 'wrong response format'
12 | throw customErr
13 | }
14 | debug('response', res.url, res.status, data)
15 | if (data.error != null) throw requestError(res, data)
16 | else return data
17 | }
18 |
19 | const requestError = (res, body) => {
20 | const { code, info } = body.error || {}
21 | const errMessage = `${code}: ${info}`
22 | const err = new Error(errMessage)
23 | err.name = code
24 | if (res.status === 200) {
25 | // Override false positive status code
26 | err.statusCode = 500
27 | } else {
28 | err.statusCode = res.status
29 | }
30 | err.statusMessage = res.statusMessage
31 | err.headers = res.headers
32 | err.body = body
33 | err.url = res.url
34 | if (res.url) err.stack += `\nurl: ${res.url}`
35 | if (res.status) err.stack += `\nresponse status: ${res.status}`
36 | if (body) err.stack += `\nresponse body: ${JSON.stringify(body)}`
37 | err.context = { url: res.url, body }
38 | return err
39 | }
40 |
--------------------------------------------------------------------------------
/lib/request/parse_session_cookies.js:
--------------------------------------------------------------------------------
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 |
--------------------------------------------------------------------------------
/lib/request/post.js:
--------------------------------------------------------------------------------
1 | import error_ from '../error.js'
2 | import { buildUrl, wait } from '../utils.js'
3 | import request from './request.js'
4 |
5 | export const defaultMaxlag = 5
6 |
7 | export default async (action, data, config) => {
8 | const { anonymous } = config
9 | const getAuthData = anonymous ? null : config.credentials._getAuthData
10 |
11 | const tryActionPost = async () => {
12 | if (anonymous) {
13 | return actionPost({ action, data, config })
14 | } else {
15 | const authData = await getAuthData()
16 | return actionPost({ action, data, config, authData })
17 | }
18 | }
19 |
20 | const insistentRequest = async (attempt = 0) => {
21 | try {
22 | return await tryActionPost()
23 | } catch (err) {
24 | // Known case of required retrial: token expired
25 | if (attempt < 10 && !anonymous && mayBeSolvedByTokenRefresh(err)) {
26 | await getAuthData({ refresh: true })
27 | await wait(attempt * 1000)
28 | return insistentRequest(++attempt)
29 | } else {
30 | throw err
31 | }
32 | }
33 | }
34 |
35 | return insistentRequest()
36 | }
37 |
38 | const actionPost = async ({ action, data, config, authData }) => {
39 | const { instanceApiEndpoint, userAgent, bot, summary, baserevid, tags, maxlag, anonymous } = config
40 |
41 | const query = { action, format: 'json' }
42 |
43 | if (bot) {
44 | query.bot = true
45 | data.assert = 'bot'
46 | } else if (!anonymous) {
47 | data.assert = 'user'
48 | }
49 |
50 | const params = {
51 | url: buildUrl(instanceApiEndpoint, query),
52 | headers: {
53 | 'User-Agent': userAgent,
54 | },
55 | autoRetry: config.autoRetry,
56 | httpRequestAgent: config.httpRequestAgent,
57 | }
58 |
59 | if (anonymous) {
60 | // The edit token for logged-out users is a hardcoded string of +\
61 | // cf https://phabricator.wikimedia.org/T40417
62 | data.token = '+\\'
63 | } else {
64 | params.oauth = config.credentials.oauth
65 | params.headers.cookie = authData.cookie
66 | data.token = authData.token
67 | }
68 |
69 | if (summary != null) data.summary = data.summary || summary
70 | if (baserevid != null) data.baserevid = data.baserevid || baserevid
71 | if (tags != null) data.tags = data.tags || tags.join('|')
72 | // Allow to omit the maxlag by passing null, see https://github.com/maxlath/wikibase-edit/pull/65
73 | if (maxlag !== null) data.maxlag = maxlag || defaultMaxlag
74 |
75 | params.body = data
76 |
77 | const body = await request('post', params)
78 | if (body.error) {
79 | const errMessage = `action=${action} error: ${body.error.info}`
80 | const err = error_.new(errMessage, { params, body })
81 | err.body = body
82 | throw err
83 | }
84 | return body
85 | }
86 |
87 | const mayBeSolvedByTokenRefresh = err => {
88 | if (!(err && err.body && err.body.error)) return false
89 | const errorCode = err.body.error.code || ''
90 | return tokenErrors.includes(errorCode)
91 | }
92 |
93 | // Errors that might be resolved by a refreshed token
94 | const tokenErrors = [
95 | 'badtoken',
96 | 'notoken',
97 | 'assertuserfailed',
98 | ]
99 |
--------------------------------------------------------------------------------
/lib/request/request.js:
--------------------------------------------------------------------------------
1 | import { stringifyQuery, wait } from '../utils.js'
2 | import checkKnownIssues from './check_known_issues.js'
3 | import { customFetch } from './fetch.js'
4 | import { getSignatureHeaders } from './oauth.js'
5 | import parseResponseBody from './parse_response_body.js'
6 |
7 | const timeout = 30000
8 |
9 | export default async (verb, params) => {
10 | const method = verb || 'get'
11 | const { url, body, oauth: oauthTokens, headers, autoRetry = true, httpRequestAgent } = params
12 | const maxlag = body?.maxlag
13 | let attempts = 1
14 |
15 | let bodyStr
16 | if (method === 'post' && body != null) {
17 | bodyStr = stringifyQuery(body)
18 | headers['Content-Type'] = 'application/x-www-form-urlencoded'
19 | }
20 |
21 | const tryRequest = async () => {
22 | if (oauthTokens) {
23 | const signatureHeaders = getSignatureHeaders({
24 | url,
25 | method,
26 | data: body,
27 | oauthTokens,
28 | })
29 | Object.assign(headers, signatureHeaders)
30 | }
31 |
32 | try {
33 | const res = await customFetch(url, { method, body: bodyStr, headers, timeout, agent: httpRequestAgent })
34 | return await parseResponseBody(res)
35 | } catch (err) {
36 | checkKnownIssues(url, err)
37 | if (autoRetry === false) throw err
38 | if (errorIsWorthARetry(err)) {
39 | const delaySeconds = getRetryDelay(err.headers) * attempts
40 | retryWarn(verb, url, err, delaySeconds, attempts++, maxlag)
41 | await wait(delaySeconds * 1000)
42 | return tryRequest()
43 | } else {
44 | err.context ??= {}
45 | err.context.request = { url, body }
46 | throw err
47 | }
48 | }
49 | }
50 |
51 | return tryRequest()
52 | }
53 |
54 | const errorIsWorthARetry = err => {
55 | if (errorsWorthARetry.has(err.name) || errorsWorthARetry.has(err.type) || errorsCodeWorthARetry.has(err.code || err.cause?.code)) return true
56 | // failed-save might be a recoverable error from the server
57 | // See https://github.com/maxlath/wikibase-cli/issues/150
58 | if (err.name === 'failed-save') {
59 | const { messages } = err.body.error
60 | return !messages.some(isNonRecoverableFailedSave)
61 | }
62 | return false
63 | }
64 |
65 | const isNonRecoverableFailedSave = message => message.name.startsWith('wikibase-validator') || nonRecoverableFailedSaveMessageNames.has(message.name)
66 |
67 | const errorsWorthARetry = new Set([
68 | 'maxlag',
69 | 'TimeoutError',
70 | 'request-timeout',
71 | 'wrong response format',
72 | ])
73 |
74 | const errorsCodeWorthARetry = new Set([
75 | 'ECONNREFUSED',
76 | 'UND_ERR_CONNECT_TIMEOUT',
77 | ])
78 |
79 | const nonRecoverableFailedSaveMessageNames = new Set([
80 | 'protectedpagetext',
81 | 'permissionserrors',
82 | ])
83 |
84 | const defaultRetryDelay = 5
85 | const getRetryDelay = headers => {
86 | const retryAfterSeconds = headers && headers['retry-after']
87 | if (/^\d+$/.test(retryAfterSeconds)) return parseInt(retryAfterSeconds)
88 | else return defaultRetryDelay
89 | }
90 |
91 | const retryWarn = (verb, url, err, delaySeconds, attempts, maxlag) => {
92 | verb = verb.toUpperCase()
93 | const maxlagStr = typeof maxlag === 'number' ? `${maxlag}s` : maxlag
94 | console.warn(`[wikibase-edit][WARNING] ${verb} ${url}
95 | ${err.message}
96 | retrying in ${delaySeconds}s (attempt: ${attempts}, maxlag: ${maxlagStr})`)
97 | }
98 |
--------------------------------------------------------------------------------
/lib/request_wrapper.js:
--------------------------------------------------------------------------------
1 | import error_ 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 |
10 | export default (fn, generalConfig) => async (params, reqConfig) => {
11 | const config = validateAndEnrichConfig(generalConfig, reqConfig)
12 | validateParameters(params)
13 | initializeConfigAuth(config)
14 |
15 | await fetchUsedPropertiesDatatypes(params, config)
16 |
17 | if (!config.properties) throw error_.new('properties not found', config)
18 |
19 | const { action, data } = await fn(params, config.properties, config.instance, config)
20 |
21 | const { summarySuffix } = config
22 | let summary = params.summary || config.summary
23 | if (summarySuffix) {
24 | if (summary) {
25 | summary += ` - ${summarySuffix}`
26 | } else {
27 | summary = summarySuffix
28 | }
29 | summary = summary.trim()
30 | }
31 |
32 | const baserevid = params.baserevid || config.baserevid
33 |
34 | if (isNonEmptyString(summary)) data.summary = summary
35 | if (baserevid != null) data.baserevid = baserevid
36 |
37 | if (!data.title) return post(action, data, config)
38 |
39 | const title = await resolveTitle(data.title, config.instanceApiEndpoint)
40 | data.title = title
41 | return post(action, data, config)
42 | }
43 |
--------------------------------------------------------------------------------
/lib/resolve_title.js:
--------------------------------------------------------------------------------
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 } from 'wikibase-sdk'
6 | import getJson from './request/get_json.js'
7 |
8 | let prefixesMapPromise
9 |
10 | export default async (title, instanceApiEndpoint) => {
11 | if (!isEntityId(title)) return
12 | prefixesMapPromise = prefixesMapPromise || getPrefixesMap(instanceApiEndpoint)
13 | const prefixesMap = await prefixesMapPromise
14 | const idFirstLetter = title[0]
15 | const prefix = prefixesMap[idFirstLetter]
16 | return prefix === '' ? title : `${prefix}:${title}`
17 | }
18 |
19 | const getPrefixesMap = async instanceApiEndpoint => {
20 | const infoUrl = `${instanceApiEndpoint}?action=query&meta=siteinfo&siprop=namespaces&format=json`
21 | const res = await getJson(infoUrl)
22 | return parsePrefixesMap(res)
23 | }
24 |
25 | const parsePrefixesMap = res => {
26 | return Object.values(res.query.namespaces)
27 | .filter(namespace => namespace.defaultcontentmodel)
28 | .filter(namespace => namespace.defaultcontentmodel.startsWith('wikibase'))
29 | .reduce(aggregatePrefixes, {})
30 | }
31 |
32 | const aggregatePrefixes = (prefixesMap, namespace) => {
33 | const { defaultcontentmodel, '*': prefix } = namespace
34 | const type = defaultcontentmodel.split('-')[1]
35 | const firstLetter = type === 'item' ? 'Q' : type[0].toUpperCase()
36 | prefixesMap[firstLetter] = prefix
37 | return prefixesMap
38 | }
39 |
--------------------------------------------------------------------------------
/lib/sitelink/set.js:
--------------------------------------------------------------------------------
1 | // Doc https://www.wikidata.org/w/api.php?action=help&modules=wbsetsitelink
2 | import * as format from '../entity/format.js'
3 | import * as validate from '../validate.js'
4 |
5 | export default ({ id, site, title, badges }) => {
6 | validate.entity(id)
7 | validate.site(site)
8 | validate.siteTitle(title)
9 |
10 | const params = {
11 | action: 'wbsetsitelink',
12 | data: {
13 | id,
14 | linksite: site,
15 | linktitle: title,
16 | },
17 | }
18 |
19 | // Allow to pass null to delete a sitelink
20 | if (title === null) {
21 | delete params.data.linktitle
22 | }
23 |
24 | if (badges != null) {
25 | params.data.badges = format.badges(badges).join('|')
26 | }
27 |
28 | return params
29 | }
30 |
--------------------------------------------------------------------------------
/lib/utils.js:
--------------------------------------------------------------------------------
1 | const stringNumberPattern = /^(-|\+)?\d+(\.\d+)?$/
2 | const signedStringNumberPattern = /^(-|\+)\d+(\.\d+)?$/
3 |
4 | export const stringifyQuery = query => new URLSearchParams(query).toString()
5 |
6 | export const isNonEmptyString = str => typeof str === 'string' && str.length > 0
7 | export const buildUrl = (base, query) => {
8 | return `${base}?${stringifyQuery(query)}`
9 | }
10 | // helpers to simplify polymorphisms
11 | export const forceArray = obj => {
12 | if (obj == null) return []
13 | if (!(obj instanceof Array)) return [ obj ]
14 | return obj
15 | }
16 | export const isString = str => typeof str === 'string'
17 | export const isNumber = num => typeof num === 'number'
18 | export const isStringNumber = str => stringNumberPattern.test(str)
19 | export const isSignedStringNumber = str => signedStringNumberPattern.test(str)
20 | export const isArray = array => array instanceof Array
21 | export const isPlainObject = obj => {
22 | if (obj instanceof Array) return false
23 | if (obj === null) return false
24 | return typeof obj === 'object'
25 | }
26 | export const isntEmpty = value => value != null
27 | export const flatten = arrays => [].concat.apply([], arrays)
28 | export const map = (obj, fn) => {
29 | const aggregator = (index, key) => {
30 | index[key] = fn(key, obj[key])
31 | return index
32 | }
33 | return Object.keys(obj).reduce(aggregator, {})
34 | }
35 | export const values = obj => Object.keys(obj).map(key => obj[key])
36 | export const uniq = array => Array.from(new Set(array))
37 | export const difference = (values, excluded) => {
38 | return values.filter(value => !excluded.includes(value))
39 | }
40 | export const wait = ms => new Promise(resolve => setTimeout(resolve, ms))
41 |
--------------------------------------------------------------------------------
/lib/validate.js:
--------------------------------------------------------------------------------
1 | import { isEntityId, isPropertyId, isGuid, isItemId } from 'wikibase-sdk'
2 | import { hasSpecialSnaktype } from './claim/special_snaktype.js'
3 | import * as datatypeTests from './datatype_tests.js'
4 | import error_ from './error.js'
5 | import { inviteToOpenAFeatureRequest } from './issues.js'
6 | import datatypesToBuilderDatatypes from './properties/datatypes_to_builder_datatypes.js'
7 | import { isPlainObject, isNonEmptyString, forceArray, isArray } from './utils.js'
8 | // For a list of valid languages
9 | // see https://www.wikidata.org/w/api.php?action=help&modules=wbgetentities
10 | const langRegex = /^\w{2,3}(-[\w-]{2,10})?$/
11 | const siteRegex = /^[a-z_]{2,20}$/
12 | const possibleRanks = [ 'normal', 'preferred', 'deprecated' ]
13 |
14 | const validateStringValue = (name, str) => {
15 | if (str === null) return
16 | if (isPlainObject(str)) {
17 | if (str.remove === true) return
18 | // Required by entity/edit.js validation:
19 | // values can be passed as objects to allow for flags (ex: 'remove=true')
20 | if (str.value != null) str = str.value
21 | // title is the API key for sitelinks
22 | if (str.title != null) str = str.title
23 | }
24 | if (!(isNonEmptyString(str))) {
25 | throw error_.new(`invalid ${name}`, { str })
26 | }
27 | }
28 |
29 | export const entity = entity => {
30 | if (!isEntityId(entity)) {
31 | throw error_.new('invalid entity', { entity })
32 | }
33 | }
34 | export const property = property => {
35 | if (!isNonEmptyString(property)) {
36 | throw error_.new('missing property', { property })
37 | }
38 | if (!isPropertyId(property)) {
39 | throw error_.new('invalid property', { property })
40 | }
41 | }
42 | export const language = language => {
43 | if (!(isNonEmptyString(language) && langRegex.test(language))) {
44 | throw error_.new('invalid language', { language })
45 | }
46 | }
47 | export const labelOrDescription = validateStringValue
48 | export const aliases = (value, options = {}) => {
49 | const { allowEmptyArray = false } = options
50 | value = forceArray(value)
51 | if (!allowEmptyArray && value.length === 0) throw error_.new('empty alias array', { value })
52 | // Yes, it's not an label or a description, but it works the same
53 | value.forEach(validateStringValue.bind(null, 'alias'))
54 | }
55 | export const snakValue = (property, datatype, value) => {
56 | if (hasSpecialSnaktype(value)) return
57 | if (value == null) {
58 | throw error_.new('missing snak value', { property, value })
59 | }
60 | if (value.value) value = value.value
61 |
62 | const builderDatatype = datatypesToBuilderDatatypes(datatype)
63 |
64 | if (datatypeTests[builderDatatype] == null) {
65 | const context = { property, value, datatype, builderDatatype }
66 | const featureRequestMessage = inviteToOpenAFeatureRequest({
67 | title: `add support for ${datatype} datatype`,
68 | context,
69 | })
70 | throw error_.new(`unsupported datatype: ${datatype}\n${featureRequestMessage}`, context)
71 | }
72 |
73 | if (!datatypeTests[builderDatatype](value)) {
74 | throw error_.new(`invalid ${builderDatatype} value`, { property, value })
75 | }
76 | }
77 | export const site = site => {
78 | if (!(isNonEmptyString(site) && siteRegex.test(site))) {
79 | throw error_.new('invalid site', { site })
80 | }
81 | }
82 | export const siteTitle = validateStringValue.bind(null, 'title')
83 | export const badges = badges => {
84 | if (!isArray(badges)) {
85 | throw error_.new('invalid badges', { badges })
86 | }
87 | for (const badge of badges) {
88 | if (!isItemId(badge)) {
89 | throw error_.new('invalid badge', { invalidBadge: badge, badges })
90 | }
91 | }
92 | }
93 | export const guid = guid => {
94 | if (!isNonEmptyString(guid)) {
95 | throw error_.new('missing guid', { guid })
96 | }
97 |
98 | if (!isGuid(guid)) {
99 | throw error_.new('invalid guid', { guid })
100 | }
101 | }
102 | export const hash = hash => {
103 | // Couldn't find the hash length range
104 | // but it looks to be somewhere around 40 characters
105 | if (!/^[0-9a-f]{20,80}$/.test(hash)) {
106 | throw error_.new('invalid hash', { hash })
107 | }
108 | }
109 | export const rank = rank => {
110 | if (possibleRanks.indexOf(rank) === -1) {
111 | throw error_.new('invalid rank', { rank })
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/lib/validate_and_enrich_config.js:
--------------------------------------------------------------------------------
1 | import { name, version, homepage } from '../assets/metadata.js'
2 | import error_ from './error.js'
3 | import parseInstance from './parse_instance.js'
4 | import { forceArray } from './utils.js'
5 |
6 | export default (generalConfig, requestConfig) => {
7 | generalConfig.userAgent = generalConfig.userAgent || `${name}/v${version} (${homepage})`
8 |
9 | let config
10 | if (requestConfig) {
11 | config = Object.assign({}, generalConfig, requestConfig)
12 | } else {
13 | config = generalConfig
14 | if (config._validatedAndEnriched) return config
15 | }
16 |
17 | parseInstance(config)
18 | if (config.instance == null) throw error_.new('invalid config object', { config })
19 |
20 | config.anonymous = config.anonymous === true
21 |
22 | if (!config.credentials && !config.anonymous) throw error_.new('missing credentials', { config })
23 |
24 | if (config.credentials) {
25 | if (!config.credentials.oauth && !config.credentials.browserSession && (!config.credentials.username || !config.credentials.password)) {
26 | throw error_.new('missing credentials')
27 | }
28 |
29 | if (config.credentials.oauth && (config.credentials.username || config.credentials.password)) {
30 | throw error_.new('credentials can not be both oauth tokens, and a username and password')
31 | }
32 |
33 | // Making sure that the 'bot' flag was explicitly set to true
34 | config.bot = config.bot === true
35 | }
36 |
37 | let { summary, tags, baserevid } = config
38 |
39 | if (summary != null) checkType('summary', summary, 'string')
40 |
41 | // on wikidata.org: set tag to wikibase-edit by default
42 | if (config.instance === 'https://www.wikidata.org') {
43 | tags = tags || 'WikibaseJS-edit'
44 | }
45 |
46 | if (tags != null) {
47 | tags = forceArray(tags)
48 | tags.forEach(tag => checkType('tags', tag, 'string'))
49 | config.tags = tags
50 | }
51 |
52 | if (baserevid != null) checkType('baserevid', baserevid, 'number')
53 |
54 | config._validatedAndEnriched = true
55 |
56 | return config
57 | }
58 |
59 | const checkType = (name, value, type) => {
60 | if (typeof value !== type) { // eslint-disable-line valid-typeof
61 | throw error_.new(`invalid config ${name}`, { [name]: value, type: typeof summary })
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/lib/validate_parameters.js:
--------------------------------------------------------------------------------
1 | import error_ from './error.js'
2 |
3 | export default params => {
4 | if (params == null) {
5 | const err = error_.new('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 error_.new('invalid params object', { params })
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "wikibase-edit",
3 | "version": "7.2.4",
4 | "description": "Edit Wikibase from NodeJS",
5 | "type": "module",
6 | "main": "lib/index.js",
7 | "files": [
8 | "assets",
9 | "lib"
10 | ],
11 | "scripts": {
12 | "git-pre-commit": "./scripts/githooks/pre-commit",
13 | "lint": "eslint -c .eslintrc.cjs lib tests",
14 | "lint:fix": "eslint -c .eslintrc.cjs --fix lib tests",
15 | "test": "npm run test:unit && npm run test:integration",
16 | "test:unit": "mocha $MOCHA_OPTIONS tests/unit/*.js tests/unit/*/*.js",
17 | "test:integration": "mocha $MOCHA_OPTIONS tests/integration/*.js tests/integration/*/*.js",
18 | "prepublishOnly": "npm run lint && npm test",
19 | "postpublish": "git push --tags",
20 | "postversion": "./scripts/postversion",
21 | "update-toc": "./scripts/update_toc"
22 | },
23 | "repository": {
24 | "type": "git",
25 | "url": "git+https://github.com/maxlath/wikibase-edit.git"
26 | },
27 | "keywords": [
28 | "wikibase",
29 | "wikidata",
30 | "write",
31 | "update",
32 | "edit",
33 | "API"
34 | ],
35 | "author": "maxlath",
36 | "license": "MIT",
37 | "bugs": {
38 | "url": "https://github.com/maxlath/wikibase-edit/issues"
39 | },
40 | "homepage": "https://github.com/maxlath/wikibase-edit",
41 | "dependencies": {
42 | "cross-fetch": "^4.1.0",
43 | "crypto-js": "^4.1.1",
44 | "lodash.isequal": "^4.5.0",
45 | "oauth-1.0a": "^2.2.6",
46 | "wikibase-sdk": "^10.1.0"
47 | },
48 | "devDependencies": {
49 | "@vercel/git-hooks": "^1.0.0",
50 | "config": "^3.3.9",
51 | "eslint": "^8.42.0",
52 | "eslint-config-standard": "^17.1.0",
53 | "eslint-plugin-import": "^2.27.5",
54 | "eslint-plugin-n": "^16.0.0",
55 | "eslint-plugin-promise": "^6.1.1",
56 | "mocha": "^10.2.0",
57 | "nock": "^13.3.1",
58 | "should": "^13.2.3",
59 | "tiny-chalk": "^3.0.2"
60 | },
61 | "engines": {
62 | "node": ">= 18"
63 | },
64 | "imports": {
65 | "#lib/*": "./lib/*.js",
66 | "#tests/*": "./tests/*.js",
67 | "#root": "./lib/index.js"
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/scripts/githooks/pre-commit:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | set -eu
3 |
4 | npm run lint
5 | npm run test:unit
6 |
--------------------------------------------------------------------------------
/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 > ./assets/metadata.js
12 |
13 | git add --force ./assets/metadata.js
14 | git commit --amend --no-edit
15 |
16 | version=$(cat package.json | jq .version -cr)
17 | tag="v${version}"
18 | git tag -d "$tag"
19 | git tag "$tag"
20 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/tests/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | globals: {
3 | // Mocha globals
4 | it: 'readonly',
5 | xit: 'readonly',
6 | describe: 'readonly',
7 | xdescribe: 'readonly',
8 | beforeEach: 'readonly',
9 | before: 'readonly',
10 | },
11 | }
12 |
--------------------------------------------------------------------------------
/tests/integration/alias/add.js:
--------------------------------------------------------------------------------
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 |
--------------------------------------------------------------------------------
/tests/integration/alias/remove.js:
--------------------------------------------------------------------------------
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 |
--------------------------------------------------------------------------------
/tests/integration/alias/set.js:
--------------------------------------------------------------------------------
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/anonymous_edit.js:
--------------------------------------------------------------------------------
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/integration/badge/add.js:
--------------------------------------------------------------------------------
1 | import 'should'
2 | import config from 'config'
3 | import WBEdit from '#root'
4 |
5 | const wbEdit = WBEdit(config)
6 |
7 | // Those tests require setting an instance with sitelinks
8 | // (such as test.wikidata.org) in config, and are thus disabled by default
9 | xdescribe('add badges', () => {
10 | it('should add a badge', async () => {
11 | await wbEdit.sitelink.set({
12 | id: 'Q224124',
13 | site: 'dewiki',
14 | title: 'September',
15 | badges: [ 'Q608' ],
16 | })
17 | const res = await wbEdit.badge.add({
18 | id: 'Q224124',
19 | site: 'dewiki',
20 | badges: [ 'Q609' ],
21 | })
22 | res.success.should.equal(1)
23 | res.entity.sitelinks.dewiki.badges.should.deepEqual([ 'Q608', 'Q609' ])
24 | })
25 |
26 | it('should ignore already added badges', async () => {
27 | await wbEdit.sitelink.set({
28 | id: 'Q224124',
29 | site: 'dewiki',
30 | title: 'September',
31 | badges: [ 'Q608' ],
32 | })
33 | const res = await wbEdit.badge.add({
34 | id: 'Q224124',
35 | site: 'dewiki',
36 | badges: [ 'Q608' ],
37 | })
38 | res.success.should.equal(1)
39 | res.entity.sitelinks.dewiki.badges.should.deepEqual([ 'Q608' ])
40 | })
41 | })
42 |
--------------------------------------------------------------------------------
/tests/integration/badge/remove.js:
--------------------------------------------------------------------------------
1 | import 'should'
2 | import config from 'config'
3 | import WBEdit from '#root'
4 |
5 | const wbEdit = WBEdit(config)
6 |
7 | // Those tests require setting an instance with sitelinks
8 | // (such as test.wikidata.org) in config, and are thus disabled by default
9 | xdescribe('remove badges', () => {
10 | it('should remove a badge', async () => {
11 | await wbEdit.sitelink.set({
12 | id: 'Q224124',
13 | site: 'dewiki',
14 | title: 'September',
15 | badges: [ 'Q608' ],
16 | })
17 | const res = await wbEdit.badge.remove({
18 | id: 'Q224124',
19 | site: 'dewiki',
20 | badges: [ 'Q608' ],
21 | })
22 | res.success.should.equal(1)
23 | res.entity.sitelinks.dewiki.badges.should.deepEqual([])
24 | })
25 |
26 | it('should ignore absent badges', async () => {
27 | await wbEdit.sitelink.set({
28 | id: 'Q224124',
29 | site: 'dewiki',
30 | title: 'September',
31 | badges: [ 'Q608' ],
32 | })
33 | const res = await wbEdit.badge.remove({
34 | id: 'Q224124',
35 | site: 'dewiki',
36 | badges: [ 'Q609' ],
37 | })
38 | res.success.should.equal(1)
39 | res.entity.sitelinks.dewiki.badges.should.deepEqual([ 'Q608' ])
40 | })
41 | })
42 |
--------------------------------------------------------------------------------
/tests/integration/claim/reconciliation.js:
--------------------------------------------------------------------------------
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 { shouldNotBeCalled } from '../utils/utils.js'
7 | import WBEdit from '#root'
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 | await wbEdit.claim.create({ id, property, value: 'foo', reconciliationz: {} })
21 | .then(shouldNotBeCalled)
22 | .catch(err => {
23 | err.message.should.equal('invalid parameter: reconciliationz')
24 | })
25 |
26 | await wbEdit.entity.edit({
27 | id,
28 | claims: {
29 | [property]: 'foo',
30 | },
31 | reconciliationz: {},
32 | })
33 | .then(shouldNotBeCalled)
34 | .catch(err => {
35 | err.message.should.equal('invalid parameter: reconciliationz')
36 | })
37 |
38 | await wbEdit.entity.edit({
39 | id,
40 | claims: {
41 | [property]: {
42 | value: 'foo',
43 | reconciliationz: {},
44 | },
45 | },
46 | })
47 | .then(shouldNotBeCalled)
48 | .catch(err => {
49 | err.message.should.equal('invalid claim parameter: reconciliationz')
50 | })
51 | })
52 |
53 | describe('per-claim reconciliation settings', () => {
54 | it('should accept per-claim reconciliation settings', async () => {
55 | const [ id, property ] = await Promise.all([
56 | getReservedItemId(),
57 | getSandboxPropertyId('string'),
58 | ])
59 | await wbEdit.entity.edit({
60 | id,
61 | claims: {
62 | [property]: [
63 | { value: 'foo', qualifiers: { [property]: 'buzz' } },
64 | { value: 'bar', qualifiers: { [property]: 'bla' } },
65 | ],
66 | },
67 | })
68 | const res2 = await wbEdit.entity.edit({
69 | id,
70 | claims: {
71 | [property]: [
72 | { value: 'foo', qualifiers: { [property]: 'blo' } },
73 | { value: 'bar', qualifiers: { [property]: 'bli' }, reconciliation: { mode: 'skip-on-value-match' } },
74 | ],
75 | },
76 | reconciliation: {
77 | mode: 'merge',
78 | },
79 | })
80 | simplify.claims(res2.entity.claims, { keepQualifiers: true }).should.deepEqual({
81 | [property]: [
82 | { value: 'foo', qualifiers: { [property]: [ 'buzz', 'blo' ] } },
83 | { value: 'bar', qualifiers: { [property]: [ 'bla' ] } },
84 | ],
85 | })
86 | })
87 | })
88 | })
89 |
--------------------------------------------------------------------------------
/tests/integration/claim/reconciliation_remove.js:
--------------------------------------------------------------------------------
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 { shouldNotBeCalled } from '../utils/utils.js'
7 | import WBEdit from '#root'
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/claim/reconciliation_skip_on_any_value.js:
--------------------------------------------------------------------------------
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 |
7 | const wbEdit = WBEdit(config)
8 |
9 | describe('reconciliation: skip-on-any-value mode', function () {
10 | this.timeout(20 * 1000)
11 | before('wait for instance', waitForInstance)
12 |
13 | it('should add a statement when no statement exists for that property', async () => {
14 | const [ id, property ] = await Promise.all([
15 | getReservedItemId(),
16 | getSandboxPropertyId('string'),
17 | ])
18 | const res = await wbEdit.claim.create({
19 | id,
20 | property,
21 | value: 'foo',
22 | reconciliation: {
23 | mode: 'skip-on-any-value',
24 | },
25 | })
26 | res.claim.mainsnak.datavalue.value.should.equal('foo')
27 | })
28 |
29 | it('should not add a statement when a statement exists for that property', async () => {
30 | const [ id, property ] = await Promise.all([
31 | getReservedItemId(),
32 | getSandboxPropertyId('string'),
33 | ])
34 | const res = await wbEdit.claim.create({ id, property, value: 'foo' })
35 | const res2 = await wbEdit.claim.create({
36 | id,
37 | property,
38 | value: 'bar',
39 | reconciliation: {
40 | mode: 'skip-on-any-value',
41 | },
42 | })
43 | res2.claim.id.should.equal(res.claim.id)
44 | res2.claim.mainsnak.datavalue.value.should.equal('foo')
45 | })
46 | })
47 |
--------------------------------------------------------------------------------
/tests/integration/claim/reconciliation_skip_on_value_match_mode.js:
--------------------------------------------------------------------------------
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 |
8 | const wbEdit = WBEdit(config)
9 |
10 | describe('reconciliation: skip-on-value-match mode', function () {
11 | this.timeout(20 * 1000)
12 | before('wait for instance', waitForInstance)
13 |
14 | it('should add a statement when no statement exists', 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-value-match',
25 | },
26 | })
27 | res.claim.mainsnak.datavalue.value.should.equal('foo')
28 | })
29 |
30 | it('should not re-add an existing statement', async () => {
31 | const [ id, property ] = await Promise.all([
32 | getReservedItemId(),
33 | getSandboxPropertyId('string'),
34 | ])
35 | const res = await wbEdit.claim.create({ id, property, value: 'foo' })
36 | const res2 = await wbEdit.claim.create({
37 | id,
38 | property,
39 | value: 'foo',
40 | reconciliation: {
41 | mode: 'skip-on-value-match',
42 | },
43 | })
44 | res2.claim.id.should.equal(res.claim.id)
45 | res2.claim.mainsnak.datavalue.value.should.equal('foo')
46 | })
47 |
48 | it('should not merge qualifiers and references', async () => {
49 | const [ id, property ] = await Promise.all([
50 | getReservedItemId(),
51 | getSandboxPropertyId('string'),
52 | ])
53 | const res = await wbEdit.claim.create({
54 | id,
55 | property,
56 | value: 'foo',
57 | qualifiers: { [property]: 'bar' },
58 | references: { [property]: 'buzz' },
59 | })
60 | const res2 = await wbEdit.claim.create({
61 | id,
62 | property,
63 | value: 'foo',
64 | qualifiers: { [property]: 'bla' },
65 | references: { [property]: 'blu' },
66 | reconciliation: {
67 | mode: 'skip-on-value-match',
68 | },
69 | })
70 | res2.claim.id.should.equal(res.claim.id)
71 | res2.claim.mainsnak.datavalue.value.should.equal('foo')
72 | simplify.propertyQualifiers(res2.claim.qualifiers[property]).should.deepEqual([ 'bar' ])
73 | simplify.references(res2.claim.references).should.deepEqual([ { [property]: [ 'buzz' ] } ])
74 | })
75 | })
76 |
--------------------------------------------------------------------------------
/tests/integration/claim/remove.js:
--------------------------------------------------------------------------------
1 | import 'should'
2 | import config from 'config'
3 | import { getSandboxPropertyId } from '#tests/integration/utils/sandbox_entities'
4 | import { addClaim } from '#tests/integration/utils/sandbox_snaks'
5 | import { waitForInstance } from '#tests/integration/utils/wait_for_instance'
6 | import { randomString } from '#tests/unit/utils'
7 | import { shouldNotBeCalled } from '../utils/utils.js'
8 | import WBEdit from '#root'
9 |
10 | const wbEdit = WBEdit(config)
11 |
12 | describe('claim create', function () {
13 | this.timeout(20 * 1000)
14 | before('wait for instance', waitForInstance)
15 |
16 | it('should remove a claim', async () => {
17 | const { guid } = await addClaim({ datatype: 'string', value: randomString() })
18 | const res = await wbEdit.claim.remove({ guid })
19 | res.success.should.equal(1)
20 | res.claims.should.deepEqual([ guid ])
21 | })
22 |
23 | it('should remove several claims on a same entity', async () => {
24 | const propertyId = await getSandboxPropertyId('string')
25 | const claims = {}
26 | claims[propertyId] = [ randomString(), randomString() ]
27 | const res = await createEntity(claims)
28 | const guids = getGuids(res.entity, propertyId)
29 | const res2 = await wbEdit.claim.remove({ guid: guids })
30 | res2.success.should.equal(1)
31 | res2.claims.should.deepEqual(guids)
32 | })
33 |
34 | it('should remove a claim by matching value', async () => {
35 | const { guid, id, property, claim } = await addClaim({ datatype: 'string', value: randomString() })
36 | const value = claim.mainsnak.datavalue.value
37 | const res = await wbEdit.claim.remove({ id, property, value })
38 | res.success.should.equal(1)
39 | res.claims.should.deepEqual([ guid ])
40 | })
41 |
42 | it('should remove a claim with qualifiers by matching mainsnak value', async () => {
43 | const somePropertyId = await getSandboxPropertyId('string')
44 | const value = randomString()
45 | const qualifierValue = randomString()
46 | const { guid, id, property } = await addClaim({
47 | datatype: 'string',
48 | value,
49 | qualifiers: {
50 | [somePropertyId]: qualifierValue,
51 | },
52 | })
53 | const res = await wbEdit.claim.remove({
54 | id,
55 | property,
56 | value,
57 | qualifiers: { [somePropertyId]: qualifierValue },
58 | })
59 | res.success.should.equal(1)
60 | res.claims.should.deepEqual([ guid ])
61 | })
62 |
63 | it('should remove a claim with qualifiers by matching mainsnak value', async () => {
64 | const somePropertyId = await getSandboxPropertyId('string')
65 | const value = randomString()
66 | const qualifierValue = randomString()
67 | const { guid, id, property } = await addClaim({
68 | datatype: 'string',
69 | value,
70 | qualifiers: {
71 | [somePropertyId]: qualifierValue,
72 | },
73 | })
74 | const res = await wbEdit.claim.remove({
75 | id,
76 | property,
77 | value,
78 | qualifiers: { [somePropertyId]: qualifierValue },
79 | reconciliation: {
80 | matchingQualifiers: [ somePropertyId ],
81 | },
82 | })
83 | res.success.should.equal(1)
84 | res.claims.should.deepEqual([ guid ])
85 | })
86 |
87 | it('should refuse to remove a claim with non matching qualifiers', async () => {
88 | const somePropertyId = await getSandboxPropertyId('string')
89 | const value = randomString()
90 | const { id, property } = await addClaim({
91 | datatype: 'string',
92 | value,
93 | qualifiers: {
94 | [somePropertyId]: randomString(),
95 | },
96 | })
97 | await wbEdit.claim.remove({
98 | id,
99 | property,
100 | value,
101 | qualifiers: { [somePropertyId]: randomString() },
102 | reconciliation: {
103 | matchingQualifiers: [ somePropertyId ],
104 | },
105 | })
106 | .then(shouldNotBeCalled)
107 | .catch(err => {
108 | err.message.should.equal('claim not found')
109 | })
110 | })
111 |
112 | // The documentation explicitly specify that the claims should belong to the same entity
113 | // https://www.wikidata.org/w/api.php?action=help&modules=wbremoveclaims
114 |
115 | // it('should remove several claims on different entities', async () => {
116 | // const propertyId = await getSandboxPropertyId('string')
117 | // const claims = {}
118 | // claims[propertyId] = randomString()
119 | // const [ res1, res2 ] = await Promise.all([
120 | // createEntity(claims),
121 | // createEntity(claims)
122 | // ])
123 | // const guid1 = getGuids(res1.entity, propertyId)[0]
124 | // const guid2 = getGuids(res2.entity, propertyId)[0]
125 | // const res2 = await wbEdit.claim.remove({ guid: [ guid1, guid2 ] })
126 | // res2.success.should.equal(1)
127 | // res2.claims.should.deepEqual(guids)
128 | // })
129 | })
130 |
131 | const createEntity = claims => {
132 | return wbEdit.entity.create({
133 | labels: { en: randomString() },
134 | claims,
135 | })
136 | }
137 |
138 | const getGuids = (entity, propertyId) => entity.claims[propertyId].map(claim => claim.id)
139 |
--------------------------------------------------------------------------------
/tests/integration/claim/set.js:
--------------------------------------------------------------------------------
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 { 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 | res.claim.mainsnak.datavalue.value.should.equal(value)
20 | })
21 | })
22 |
--------------------------------------------------------------------------------
/tests/integration/credentials.js:
--------------------------------------------------------------------------------
1 | import 'should'
2 | import config from 'config'
3 | import { waitForInstance } from '#tests/integration/utils/wait_for_instance'
4 | import { randomString } from '#tests/unit/utils'
5 | import { undesiredRes, shouldNotBeCalled, rethrowShouldNotBeCalledErrors } from './utils/utils.js'
6 | import WBEdit from '#root'
7 |
8 | const { instance, credentials } = config
9 |
10 | const params = () => ({ labels: { en: randomString() } })
11 |
12 | describe('credentials', function () {
13 | this.timeout(20 * 1000)
14 | before('wait for instance', waitForInstance)
15 |
16 | it('should accept config at initialization', async () => {
17 | const wbEdit = WBEdit({ instance, credentials })
18 | const res = await wbEdit.entity.create(params())
19 | res.success.should.equal(1)
20 | })
21 |
22 | it('should accept credentials at request time', async () => {
23 | const wbEdit = WBEdit({ instance })
24 | const res = await wbEdit.entity.create(params(), { credentials })
25 | res.success.should.equal(1)
26 | })
27 |
28 | it('should accept instance at request time', async () => {
29 | const wbEdit = WBEdit()
30 | const res = await wbEdit.entity.create(params(), { instance, credentials })
31 | res.success.should.equal(1)
32 | })
33 |
34 | it('should reject undefined credentials', async () => {
35 | const creds = { username: null, password: null }
36 | const wbEdit = WBEdit({ instance, credentials: creds })
37 | try {
38 | await wbEdit.entity.create(params()).then(shouldNotBeCalled)
39 | } catch (err) {
40 | rethrowShouldNotBeCalledErrors(err)
41 | err.message.should.equal('missing credentials')
42 | }
43 | })
44 |
45 | it('should allow defining credentials both at initialization and request time', async () => {
46 | const wbEdit = WBEdit({ credentials })
47 | const res = await wbEdit.entity.create(params(), { instance, credentials })
48 | res.success.should.equal(1)
49 | })
50 |
51 | it('should reject defining both oauth and username:password credentials', async () => {
52 | const creds = { username: 'abc', password: 'def', oauth: {} }
53 | const wbEdit = WBEdit({ instance, credentials: creds })
54 | try {
55 | await wbEdit.entity.create(params()).then(shouldNotBeCalled)
56 | } catch (err) {
57 | rethrowShouldNotBeCalledErrors(err)
58 | err.message.should.equal('credentials can not be both oauth tokens, and a username and password')
59 | }
60 | })
61 |
62 | // TODO: run a similar test for oauth
63 | if (!('oauth' in credentials)) {
64 | it('should re-generate credentials when re-using a pre-existing credentials object', done => {
65 | const wbEdit = WBEdit({ instance })
66 | const creds = Object.assign({}, credentials)
67 | wbEdit.entity.create(params(), { credentials: creds })
68 | .then(() => {
69 | creds.username = 'foo'
70 | return wbEdit.entity.create(params(), { credentials: creds })
71 | })
72 | .then(undesiredRes(done))
73 | .catch(err => {
74 | err.body.error.code.should.equal('assertuserfailed')
75 | done()
76 | })
77 | .catch(done)
78 | })
79 | }
80 | })
81 |
--------------------------------------------------------------------------------
/tests/integration/description/set.js:
--------------------------------------------------------------------------------
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 |
--------------------------------------------------------------------------------
/tests/integration/entity/create.js:
--------------------------------------------------------------------------------
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 |
8 | const wbEdit = WBEdit(config)
9 |
10 | describe('entity create', function () {
11 | this.timeout(20 * 1000)
12 | before('wait for instance', waitForInstance)
13 |
14 | it('should create a property', async () => {
15 | const res = await wbEdit.entity.create({
16 | type: 'property',
17 | datatype: 'external-id',
18 | labels: {
19 | en: randomString(),
20 | },
21 | })
22 | res.success.should.equal(1)
23 | res.entity.type.should.equal('property')
24 | })
25 |
26 | it('should create an item', async () => {
27 | const [ pidA, pidB, pidC ] = await Promise.all([
28 | getSandboxPropertyId('string'),
29 | getSandboxPropertyId('external-id'),
30 | getSandboxPropertyId('url'),
31 | ])
32 | const claims = {}
33 | claims[pidA] = { value: randomString(), qualifiers: {}, references: {} }
34 | claims[pidA].qualifiers[pidB] = randomString()
35 | claims[pidA].references[pidC] = 'http://foo.bar'
36 | const res = await wbEdit.entity.create({
37 | type: 'item',
38 | labels: { en: randomString() },
39 | descriptions: { en: randomString() },
40 | aliases: { en: randomString() },
41 | claims,
42 | })
43 | res.success.should.equal(1)
44 | })
45 | })
46 |
--------------------------------------------------------------------------------
/tests/integration/entity/delete.js:
--------------------------------------------------------------------------------
1 | import 'should'
2 | import config from 'config'
3 | import { waitForInstance } from '#tests/integration/utils/wait_for_instance'
4 | // Use credentialsAlt as the OAuth token might miss the permission to delete pages
5 | // thus getting a 'permissiondenied' error
6 | import { randomString } from '#tests/unit/utils'
7 | import WBEdit from '#root'
8 |
9 | const { instance, credentialsAlt } = config
10 |
11 | const wbEdit = WBEdit({ instance, credentials: credentialsAlt })
12 |
13 | describe('entity delete', function () {
14 | this.timeout(20 * 1000)
15 | before('wait for instance', waitForInstance)
16 |
17 | it('should delete an item', async () => {
18 | const resA = await wbEdit.entity.create({ labels: { en: randomString() } })
19 | const { id } = resA.entity
20 | const resB = await wbEdit.entity.delete({ id })
21 | resB.delete.title.should.endWith(id)
22 | })
23 |
24 | it('should delete a property', async () => {
25 | const resA = await wbEdit.entity.create({
26 | type: 'property',
27 | datatype: 'string',
28 | labels: { en: randomString() },
29 | })
30 | const { id } = resA.entity
31 | const resB = await wbEdit.entity.delete({ id })
32 | resB.delete.title.should.equal(`Property:${id}`)
33 | })
34 | })
35 |
--------------------------------------------------------------------------------
/tests/integration/entity/merge.js:
--------------------------------------------------------------------------------
1 | import 'should'
2 | import config from 'config'
3 | import { waitForInstance } from '#tests/integration/utils/wait_for_instance'
4 | import { randomString } from '#tests/unit/utils'
5 | import WBEdit from '#root'
6 |
7 | const wbEdit = WBEdit(config)
8 |
9 | describe('entity merge', function () {
10 | this.timeout(20 * 1000)
11 | before('wait for instance', waitForInstance)
12 |
13 | // Do not run this test on the local instance as it currently fails
14 | // https://phabricator.wikimedia.org/T232925
15 | xit('should merge two items', async () => {
16 | const [ res1, res2 ] = await Promise.all([
17 | wbEdit.entity.create({ labels: { en: randomString() } }),
18 | wbEdit.entity.create({ labels: { en: randomString() } }),
19 | ])
20 | const { id: from } = res1.entity
21 | const { id: to } = res2.entity
22 | const res3 = await wbEdit.entity.merge({ from, to })
23 | res3.success.should.equal(1)
24 | res3.redirected.should.equal(1)
25 | res3.from.id.should.equal(from)
26 | res3.to.id.should.equal(to)
27 | })
28 | })
29 |
--------------------------------------------------------------------------------
/tests/integration/fetch_properties_datatypes.js:
--------------------------------------------------------------------------------
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 |
--------------------------------------------------------------------------------
/tests/integration/get_auth_data.js:
--------------------------------------------------------------------------------
1 | import config from 'config'
2 | import should from 'should'
3 | import GetAuthData 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 = GetAuthData(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 = GetAuthData(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 = GetAuthData(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 = GetAuthData(config)
45 | const dataA = await getAuthData()
46 | const dataB = await getAuthData({ refresh: true })
47 | dataA.should.not.equal(dataB)
48 | })
49 | })
50 |
--------------------------------------------------------------------------------
/tests/integration/get_token.js:
--------------------------------------------------------------------------------
1 | import config from 'config'
2 | import should from 'should'
3 | import GetToken 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 = GetToken(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 = GetToken(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 = GetToken(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 = GetToken(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: { oauth: invalidCreds } })
71 | const getToken = GetToken(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 |
--------------------------------------------------------------------------------
/tests/integration/label/set.js:
--------------------------------------------------------------------------------
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/maxlag.js:
--------------------------------------------------------------------------------
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 { undesiredRes } from './utils/utils.js'
7 | import WBEdit from '#root'
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 | const doAction = async (wbEdit, reqConfig) => {
39 | const id = await getSandboxItemId()
40 | const params = { id, language: 'fr', value: randomString() }
41 | return wbEdit.alias.add(params, reqConfig)
42 | }
43 |
--------------------------------------------------------------------------------
/tests/integration/multi_users.js:
--------------------------------------------------------------------------------
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 { getEntityHistory } from './utils/utils.js'
7 | import WBEdit from '#root'
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 | const addAlias = async (wbEdit, id, reqConfig) => {
34 | return wbEdit.alias.add({
35 | id,
36 | language: 'la',
37 | value: randomString(),
38 | }, reqConfig)
39 | }
40 |
--------------------------------------------------------------------------------
/tests/integration/qualifier/remove.js:
--------------------------------------------------------------------------------
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/qualifier/set.js:
--------------------------------------------------------------------------------
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 { 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 | qualifier.datavalue.value.should.equal(value)
25 | })
26 |
27 | it('should set a qualifier with a custom calendar', async () => {
28 | const [ guid, property ] = await Promise.all([
29 | getSandboxClaimId(),
30 | getSandboxPropertyId('time'),
31 | ])
32 | const res = await setQualifier({ guid, property, value: { time: '1802-02-26', calendar: 'julian' } })
33 | res.success.should.equal(1)
34 | const qualifier = res.claim.qualifiers[property].slice(-1)[0]
35 | qualifier.datavalue.value.calendarmodel.should.equal('http://www.wikidata.org/entity/Q1985786')
36 | })
37 | })
38 |
--------------------------------------------------------------------------------
/tests/integration/reference/remove.js:
--------------------------------------------------------------------------------
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 |
--------------------------------------------------------------------------------
/tests/integration/reference/set.js:
--------------------------------------------------------------------------------
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 { randomString } from '#tests/unit/utils'
6 | import WBEdit from '#root'
7 |
8 | const wbEdit = WBEdit(config)
9 | const setReference = wbEdit.reference.set
10 |
11 | describe('reference set', function () {
12 | this.timeout(20 * 1000)
13 | before('wait for instance', waitForInstance)
14 |
15 | it('should set a reference with the property/value interface', async () => {
16 | const [ guid, property ] = await Promise.all([
17 | getSandboxClaimId(),
18 | getSandboxPropertyId('string'),
19 | ])
20 | const value = randomString()
21 | const res = await setReference({ guid, property, value })
22 | res.success.should.equal(1)
23 | res.reference.snaks[property][0].datavalue.value.should.equal(value)
24 | })
25 |
26 | it('should set a reference with the snaks object interface', async () => {
27 | const [ guid, stringProperty, quantityProperty ] = await Promise.all([
28 | getSandboxClaimId(),
29 | getSandboxPropertyId('string'),
30 | getSandboxPropertyId('quantity'),
31 | ])
32 | const stringValue = randomString()
33 | const quantityValue = Math.random()
34 | const snaks = {
35 | [stringProperty]: [
36 | { snaktype: 'novalue' },
37 | stringValue,
38 | ],
39 | [quantityProperty]: quantityValue,
40 | }
41 | const res = await setReference({ guid, snaks })
42 | res.success.should.equal(1)
43 | res.reference.snaks[stringProperty][0].snaktype.should.equal('novalue')
44 | res.reference.snaks[stringProperty][1].datavalue.value.should.equal(stringValue)
45 | res.reference.snaks[quantityProperty][0].datavalue.value.amount.should.equal(`+${quantityValue}`)
46 | })
47 |
48 | it('should update a reference by passing a hash', async () => {
49 | const [ guid, stringProperty, quantityProperty ] = await Promise.all([
50 | getSandboxClaimId(),
51 | getSandboxPropertyId('string'),
52 | getSandboxPropertyId('quantity'),
53 | ])
54 | const initialClaim = await getRefreshedClaim(guid)
55 | const stringValue = randomString()
56 | const quantityValue = Math.random()
57 | const initialSnaks = {
58 | [stringProperty]: { snaktype: 'novalue' },
59 | }
60 | const res1 = await setReference({ guid, snaks: initialSnaks })
61 | res1.reference.snaks[stringProperty][0].snaktype.should.equal('novalue')
62 | const { hash } = res1.reference
63 | const updatedSnaks = {
64 | [stringProperty]: stringValue,
65 | [quantityProperty]: quantityValue,
66 | }
67 | const res2 = await setReference({ guid, hash, snaks: updatedSnaks }, { summary: 'here' })
68 | res2.reference.snaks[stringProperty][0].datavalue.value.should.equal(stringValue)
69 | res2.reference.snaks[quantityProperty][0].datavalue.value.amount.should.equal(`+${quantityValue}`)
70 | const claim = await getRefreshedClaim(guid)
71 | claim.references.length.should.equal(initialClaim.references.length + 1)
72 | claim.references.slice(-1)[0].hash.should.equal(res2.reference.hash)
73 | })
74 | })
75 |
--------------------------------------------------------------------------------
/tests/integration/sitelink/set.js:
--------------------------------------------------------------------------------
1 | import 'should'
2 | import config from 'config'
3 | import WBEdit from '#root'
4 |
5 | const wbEdit = WBEdit(config)
6 |
7 | // Those tests require setting an instance with sitelinks
8 | // (such as test.wikidata.org) in config, and are thus disabled by default
9 | xdescribe('set sitelink', () => {
10 | it('should set a sitelink', async () => {
11 | const res = await wbEdit.sitelink.set({
12 | id: 'Q224124',
13 | site: 'frwiki',
14 | title: 'Septembre',
15 | badges: 'Q608|Q609',
16 | })
17 | res.success.should.equal(1)
18 | res.entity.id.should.equal('Q224124')
19 | res.entity.sitelinks.frwiki.title.should.equal('Septembre')
20 | res.entity.sitelinks.frwiki.badges.should.deepEqual([ 'Q608', 'Q609' ])
21 | })
22 |
23 | it('should remove a sitelink', async () => {
24 | await wbEdit.sitelink.set({
25 | id: 'Q224124',
26 | site: 'eswiki',
27 | title: 'Septiembre',
28 | })
29 | const res = await wbEdit.sitelink.set({
30 | id: 'Q224124',
31 | site: 'eswiki',
32 | title: null,
33 | })
34 | res.success.should.equal(1)
35 | res.entity.id.should.equal('Q224124')
36 | res.entity.sitelinks.eswiki.removed.should.equal('')
37 | })
38 | })
39 |
--------------------------------------------------------------------------------
/tests/integration/tags.js:
--------------------------------------------------------------------------------
1 | import 'should'
2 | import config from 'config'
3 | import { randomString } from '../unit/utils.js'
4 | import { getLastRevision } from './utils/utils.js'
5 | import WBEdit from '#root'
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 |
--------------------------------------------------------------------------------
/tests/integration/token_expiration.js:
--------------------------------------------------------------------------------
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/utils/get_property.js:
--------------------------------------------------------------------------------
1 | import config from 'config'
2 | import wbkFactory from 'wikibase-sdk'
3 | import { customFetch } from '#lib/request/fetch'
4 | import { randomString } from '#tests/unit/utils'
5 | import WBEdit from '#root'
6 |
7 | const wbk = wbkFactory({ instance: config.instance })
8 | const sandboxProperties = {}
9 | const wbEdit = WBEdit(config)
10 |
11 | export default async ({ datatype, reserved }) => {
12 | if (!datatype) throw new Error('missing datatype')
13 | if (reserved) return createProperty(datatype)
14 | const property = await getProperty(datatype)
15 | sandboxProperties[datatype] = property
16 | return property
17 | }
18 |
19 | const getProperty = async datatype => {
20 | const pseudoPropertyId = getPseudoPropertyId(datatype)
21 |
22 | const cachedPropertyId = sandboxProperties[pseudoPropertyId]
23 |
24 | if (cachedPropertyId) return cachedPropertyId
25 |
26 | const foundPropertyId = await findOnWikibase(pseudoPropertyId)
27 | if (foundPropertyId) return foundPropertyId
28 | else return createProperty(datatype)
29 | }
30 |
31 | const findOnWikibase = async pseudoPropertyId => {
32 | const url = wbk.searchEntities({ search: pseudoPropertyId, type: 'property' })
33 | const body = await customFetch(url).then(res => res.json())
34 | const firstWbResult = body.search[0]
35 | if (firstWbResult) return firstWbResult
36 | }
37 |
38 | const createProperty = async datatype => {
39 | const pseudoPropertyId = getPseudoPropertyId(datatype)
40 | const res = await wbEdit.entity.create({
41 | type: 'property',
42 | datatype,
43 | labels: {
44 | // Including a random string to avoid conflicts in case a property with that pseudoPropertyId
45 | // already exist but wasn't found due to a problem in ElasticSearch
46 | en: `${pseudoPropertyId} (${randomString()})`,
47 | },
48 | })
49 | return res.entity
50 | }
51 |
52 | const getPseudoPropertyId = datatype => `${datatype} sandbox property`
53 |
--------------------------------------------------------------------------------
/tests/integration/utils/sandbox_entities.js:
--------------------------------------------------------------------------------
1 | import config from 'config'
2 | import wbkFactory from 'wikibase-sdk'
3 | import { customFetch } from '#lib/request/fetch'
4 | import { randomString } from '#tests/unit/utils'
5 | import getProperty from './get_property.js'
6 | import WBEdit from '#root'
7 |
8 | const wbk = wbkFactory({ instance: config.instance })
9 | const { getEntityIdFromGuid } = wbk
10 | const wbEdit = WBEdit(config)
11 |
12 | // Working around the circular dependency
13 | let addClaim
14 | const lateRequire = async () => {
15 | ({ addClaim } = await import('#tests/integration/utils/sandbox_snaks'))
16 | }
17 | setTimeout(lateRequire, 0)
18 |
19 | const createEntity = async (data = {}) => {
20 | data.labels = data.labels || { en: randomString() }
21 | const { entity } = await wbEdit.entity.create(data)
22 | console.log(`created ${entity.type}`, entity.id, data.datatype || '')
23 | return entity
24 | }
25 |
26 | let sandboxItemPromise
27 | export const getSandboxItem = () => {
28 | sandboxItemPromise = sandboxItemPromise || createEntity()
29 | return sandboxItemPromise
30 | }
31 |
32 | export const getRefreshedEntity = async id => {
33 | const url = wbk.getEntities({ ids: id })
34 | const res = await customFetch(url).then(res => res.json())
35 | return res.entities[id]
36 | }
37 |
38 | let claimPromise
39 | export const getSandboxClaim = (datatype = 'string') => {
40 | if (claimPromise) return claimPromise
41 |
42 | claimPromise = Promise.all([
43 | getSandboxItem(),
44 | getSandboxPropertyId(datatype),
45 | ])
46 | .then(([ item, propertyId ]) => {
47 | const propertyClaims = item.claims[propertyId]
48 | if (propertyClaims) return propertyClaims[0]
49 | return wbEdit.claim.create({ id: item.id, property: propertyId, value: randomString() })
50 | .then(res => res.claim)
51 | })
52 |
53 | return claimPromise
54 | }
55 |
56 | export const getRefreshedClaim = async guid => {
57 | const id = getEntityIdFromGuid(guid)
58 | const { claims } = await getRefreshedEntity(id)
59 | for (const propertyClaims of Object.values(claims)) {
60 | for (const claim of propertyClaims) {
61 | if (claim.id === guid) return claim
62 | }
63 | }
64 | }
65 |
66 | export const getSandboxItemId = () => getSandboxItem().then(getId)
67 | export const getSandboxPropertyId = datatype => getProperty({ datatype }).then(getId)
68 | export const getSandboxClaimId = () => getSandboxClaim().then(getId)
69 | const getId = obj => obj.id
70 |
71 | export const createItem = (data = {}) => {
72 | data.type = 'item'
73 | return createEntity(data)
74 | }
75 |
76 | let someEntityId
77 | export const getSomeEntityId = async () => {
78 | someEntityId = someEntityId || (await getSandboxItemId())
79 | return someEntityId
80 | }
81 |
82 | let someGuid
83 | export const getSomeGuid = async () => {
84 | if (someGuid) return someGuid
85 | const { guid } = await addClaim({ datatype: 'string', value: randomString() })
86 | someGuid = guid
87 | return guid
88 | }
89 |
90 | export const getReservedItem = createItem
91 | export const getReservedItemId = () => createItem().then(entity => entity.id)
92 |
--------------------------------------------------------------------------------
/tests/integration/utils/sandbox_snaks.js:
--------------------------------------------------------------------------------
1 | import config from 'config'
2 | import { randomString } from '#tests/unit/utils'
3 | import { getSandboxItemId, getSandboxPropertyId, getSandboxClaimId } from './sandbox_entities.js'
4 | import WBEdit from '#root'
5 |
6 | const wbEdit = WBEdit(config)
7 |
8 | export const addClaim = async (params = {}) => {
9 | let { id, property, datatype = 'string', value = randomString(), qualifiers } = params
10 | id = id || (await getSandboxItemId())
11 | property = property || (await getSandboxPropertyId(datatype))
12 | const res = await wbEdit.entity.edit({
13 | id,
14 | claims: {
15 | [property]: {
16 | value,
17 | qualifiers,
18 | },
19 | },
20 | })
21 | const claim = res.entity.claims[property].slice(-1)[0]
22 | return { id, property, claim, guid: claim.id }
23 | }
24 |
25 | export const addQualifier = async ({ guid, property, datatype, value }) => {
26 | guid = guid || (await getSandboxClaimId())
27 | property = property || (await getSandboxPropertyId(datatype))
28 | const res = await wbEdit.qualifier.set({ guid, property, value })
29 | const qualifier = res.claim.qualifiers[property].slice(-1)[0]
30 | const { hash } = qualifier
31 | return { guid, property, qualifier, hash }
32 | }
33 |
34 | export const addReference = async ({ guid, property, datatype, value }) => {
35 | guid = guid || (await getSandboxClaimId())
36 | property = property || (await getSandboxPropertyId(datatype))
37 | const { reference } = await wbEdit.reference.set({ guid, property, value })
38 | const referenceSnak = reference.snaks[property].slice(-1)[0]
39 | return { guid, property, reference, referenceSnak }
40 | }
41 |
--------------------------------------------------------------------------------
/tests/integration/utils/utils.js:
--------------------------------------------------------------------------------
1 | import config from 'config'
2 | import { yellow } from 'tiny-chalk'
3 | import { WBK } from 'wikibase-sdk'
4 | import { customFetch } from '#lib/request/fetch'
5 | import resolveTitle from '#lib/resolve_title'
6 |
7 | const { instance } = config
8 |
9 | const wbk = WBK({ instance })
10 |
11 | const getRevisions = async ({ id, customInstance, limit, props }) => {
12 | customInstance = customInstance || instance
13 | const title = await resolveTitle(id, `${customInstance}/w/api.php`)
14 | const customWbk = WBK({ instance: customInstance })
15 | const url = customWbk.getRevisions({ ids: title, limit, props })
16 | const { query } = await customFetch(url).then(res => res.json())
17 | return Object.values(query.pages)[0].revisions
18 | }
19 |
20 | export async function getLastRevision (id, customInstance) {
21 | const revisions = await getRevisions({ id, customInstance, limit: 1, props: [ 'comment', 'tags' ] })
22 | return revisions[0]
23 | }
24 |
25 | export const wait = ms => new Promise(resolve => setTimeout(resolve, ms))
26 |
27 | export const getEntity = async id => {
28 | const url = wbk.getEntities({ ids: id })
29 | const { entities } = await customFetch(url).then(res => res.json())
30 | return entities[id]
31 | }
32 |
33 | export const getEntityHistory = async (id, customInstance) => {
34 | const revisions = await getRevisions({ id, customInstance })
35 | return revisions.sort(chronologically)
36 | }
37 |
38 | export const getLastEditSummary = async id => {
39 | if (typeof id === 'object' && id.entity) id = id.entity.id
40 | const revision = await getLastRevision(id)
41 | return revision.comment
42 | }
43 |
44 | // A function to quickly fail when a test gets an undesired positive answer
45 | export const undesiredRes = done => res => {
46 | console.warn(yellow('undesired positive res:'), res)
47 | done(new Error('.then function was expected not to be called'))
48 | }
49 |
50 | // Same but for async/await tests that don't use done
51 | export const shouldNotBeCalled = res => {
52 | console.warn(yellow('undesired positive res:'), res)
53 | const err = new Error('function was expected not to be called')
54 | err.name = 'shouldNotBeCalled'
55 | err.context = { res }
56 | throw err
57 | }
58 |
59 | export const rethrowShouldNotBeCalledErrors = err => {
60 | if (err.name === 'shouldNotBeCalled') throw err
61 | }
62 |
63 | // See /wiki/Special:BotPasswords
64 | export const isBotPassword = password => password.match(/^\w+@[a-z0-9]{32}$/)
65 |
66 | const chronologically = (a, b) => a.revid - b.revid
67 |
--------------------------------------------------------------------------------
/tests/integration/utils/wait_for_instance.js:
--------------------------------------------------------------------------------
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 |
--------------------------------------------------------------------------------
/tests/unit/alias/add.js:
--------------------------------------------------------------------------------
1 | import 'should'
2 | import addAlias from '#lib/alias/add'
3 | import { 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', () => {
9 | addAlias.bind(null, {}).should.throw('invalid entity')
10 | })
11 |
12 | it('should reject if not passed a language', () => {
13 | addAlias.bind(null, { id: someEntityId }).should.throw('invalid language')
14 | })
15 |
16 | it('should reject if not passed an alias', () => {
17 | addAlias.bind(null, { id: someEntityId, language }).should.throw('empty alias array')
18 | })
19 |
20 | it('should accept a single alias string', () => {
21 | const value = randomString()
22 | const { action, data } = addAlias({ id: someEntityId, language, value })
23 | action.should.equal('wbsetaliases')
24 | data.add.should.deepEqual(value)
25 | })
26 |
27 | it('should accept multiple aliases as an array of strings', () => {
28 | const value = [ randomString(), randomString() ]
29 | const { action, data } = addAlias({ id: someEntityId, language, value })
30 | action.should.equal('wbsetaliases')
31 | data.add.should.equal(value.join('|'))
32 | })
33 | })
34 |
--------------------------------------------------------------------------------
/tests/unit/alias/remove.js:
--------------------------------------------------------------------------------
1 | import 'should'
2 | import removeAlias from '#lib/alias/remove'
3 | import { 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', () => {
9 | removeAlias.bind(null, {}).should.throw('invalid entity')
10 | })
11 |
12 | it('should reject if not passed a language', () => {
13 | removeAlias.bind(null, { id: someEntityId }).should.throw('invalid language')
14 | })
15 |
16 | it('should reject if not passed an alias', () => {
17 | removeAlias.bind(null, { id: someEntityId, language }).should.throw('empty alias array')
18 | })
19 |
20 | it('should accept a single alias string', () => {
21 | // It's not necessary that the removed alias actually exist
22 | // so we can just pass a random string and expect Wikibase to deal with it
23 | const value = randomString()
24 | const { action, data } = removeAlias({ id: someEntityId, language, value })
25 | action.should.equal('wbsetaliases')
26 | data.remove.should.equal(value)
27 | })
28 |
29 | it('should accept multiple aliases as an array of strings', () => {
30 | const value = [ randomString(), randomString() ]
31 | const { action, data } = removeAlias({ id: someEntityId, language, value })
32 | action.should.equal('wbsetaliases')
33 | data.remove.should.equal(value.join('|'))
34 | })
35 | })
36 |
--------------------------------------------------------------------------------
/tests/unit/alias/set.js:
--------------------------------------------------------------------------------
1 | import 'should'
2 | import setAlias from '#lib/alias/set'
3 | import { 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', () => {
9 | setAlias.bind(null, {}).should.throw('invalid entity')
10 | })
11 |
12 | it('should reject if not passed a language', () => {
13 | setAlias.bind(null, { id: someEntityId }).should.throw('invalid language')
14 | })
15 |
16 | it('should reject if not passed an alias', () => {
17 | setAlias.bind(null, { id: someEntityId, language }).should.throw('empty alias array')
18 | })
19 |
20 | it('should accept a single alias string', () => {
21 | const value = randomString()
22 | const { action, data } = setAlias({ id: someEntityId, language, value })
23 | action.should.equal('wbsetaliases')
24 | data.should.be.an.Object()
25 | })
26 |
27 | it('should accept multiple aliases as an array of strings', () => {
28 | const value = [ randomString(), randomString() ]
29 | const { action, data } = setAlias({ id: someEntityId, language, value })
30 | action.should.equal('wbsetaliases')
31 | data.set.should.equal(value.join('|'))
32 | })
33 | })
34 |
--------------------------------------------------------------------------------
/tests/unit/claim/remove.js:
--------------------------------------------------------------------------------
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 | const { action } = await removeClaim({ guid })
8 | action.should.equal('wbremoveclaims')
9 | })
10 |
11 | it('should return formatted data for one claim', async () => {
12 | const { data } = await removeClaim({ guid })
13 | data.claim.should.equal(guid)
14 | })
15 |
16 | it('should return formatted data for several claims', async () => {
17 | const guids = [ guid, guid2 ]
18 | const { data } = await removeClaim({ guid: guids })
19 | data.claim.should.equal(guids.join('|'))
20 | })
21 | })
22 |
--------------------------------------------------------------------------------
/tests/unit/claim/set.js:
--------------------------------------------------------------------------------
1 | import 'should'
2 | import setClaim from '#lib/claim/set'
3 | import { guid, sandboxStringProp as property, properties } 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)
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)
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 |
--------------------------------------------------------------------------------
/tests/unit/description/set.js:
--------------------------------------------------------------------------------
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 | setDescription.bind(null, {}).should.throw('invalid entity')
10 | })
11 |
12 | it('should throw if not passed a language', () => {
13 | setDescription.bind(null, { id: someEntityId }).should.throw('invalid language')
14 | })
15 |
16 | it('should throw if not passed a description', () => {
17 | setDescription.bind(null, { id: someEntityId, language })
18 | .should.throw('missing description')
19 | })
20 |
21 | it('should return an action and data', () => {
22 | const value = `Bac à Sable (${randomString()})`
23 | const { action, data } = setDescription({ id: someEntityId, language, value })
24 | action.should.equal('wbsetdescription')
25 | data.id.should.equal(someEntityId)
26 | data.language.should.equal(language)
27 | data.value.should.equal(value)
28 | })
29 | })
30 |
--------------------------------------------------------------------------------
/tests/unit/entity/create.js:
--------------------------------------------------------------------------------
1 | import 'should'
2 | import config from 'config'
3 | import _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: { 'entity-type': 'item', 'numeric-id': 166376 },
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 |
--------------------------------------------------------------------------------
/tests/unit/entity/delete.js:
--------------------------------------------------------------------------------
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.js:
--------------------------------------------------------------------------------
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 |
--------------------------------------------------------------------------------
/tests/unit/general.js:
--------------------------------------------------------------------------------
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/unit/label/set.js:
--------------------------------------------------------------------------------
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 | setLabel.bind(null, {}).should.throw('invalid entity')
10 | })
11 |
12 | it('should throw if not passed a language', () => {
13 | setLabel.bind(null, { id: someEntityId }).should.throw('invalid language')
14 | })
15 |
16 | it('should throw if not passed a label', () => {
17 | setLabel.bind(null, { id: someEntityId, language }).should.throw('missing label')
18 | })
19 |
20 | it('should return an action and data', () => {
21 | const value = `Bac à Sable (${randomString()})`
22 | const { action, data } = setLabel({ id: someEntityId, language, value })
23 | action.should.equal('wbsetlabel')
24 | data.id.should.equal(someEntityId)
25 | data.language.should.equal(language)
26 | data.value.should.equal(value)
27 | })
28 | })
29 |
--------------------------------------------------------------------------------
/tests/unit/parse_instance.js:
--------------------------------------------------------------------------------
1 | import 'should'
2 | import parseInstance from '#lib/parse_instance'
3 |
4 | const instance = 'https://hello.bla'
5 | const apiEndpoint = `${instance}/w/api.php`
6 |
7 | describe('parseInstance', () => {
8 | it('reject a missing instance', () => {
9 | parseInstance.bind(null, {}).should.throw('missing config parameter: instance')
10 | })
11 |
12 | it('should return an instance and sparql endpoint', () => {
13 | const configA = { instance }
14 | const configB = { instance: apiEndpoint }
15 | parseInstance(configA)
16 | parseInstance(configB)
17 | configA.instance.should.equal(instance)
18 | configB.instance.should.equal(instance)
19 | })
20 |
21 | it('should allow to customize the script path', () => {
22 | const configA = { instance, wgScriptPath: 'foo' }
23 | const configB = { instance, wgScriptPath: '/foo' }
24 | parseInstance(configA)
25 | configA.instanceApiEndpoint.should.equal(`${instance}/foo/api.php`)
26 | parseInstance(configB)
27 | configB.instanceApiEndpoint.should.equal(`${instance}/foo/api.php`)
28 | })
29 | })
30 |
--------------------------------------------------------------------------------
/tests/unit/qualifier/remove.js:
--------------------------------------------------------------------------------
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 |
--------------------------------------------------------------------------------
/tests/unit/qualifier/set.js:
--------------------------------------------------------------------------------
1 | import 'should'
2 | import config from 'config'
3 | import _setQualifier from '#lib/qualifier/set'
4 | import { guid, hash, properties } from '#tests/unit/utils'
5 |
6 | const { instance } = config
7 |
8 | const setQualifier = params => _setQualifier(params, properties, instance)
9 |
10 | describe('qualifier set', () => {
11 | it('should rejected if not passed a claim guid', () => {
12 | setQualifier.bind(null, {}).should.throw('missing guid')
13 | })
14 |
15 | it('should rejected if passed an invalid claim guid', () => {
16 | const params = { guid: 'some-invalid-guid' }
17 | setQualifier.bind(null, params).should.throw('invalid guid')
18 | })
19 |
20 | it('should rejected if not passed a property', () => {
21 | const params = { guid }
22 | setQualifier.bind(null, params).should.throw('missing property')
23 | })
24 |
25 | it('should rejected if not passed a value', () => {
26 | const params = { guid, property: 'P2' }
27 | setQualifier.bind(null, params).should.throw('missing snak value')
28 | })
29 |
30 | it('should rejected if passed an invalid value', () => {
31 | const params = { guid, property: 'P2', value: 'not-a-valid-value' }
32 | setQualifier.bind(null, params).should.throw('invalid entity value')
33 | })
34 |
35 | it('should rejected if passed an hash', () => {
36 | const params = { guid, hash: 'foo', property: 'P2', value: 'Q123' }
37 | setQualifier.bind(null, params).should.throw('invalid hash')
38 | })
39 |
40 | it('should set the hash', () => {
41 | const params = { guid, hash, property: 'P2', value: 'Q123' }
42 | setQualifier(params).data.snakhash.should.equal(hash)
43 | })
44 |
45 | it('should set the action to wbsetreference', () => {
46 | const params = { guid, property: 'P2', value: 'Q123' }
47 | setQualifier(params).action.should.equal('wbsetqualifier')
48 | })
49 |
50 | it('should format the data for a string', () => {
51 | const params = { guid, property: 'P1', value: '123' }
52 | setQualifier(params).data.should.deepEqual({
53 | claim: guid,
54 | property: 'P1',
55 | snaktype: 'value',
56 | value: '"123"',
57 | })
58 | })
59 |
60 | it('should set a time qualifier', () => {
61 | const params = { guid, property: 'P4', value: '1802-02' }
62 | setQualifier(params).data.should.deepEqual({
63 | claim: guid,
64 | property: 'P4',
65 | snaktype: 'value',
66 | value: '{"time":"+1802-02-00T00:00:00Z","timezone":0,"before":0,"after":0,"precision":10,"calendarmodel":"http://www.wikidata.org/entity/Q1985727"}',
67 | })
68 | })
69 |
70 | it('should set a time qualifier with precision', () => {
71 | const params = { guid, property: 'P4', value: { time: '1802-02', precision: 10 } }
72 | setQualifier(params).data.should.deepEqual({
73 | claim: guid,
74 | property: 'P4',
75 | snaktype: 'value',
76 | value: '{"time":"+1802-02-00T00:00:00Z","timezone":0,"before":0,"after":0,"precision":10,"calendarmodel":"http://www.wikidata.org/entity/Q1985727"}',
77 | })
78 | })
79 |
80 | it('should set a quantity qualifier', () => {
81 | const params = { guid, property: 'P8', value: { amount: 123, unit: 'Q4916' } }
82 | setQualifier(params).data.should.deepEqual({
83 | claim: guid,
84 | property: 'P8',
85 | snaktype: 'value',
86 | value: `{"amount":"+123","unit":"${instance.replace('https:', 'http:')}/entity/Q4916"}`,
87 | })
88 | })
89 |
90 | it('should set a monolingualtext qualifier', () => {
91 | const params = { guid, property: 'P9', value: { text: 'foo', language: 'fr' } }
92 | setQualifier(params).data.should.deepEqual({
93 | claim: guid,
94 | property: 'P9',
95 | snaktype: 'value',
96 | value: '{"text":"foo","language":"fr"}',
97 | })
98 | })
99 |
100 | it('should set a qualifier with a special snaktype', () => {
101 | const params = { guid, property: 'P4', value: { snaktype: 'novalue' } }
102 | setQualifier(params).data.should.deepEqual({
103 | claim: guid,
104 | property: 'P4',
105 | snaktype: 'novalue',
106 | })
107 | })
108 | })
109 |
--------------------------------------------------------------------------------
/tests/unit/reference/remove.js:
--------------------------------------------------------------------------------
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 |
--------------------------------------------------------------------------------
/tests/unit/reference/set.js:
--------------------------------------------------------------------------------
1 | import 'should'
2 | import setReference from '#lib/reference/set'
3 | import { guid, properties, hash } from '#tests/unit/utils'
4 |
5 | describe('reference set', () => {
6 | it('should rejected if not passed a claim guid', () => {
7 | setReference.bind(null, {}, properties).should.throw('missing guid')
8 | })
9 |
10 | it('should rejected if passed an invalid claim guid', () => {
11 | setReference.bind(null, { guid: 'some-invalid-guid' }, properties)
12 | .should.throw('invalid guid')
13 | })
14 |
15 | it('should rejected if not passed a property', () => {
16 | setReference.bind(null, { guid }, properties).should.throw('missing property')
17 | })
18 |
19 | it('should rejected if not passed a reference value', () => {
20 | setReference.bind(null, { guid, property: 'P2' }, properties)
21 | .should.throw('missing snak value')
22 | })
23 |
24 | it('should rejected if passed an invalid reference', () => {
25 | const params = { guid, property: 'P2', value: 'not-a-valid-reference' }
26 | setReference.bind(null, params, properties).should.throw('invalid entity value')
27 | })
28 |
29 | it('should set the action to wbsetreference', () => {
30 | const params = { guid, property: 'P2', value: 'Q1' }
31 | setReference(params, properties).action.should.equal('wbsetreference')
32 | })
33 |
34 | it('should format the data for a url', () => {
35 | const params = { guid, property: 'P7', value: 'http://foo.bar' }
36 | setReference(params, properties).data.should.deepEqual({
37 | statement: guid,
38 | snaks: '{"P7":[{"property":"P7","snaktype":"value","datavalue":{"type":"string","value":"http://foo.bar"}}]}',
39 | })
40 | })
41 |
42 | it('should set a reference with a special snaktype', () => {
43 | const params = { guid, property: 'P7', value: { snaktype: 'somevalue' } }
44 | setReference(params, properties).data.should.deepEqual({
45 | statement: guid,
46 | snaks: '{"P7":[{"snaktype":"somevalue","property":"P7"}]}',
47 | })
48 | })
49 |
50 | it('should accept snaks', () => {
51 | const snaks = {
52 | P2: 'Q1',
53 | P7: [
54 | 'http://foo.bar',
55 | { snaktype: 'somevalue' },
56 | ],
57 | }
58 | const params = { guid, snaks }
59 | setReference(params, properties).data.should.deepEqual({
60 | statement: guid,
61 | snaks: '[{"property":"P2","snaktype":"value","datavalue":{"type":"wikibase-entityid","value":{"entity-type":"item","numeric-id":1}}},{"property":"P7","snaktype":"value","datavalue":{"type":"string","value":"http://foo.bar"}},{"snaktype":"somevalue","property":"P7"}]',
62 | })
63 | })
64 |
65 | it('should accept a hash', () => {
66 | const params = { guid, property: 'P2', value: 'Q1', hash }
67 | setReference(params, properties).data.reference.should.equal(hash)
68 | })
69 | })
70 |
--------------------------------------------------------------------------------
/tests/unit/request.js:
--------------------------------------------------------------------------------
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 |
--------------------------------------------------------------------------------
/tests/unit/sitelink/set.js:
--------------------------------------------------------------------------------
1 | import should from 'should'
2 | import setSitelink from '#lib/sitelink/set'
3 | import { shouldNotBeCalled } from '#tests/integration/utils/utils'
4 |
5 | describe('set sitelink', () => {
6 | it('should return wbsetsitelink params', () => {
7 | const { action, data } = setSitelink({
8 | id: 'Q123',
9 | site: 'frwiki',
10 | title: 'Septembre',
11 | })
12 | action.should.equal('wbsetsitelink')
13 | data.id.should.equal('Q123')
14 | data.linksite.should.equal('frwiki')
15 | data.linktitle.should.equal('Septembre')
16 | should(data.badges).not.be.ok()
17 | })
18 |
19 | it('should reject without title', () => {
20 | try {
21 | const res = setSitelink({
22 | id: 'Q123',
23 | site: 'frwiki',
24 | })
25 | shouldNotBeCalled(res)
26 | } catch (err) {
27 | err.message.should.containEql('invalid title')
28 | err.statusCode.should.equal(400)
29 | }
30 | })
31 |
32 | it('should accept with a null title to delete the sitelink', () => {
33 | const { action, data } = setSitelink({
34 | id: 'Q123',
35 | site: 'frwiki',
36 | title: null,
37 | })
38 | action.should.equal('wbsetsitelink')
39 | data.id.should.equal('Q123')
40 | data.linksite.should.equal('frwiki')
41 | should(data.linktitle).be.Undefined()
42 | })
43 |
44 | it('should accept badges as a string', () => {
45 | const { action, data } = setSitelink({
46 | id: 'Q123',
47 | site: 'frwiki',
48 | title: 'Septembre',
49 | badges: 'Q17437796|Q17437798',
50 | })
51 | action.should.equal('wbsetsitelink')
52 | data.id.should.equal('Q123')
53 | data.linksite.should.equal('frwiki')
54 | data.linktitle.should.equal('Septembre')
55 | data.badges.should.equal('Q17437796|Q17437798')
56 | })
57 |
58 | it('should accept badges as an array', () => {
59 | const { action, data } = setSitelink({
60 | id: 'Q123',
61 | site: 'frwiki',
62 | title: 'Septembre',
63 | badges: 'Q17437796|Q17437798',
64 | })
65 | action.should.equal('wbsetsitelink')
66 | data.id.should.equal('Q123')
67 | data.linksite.should.equal('frwiki')
68 | data.linktitle.should.equal('Septembre')
69 | data.badges.should.equal('Q17437796|Q17437798')
70 | })
71 | })
72 |
--------------------------------------------------------------------------------
/tests/unit/utils.js:
--------------------------------------------------------------------------------
1 | export const randomString = () => Math.random().toString(36).slice(2, 10)
2 | export const randomNumber = (length = 5) => Math.trunc(Math.random() * Math.pow(10, length))
3 |
4 | export const someEntityId = 'Q1'
5 | export const guid = 'Q1$3A8AA34F-0DEF-4803-AA8E-39D9EFD4DEAF'
6 | export const guid2 = 'Q1$3A8AA34F-0DAB-4803-AA8E-39D9EFD4DEAF'
7 | export const hash = '3d22f4dffba1ac6f66f521ea6bea924e46df4129'
8 | export const sandboxStringProp = 'P1'
9 |
10 | export const properties = {
11 | P1: 'String',
12 | P2: 'WikibaseItem',
13 | P3: 'WikibaseProperty',
14 | P4: 'Time',
15 | P5: 'ExternalId',
16 | P6: 'GlobeCoordinate',
17 | P7: 'Url',
18 | P8: 'Quantity',
19 | P9: 'Monolingualtext',
20 | }
21 |
--------------------------------------------------------------------------------