Item   F
last analyzed

Complexity

Total Complexity 67

Size/Duplication

Total Lines 476
Duplicated Lines 0 %

Test Coverage

Coverage 6.73%

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 202
c 2
b 0
f 0
dl 0
loc 476
ccs 14
cts 208
cp 0.0673
rs 3.04
wmc 67

20 Methods

Rating   Name   Duplication   Size   Complexity  
A getPropertyOfTypeUrl() 0 11 3
A getLabel() 0 8 2
B getPropertyOfTypeExternalIdentifier() 0 28 7
A getWikidataUrl() 0 2 1
A getWikipediaIntro() 0 33 5
A __construct() 0 9 3
A setPropertyOfTypeText() 0 36 5
A getEntityCacheKey() 0 2 1
A setPropertyOfTypeItem() 0 35 4
A exists() 0 2 1
A getStandardProperties() 0 30 4
A register() 0 8 3
A getPropertyOfTypeText() 0 17 6
A setCache() 0 2 1
A getPropertyOfTypeQuantity() 0 13 3
A factory() 0 16 4
A getPropertyOfTypeItem() 0 11 3
A getPropertyOfTypeTime() 0 23 3
A getEntity() 0 17 6
A getId() 0 3 2

How to fix   Complexity   

Complex Class

Complex classes like Item often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Item, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace Samwilson\SimpleWikidata;
4
5
use DateInterval;
6
use Exception;
7
use Mediawiki\Api\MediawikiApi;
8
use Mediawiki\Api\SimpleRequest;
9
use Psr\Cache\CacheItemPoolInterface;
10
use Samwilson\SimpleWikidata\Properties\Time;
11
use Symfony\Component\DomCrawler\Crawler;
12
13
class Item {
14
15
	/** @var string|bool */
16
	const INSTANCE_OF = false;
17
18
	const PROP_INSTANCE_OF = 'P31';
19
	const PROP_TITLE = 'P1476';
20
	const PROP_IMAGE = 'P18';
21
	const PROP_AUTHOR = 'P50';
22
23
	/** @var string[] List of Q-numbers of registered classes. */
24
	protected static $registeredClasses = [];
25
26
	/** @var string */
27
	protected $id;
28
29
	/** @var MediawikiApi */
30
	protected $wdApi;
31
32
	/** @var string */
33
	protected $lang;
34
35
	/** @var CacheItemPoolInterface */
36
	protected $cache;
37
38
	/** @var string The base URL of Wikidata, with trailing slash. */
39
	protected $wikidataUrlBase = 'https://www.wikidata.org/wiki/';
40
41
	private function __construct( $id, $lang, CacheItemPoolInterface $cache ) {
42
		if ( !is_string( $id ) || preg_match( '/[QP][0-9]*/i', $id ) !== 1 ) {
43
			throw new Exception( "Not a valid ID: " . var_export( $id, true ) );
44
		}
45
		$this->id = $id;
46
		$this->wdApi = new MediawikiApi( 'https://www.wikidata.org/w/api.php' );
47
		$this->entities = [];
0 ignored issues
show
Bug Best Practice introduced by
The property entities does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
48
		$this->lang = $lang;
49
		$this->cache = $cache;
50
	}
51
52
	/**
53
	 * Create a new Item object with class based on the item's 'instance of' statement.
54
	 *
55
	 * @param string $id The item ID (Q-number).
56
	 * @param string $lang The language code.
57
	 * @param CacheItemPoolInterface $cache The cache to use.
58
	 * @return Item
59
	 */
60
	public static function factory( $id, $lang, CacheItemPoolInterface $cache ) {
61
		$item = new Item( $id, $lang, $cache );
62
		foreach ( $item->getPropertyOfTypeItem( self::PROP_INSTANCE_OF ) as $instanceOf ) {
63
			// Try to find a class matching the registered 'instance of'.
64
			foreach ( static::$registeredClasses as $classId => $className ) {
65
				// If this 'instance of' is registered, use it.
66
				if ( $classId === $instanceOf->getItem()->getId() ) {
67
					// This won't re-request the metadata, because that's cached.
68
					return new $className( $id, $lang, $cache );
69
				}
70
			}
71
		}
72
73
		// If we're here, just leave it as a basic Item.
74
		$item->setCache( $cache );
75
		return $item;
76
	}
77
78
	/**
79
	 * Register this class as a candidate for being created by Item::factory.
80
	 * Should only be called on subclasses of Item.
81
	 * @throws Exception if called on Item or the registering class does not have INSTANCE_OF set.
82
	 */
83 2
	public static function register() {
84 2
		if ( static::class === self::class ) {
0 ignored issues
show
introduced by
The condition static::class === self::class is always true.
Loading history...
85 1
			throw new Exception( __METHOD__ . ' should only be called on subclasses of Item' );
86
		}
87 1
		if ( !static::INSTANCE_OF ) {
88 1
			throw new Exception( 'Please set INSTANCE_OF for ' . static::class );
89
		}
90
		static::$registeredClasses[ static::INSTANCE_OF ] = static::class;
91
	}
92
93
	/**
94
	 * @param CacheItemPoolInterface $cache The cache to use.
95
	 */
96
	public function setCache( CacheItemPoolInterface $cache ) {
97
		$this->cache = $cache;
98
	}
99
100
	/**
101
	 * Get the ID (Q-number) of this item.
102
	 * @return string|bool The ID or false if it couldn't be determined.
103
	 */
104 1
	public function getId() {
105 1
		$entity = $this->getEntity( $this->id );
106 1
		return isset( $entity['id'] ) ? $entity['id'] : false;
107
	}
108
109
	/**
110
	 * Get this item's label.
111
	 * @return string
112
	 */
113 1
	public function getLabel() {
114 1
		$entity = $this->getEntity( $this->id );
115 1
		if ( !empty( $entity['labels'][ $this->lang ]['value'] ) ) {
116
			// Use the label if there is one.
117 1
			return $entity['labels'][ $this->lang ]['value'];
118
		}
119
		// Or just use the ID.
120
		return $entity['id'];
121
	}
122
123
	/**
124
	 * @return string The Wikidata.org URL for this item.
125
	 */
126 1
	public function getWikidataUrl() {
127 1
		return $this->wikidataUrlBase . $this->id;
128
	}
129
130
	/**
131
	 * Wikiprojects list their properties like this:
132
	 *
133
	 *     {{List of properties/Header}}
134
	 *     {{List of properties/Row|id=31|example-subject=Q923767|example-object=Q3331189}}
135
	 *     </table>
136
	 *
137
	 * @param string $wikiProject The name of the WikiProject (must exist as a Wikidata page e.g.
138
	 * [[Wikidata:$wikiProject]]).
139
	 * @param string $type
140
	 * @return array
141
	 */
142
	public function getStandardProperties( $wikiProject = 'WikiProject_Books', $type = 'work' ) {
143
		if ( $type !== 'work' ) {
144
			$type = 'edition';
145
		}
146
		$cacheKey = $type . '_item_property_IDs';
147
		if ( $this->cache->hasItem( $cacheKey ) ) {
148
			$propIds = $this->cache->getItem( $cacheKey )->get();
149
		} else {
150
			$domCrawler = new Crawler();
151
			$wikiProjectUrl = 'https://www.wikidata.org/wiki/Wikidata:' . $wikiProject;
152
			$domCrawler->addHtmlContent( file_get_contents( $wikiProjectUrl ) );
153
			$propAncors = "//h3/span[@id='" . ucfirst( $type ) . "_item_properties']"
154
				. "/../following-sibling::table[1]//td[2]/a";
155
			$propCells = $domCrawler->filterXPath( $propAncors );
156
			$propIds = [];
157
			$propCells->each( function ( Crawler $node, $i ) use ( &$propIds ) {
158
				$propId = $node->text();
159
				$propIds[] = $propId;
160
			} );
161
			$cacheItem = $this->cache->getItem( $cacheKey )
162
				->expiresAfter( new DateInterval( 'PT1H' ) )
163
				->set( $propIds );
164
			$this->cache->save( $cacheItem );
165
		}
166
		$workProperties = [];
167
		foreach ( $propIds as $propId ) {
168
			$workProperties[] = self::factory( $propId, $this->lang, $this->cache );
169
		}
170
171
		return $workProperties;
172
	}
173
174
	/**
175
	 * @param string $propertyId
176
	 * @return bool|Time[]
177
	 */
178
	public function getPropertyOfTypeTime( $propertyId ) {
179
		$times = [];
180
		$entity = $this->getEntity();
181
		if ( !isset( $entity['claims'][$propertyId] ) ) {
182
			// No statements for this property.
183
			return $times;
184
		}
185
		// print_r($entity['claims'][$propertyId]);exit();
186
		foreach ( $entity['claims'][$propertyId] as $claim ) {
187
			// print_r($claim);
188
			$times[] = new Time( $claim, $this->lang, $this->cache );
189
//
190
// $timeValue = $claim['datavalue']['value']['time'];
191
// // Ugly workaround for imprecise dates. :-(
192
// if (preg_match('/([0-9]{1,4})-00-00/', $timeValue, $matches) === 1) {
193
// $timeValue = $matches[1];
194
// return $timeValue;
195
// }
196
// $time = strtotime($timeValue);
197
// return date($dateFormat, $time);
198
			// }
199
		}
200
		return $times;
201
	}
202
203
	/**
204
	 * Get the Item that is referred to by the specified item's property.
205
	 *
206
	 * @param string $propertyId
207
	 *
208
	 * @return \Samwilson\SimpleWikidata\Properties\Item[]
209
	 */
210
	public function getPropertyOfTypeItem( $propertyId ) {
211
		$entity = $this->getEntity( $this->id );
212
		if ( !isset( $entity['claims'][$propertyId] ) ) {
213
			return [];
214
		}
215
		$items = [];
216
		foreach ( $entity['claims'][$propertyId] as $claim ) {
217
			$items[] = new Properties\Item( $claim, $this->lang, $this->cache );
218
		}
219
220
		return $items;
221
	}
222
223
	/**
224
	 * Set a claim to the given Item.
225
	 * @param string $property Property ID, with 'P'.
226
	 * @param string $itemId Item ID with 'Q'.
227
	 */
228
	public function setPropertyOfTypeItem( $property, $itemId ) {
229
		$itemIdNumeric = substr( $itemId, 1 );
230
231
		// First see if this property already exists, and that it is different from what's being set.
232
		$entity = $this->getEntity( $this->id );
233
		if ( !empty( $entity['claims'][$property] ) ) {
234
			// Get the first claim, and update it if necessary.
235
			$claim = array_shift( $entity['claims'][$property] );
236
			if ( $claim['mainsnak']['datavalue']['value']['id'] == $itemId ) {
237
				// Already is the required value, no need to change.
238
				return;
239
			}
240
			$claim['mainsnak']['datavalue']['value']['id'] = $itemId;
241
			$claim['mainsnak']['datavalue']['value']['numeric-id'] = $itemIdNumeric;
242
			$apiParams = [
243
				'action' => 'wbsetclaim',
244
				'claim' => json_encode( $claim ),
245
			];
246
		}
247
248
		// If no claim was found (and modified) above, create a new claim.
249
		if ( !isset( $apiParams ) ) {
250
			$apiParams = [
0 ignored issues
show
Unused Code introduced by
The assignment to $apiParams is dead and can be removed.
Loading history...
251
				'action' => 'wbcreateclaim',
252
				'entity' => $this->getId(),
253
				'property' => $property,
254
				'snaktype' => 'value',
255
				'value' => json_encode( [ 'entity-type' => 'item', 'numeric-id' => $itemIdNumeric ] ),
256
			];
257
		}
258
259
		// @TODO Save the property.
260
261
		// Clear the cache.
262
		$this->cache->deleteItem( $this->getEntityCacheKey( $this->id ) );
263
	}
264
265
	/**
266
	 * @param string $entityId
267
	 * @param string $propertyId
268
	 * @return array|bool
269
	 */
270
	public function getPropertyOfTypeUrl( $entityId, $propertyId ) {
271
		$entity = $this->getEntity( $entityId );
272
		if ( !isset( $entity['claims'][$propertyId] ) ) {
273
			return false;
274
		}
275
		$urls = [];
276
		foreach ( $entity['claims'][$propertyId] as $claim ) {
277
			$urls[] = $claim['mainsnak']['datavalue']['value'];
278
		}
279
280
		return $urls;
281
	}
282
283
	/**
284
	 * @param string $entityId
285
	 * @param string $propertyId
286
	 * @return array|bool
287
	 */
288
	public function getPropertyOfTypeExternalIdentifier( $entityId, $propertyId ) {
289
		$entity = $this->getEntity( $entityId );
290
		if ( !isset( $entity['claims'][$propertyId] ) ) {
291
			return false;
292
		}
293
		$idents = [];
294
		foreach ( $entity['claims'][$propertyId] as $claim ) {
295
			$qualifiers = [];
296
			if ( !isset( $claim['qualifiers'] ) ) {
297
				continue;
298
			}
299
			foreach ( $claim['qualifiers'] as $qualsInfo ) {
300
				foreach ( $qualsInfo as $qualInfo ) {
301
					$qualProp = self::factory( $qualInfo['property'], $this->lang, $this->cache );
302
					$propLabel = $qualProp->getLabel();
303
					if ( !isset( $qualifiers[$propLabel] ) ) {
304
						$qualifiers[$propLabel] = [];
305
					}
306
					$qualifiers[$propLabel][] = $qualInfo['datavalue']['value'];
307
				}
308
			}
309
			$idents[] = [
310
				'qualifiers' => $qualifiers,
311
				'value' => $claim['mainsnak']['datavalue']['value'],
312
			];
313
		}
314
315
		return $idents;
316
	}
317
318
	/**
319
	 * Get a single-valued text property.
320
	 * @param string $property One of the PROP_* constants.
321
	 * @return string|bool The value, or false if it can't be found.
322
	 */
323
	public function getPropertyOfTypeText( $property ) {
324
		$entity = $this->getEntity( $this->id );
325
		if ( isset( $entity['claims'][$property] ) ) {
326
			// Use the first title.
327
			foreach ( $entity['claims'][$property] as $t ) {
328
				if ( !isset( $t['mainsnak']['datavalue']['value']['language'] ) ) {
329
					var_dump( $t['mainsnak']['datavalue']['value'] );
0 ignored issues
show
Security Debugging Code introduced by
var_dump($t['mainsnak']['datavalue']['value']) looks like debug code. Are you sure you do not want to remove it?
Loading history...
330
					exit();
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
331
				}
332
				if ( $t['mainsnak']['datavalue']['value']['language'] == $this->lang
333
					&& !empty( $t['mainsnak']['datavalue']['value']['text'] )
334
				) {
335
					return $t['mainsnak']['datavalue']['value']['text'];
336
				}
337
			}
338
		}
339
		return false;
340
	}
341
342
	/**
343
	 * Literal data field for a quantity that relates to some kind of well-defined unit.
344
	 * The actual unit goes in the data values that is entered.
345
	 *   - amount     – implicit part of the string (mapping of unit prefix is unclear)
346
	 *   - unit       – implicit part of the string that defaults to "1" (mapping to standardizing
347
	 *                  body is unclear)
348
	 *   - upperbound - quantity's upper bound
349
	 *   - lowerbound - quantity's lower bound
350
	 * @param string $property
351
	 * @return mixed[]|bool If it's not false it's an array with 'amount', 'unit', etc.
352
	 */
353
	public function getPropertyOfTypeQuantity( $property ) {
354
		$quantities = [];
355
		$entity = $this->getEntity( $this->id );
356
		if ( !isset( $entity['claims'][$property] ) ) {
357
			return false;
358
		}
359
		foreach ( $entity['claims'][$property] as $t ) {
360
			$quantity = $t['mainsnak']['datavalue']['value'];
361
			$unitId = substr( $quantity['unit'], strlen( $this->wikidataUrlBase ) + 1 );
362
			$quantity['unit'] = self::factory( $unitId, $this->lang, $this->cache );
363
			$quantities[] = $quantity;
364
		}
365
		return $quantities;
366
	}
367
368
	/**
369
	 * Set a single-valued text property.
370
	 * @param string $property One of the PROP_* constants.
371
	 * @param string $value The value.
372
	 */
373
	public function setPropertyOfTypeText( $property, $value ) {
374
		// First see if this property already exists, and that it is different from what's being set.
375
		$entity = $this->getEntity( $this->id );
376
		if ( !empty( $entity['claims'][$property] ) ) {
377
			// Find this language's claim (if there is one).
378
			foreach ( $entity['claims'][$property] as $claim ) {
379
				if ( $claim['mainsnak']['datavalue']['value']['language'] == $this->lang ) {
380
					// Modify this claim's text value.
381
					$titleClaim = $claim;
382
					$titleClaim['mainsnak']['datavalue']['value']['text'] = $value;
383
					$setTitleParams = [
384
						'action' => 'wbsetclaim',
385
						'claim' => \GuzzleHttp\json_encode( $titleClaim ),
386
					];
387
					continue;
388
				}
389
			}
390
		}
391
392
		// If no claim was found (and modified) above, create a new claim.
393
		if ( !isset( $setTitleParams ) ) {
394
			$setTitleParams = [
395
				'action' => 'wbcreateclaim',
396
				'entity' => $this->getId(),
397
				'property' => $property,
398
				'snaktype' => 'value',
399
				'value' => \GuzzleHttp\json_encode( [ 'text' => $value, 'language' => $this->lang ] ),
400
			];
401
		}
402
403
		// Save the property.
404
		$wdWpOauth = new WdWpOauth();
0 ignored issues
show
Bug introduced by
The type Samwilson\SimpleWikidata\WdWpOauth was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
405
		$wdWpOauth->makeCall( $setTitleParams, true );
406
407
		// Clear the cache.
408
		$this->cache->deleteItem( $this->getEntityCacheKey( $this->id ) );
409
	}
410
411
	/**
412
	 * Does this item exist?
413
	 * @return bool
414
	 */
415
	public function exists() {
416
		return $this->getId() !== false;
417
	}
418
419
	/**
420
	 * @return array With 'html' and 'title' keys.
421
	 */
422
	public function getWikipediaIntro() {
423
		$cacheKey = 'wikipedia-intro-' . $this->id . $this->lang;
424
		if ( $this->cache->hasItem( $cacheKey ) ) {
425
			return $this->cache->getItem( $cacheKey )->get();
426
		}
427
		$entity = $this->getEntity( $this->id );
428
		if ( !isset( $entity['sitelinks'] ) ) {
429
			return [];
430
		}
431
		foreach ( $entity['sitelinks'] as $sitelink ) {
432
			if ( $sitelink['site'] == $this->lang . 'wiki' ) {
433
				$api = new MediawikiApi( 'https://' . $this->lang . '.wikipedia.org/w/api.php' );
434
				$req = new SimpleRequest( 'query', [
435
					'prop' => 'extracts',
436
					'exintro' => true,
437
					'titles' => $sitelink['title'],
438
				] );
439
				$response = $api->getRequest( $req );
440
				$page = array_shift( $response['query']['pages'] );
441
				$out = [
442
					'title' => $page['title'],
443
					'html' => $page['extract'],
444
				];
445
				$cacheItem = $this->cache->getItem( $cacheKey )
446
					->expiresAfter( new DateInterval( 'P1D' ) )
447
					->set( $out );
448
				$this->cache->save( $cacheItem );
449
450
				return $out;
451
			}
452
		}
453
454
		return [];
455
	}
456
457
	/**
458
	 * Get the raw entity data from the 'wbgetentities' API call.
459
	 * @param string|null $id The Q-number.
460
	 * @param bool $ignoreCache
461
	 * @return array|bool
462
	 */
463
	public function getEntity( $id = null, $ignoreCache = false ) {
464
		$idActual = $id ?: $this->id;
465
		$cacheKey = $this->getEntityCacheKey( $idActual );
466
		if ( !$ignoreCache && $this->cache->hasItem( $cacheKey ) ) {
467
			return $this->cache->getItem( $cacheKey )->get();
468
		}
469
		$metadataRequest = new SimpleRequest( 'wbgetentities', [ 'ids' => $idActual ] );
470
		$itemResult = $this->wdApi->getRequest( $metadataRequest );
471
		if ( !isset( $itemResult['success'] ) || !isset( $itemResult['entities'][$id] ) ) {
472
			return false;
473
		}
474
		$metadata = $itemResult['entities'][$idActual];
475
		$cacheItem = $this->cache->getItem( $cacheKey )
476
			->expiresAfter( new DateInterval( 'PT10M' ) )
477
			->set( $metadata );
478
		$this->cache->save( $cacheItem );
479
		return $metadata;
480
	}
481
482
	/**
483
	 * @param string $id
484
	 *
485
	 * @return string
486
	 */
487
	protected function getEntityCacheKey( $id ) {
488
		return 'entities' . $id;
489
	}
490
}
491