├── .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 | wikibase 8 | 9 |           10 | wikidata 11 |
12 | 13 | [![NPM](https://nodei.co/npm/wikibase-edit.png?stars&downloads&downloadRank)](https://npmjs.com/package/wikibase-edit/) 14 | 15 | [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT) 16 | [![Node](https://img.shields.io/badge/node-%3E=%20v7.6.0-brightgreen.svg)](http://nodejs.org) 17 | [![JavaScript Style Guide](https://img.shields.io/badge/code%20style-standard-brightgreen.svg)](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 | [![inventaire banner](https://inventaire.io/public/images/inventaire-brittanystevens-13947832357-CC-BY-lighter-blue-4-banner-500px.png)](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 | 21 | 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 | 


--------------------------------------------------------------------------------