parseForeignEntityId()   A
last analyzed

Complexity

Conditions 5
Paths 4

Size

Total Lines 22

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 22
rs 9.2568
c 0
b 0
f 0
cc 5
nc 4
nop 1
1
<?php
2
3
namespace Wikibase\Repo\Hooks;
4
5
use Action;
6
use HtmlArmor;
7
use MediaWiki\Interwiki\InterwikiLookup;
8
use MediaWiki\Linker\Hook\HtmlPageLinkRendererEndHook;
9
use MediaWiki\Linker\LinkRenderer;
10
use MediaWiki\Linker\LinkTarget;
11
use MediaWiki\Special\SpecialPageFactory;
12
use RequestContext;
13
use Title;
14
use Wikibase\DataModel\Entity\EntityId;
15
use Wikibase\DataModel\Entity\EntityIdParser;
16
use Wikibase\DataModel\Entity\EntityIdParsingException;
17
use Wikibase\DataModel\Entity\PropertyId;
18
use Wikibase\DataModel\Services\Lookup\LabelDescriptionLookup;
19
use Wikibase\DataModel\Services\Lookup\LabelDescriptionLookupException;
20
use Wikibase\DataModel\Services\Lookup\TermLookup;
21
use Wikibase\DataModel\Term\TermFallback;
22
use Wikibase\Lib\LanguageFallbackChainFactory;
23
use Wikibase\Lib\Store\EntityExistenceChecker;
24
use Wikibase\Lib\Store\EntityNamespaceLookup;
25
use Wikibase\Lib\Store\EntityUrlLookup;
26
use Wikibase\Lib\Store\LanguageFallbackLabelDescriptionLookup;
27
use Wikibase\Lib\Store\LinkTargetEntityIdLookup;
28
use Wikibase\Repo\FederatedProperties\FederatedPropertiesException;
29
use Wikibase\Repo\Hooks\Formatters\EntityLinkFormatterFactory;
30
use Wikibase\Repo\WikibaseRepo;
31
32
/**
33
 * Handler for the HtmlPageLinkRendererEnd hook, used to change the default link text of links to
34
 * wikibase Entity pages to the respective entity's label. This is used mainly for listings on
35
 * special pages or for edit summaries, where it is useful to see pages listed by label rather than
36
 * their entity ID.
37
 *
38
 * Label lookups are relatively expensive if done repeatedly for individual labels. If possible,
39
 * labels should be pre-loaded and buffered for later use via the HtmlPageLinkRendererEnd hook.
40
 *
41
 * @see LabelPrefetchHookHandler
42
 *
43
 * @license GPL-2.0-or-later
44
 * @author Katie Filbert < [email protected] >
45
 */
46
class HtmlPageLinkRendererEndHookHandler implements HtmlPageLinkRendererEndHook {
47
48
	/**
49
	 * @var EntityExistenceChecker
50
	 */
51
	private $entityExistenceChecker;
52
53
	/**
54
	 * @var EntityIdParser
55
	 */
56
	private $entityIdParser;
57
58
	/**
59
	 * @var TermLookup
60
	 */
61
	private $termLookup;
62
63
	/**
64
	 * @var EntityNamespaceLookup
65
	 */
66
	private $entityNamespaceLookup;
67
68
	/**
69
	 * @var InterwikiLookup
70
	 */
71
	private $interwikiLookup;
72
73
	/**
74
	 * @var callable
75
	 */
76
	private $linkFormatterFactoryCallback;
77
78
	/**
79
	 * @var SpecialPageFactory
80
	 */
81
	private $specialPageFactory;
82
83
	/**
84
	 * @var LanguageFallbackChainFactory
85
	 */
86
	private $languageFallbackChainFactory;
87
88
	/**
89
	 * @var LabelDescriptionLookup|null
90
	 */
91
	private $labelDescriptionLookup;
92
93
	/**
94
	 * @var EntityUrlLookup
95
	 */
96
	private $entityUrlLookup;
97
98
	/**
99
	 * @var LinkTargetEntityIdLookup
100
	 */
101
	private $linkTargetEntityIdLookup;
102
103
	/**
104
	 * @var string|null
105
	 */
106
	private $federatedPropertiesSourceScriptUrl;
107
108
	/**
109
	 * @var bool
110
	 */
111
	private $federatedPropertiesEnabled;
112
113
	public static function factory(
114
		InterwikiLookup $interwikiLookup,
115
		SpecialPageFactory $specialPageFactory
116
	): self {
117
		$wikibaseRepo = WikibaseRepo::getDefaultInstance();
118
		// NOTE: keep in sync with fallback chain construction in LabelPrefetchHookHandler::factory
119
		$context = RequestContext::getMain();
0 ignored issues
show
Unused Code introduced by
$context is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
120
121
		$entityIdParser = $wikibaseRepo->getEntityIdParser();
122
		return new self(
123
			$wikibaseRepo->getEntityExistenceChecker(),
124
			$entityIdParser,
125
			$wikibaseRepo->getTermLookup(),
126
			$wikibaseRepo->getEntityNamespaceLookup(),
127
			$interwikiLookup,
128
			function ( $language ) use ( $wikibaseRepo ) {
129
				return $wikibaseRepo->getEntityLinkFormatterFactory( $language );
130
			},
131
			$specialPageFactory,
132
			$wikibaseRepo->getLanguageFallbackChainFactory(),
133
			$wikibaseRepo->getEntityUrlLookup(),
134
			$wikibaseRepo->getLinkTargetEntityIdLookup(),
135
			$wikibaseRepo->getSettings()->getSetting( 'federatedPropertiesSourceScriptUrl' ),
136
			$wikibaseRepo->getSettings()->getSetting( 'federatedPropertiesEnabled' )
137
		);
138
	}
139
140
	/**
141
	 * Special page handling where we want to display meaningful link labels instead of just the items ID.
142
	 * This is only handling special pages right now and gets disabled in normal pages.
143
	 * @see https://www.mediawiki.org/wiki/Manual:Hooks/HtmlPageLinkRendererEnd
144
	 *
145
	 * @param LinkRenderer $linkRenderer
146
	 * @param LinkTarget $target
147
	 * @param bool $isKnown
148
	 * @param HtmlArmor|string|null &$text
149
	 * @param array &$extraAttribs
150
	 * @param string|null &$ret
151
	 *
152
	 * @return bool true to continue processing the link, false to use $ret directly as the HTML for the link
153
	 */
154
	public function onHtmlPageLinkRendererEnd(
155
		$linkRenderer,
156
		$target,
157
		$isKnown,
158
		&$text,
159
		&$extraAttribs,
160
		&$ret
161
	): bool {
162
		$context = RequestContext::getMain();
163
		if ( !$context->hasTitle() ) {
164
			// Short-circuit this hook if no title is
165
			// set in the main context (T131176)
166
			return true;
167
		}
168
169
		return $this->doHtmlPageLinkRendererEnd(
170
			$linkRenderer,
171
			Title::newFromLinkTarget( $target ),
172
			$text,
173
			$extraAttribs,
174
			$context,
175
			$ret
176
		);
177
	}
178
179
	public function __construct(
180
		EntityExistenceChecker $entityExistenceChecker,
181
		EntityIdParser $entityIdParser,
182
		TermLookup $termLookup,
183
		EntityNamespaceLookup $entityNamespaceLookup,
184
		InterwikiLookup $interwikiLookup,
185
		callable $linkFormatterFactoryCallback,
186
		SpecialPageFactory $specialPageFactory,
187
		LanguageFallbackChainFactory $languageFallbackChainFactory,
188
		EntityUrlLookup $entityUrlLookup,
189
		LinkTargetEntityIdLookup $linkTargetEntityIdLookup,
190
		?string $federatedPropertiesSourceScriptUrl,
191
		bool $federatedPropertiesEnabled
192
	) {
193
		$this->entityExistenceChecker = $entityExistenceChecker;
194
		$this->entityIdParser = $entityIdParser;
195
		$this->termLookup = $termLookup;
196
		$this->entityNamespaceLookup = $entityNamespaceLookup;
197
		$this->interwikiLookup = $interwikiLookup;
198
		$this->linkFormatterFactoryCallback = $linkFormatterFactoryCallback;
199
		$this->specialPageFactory = $specialPageFactory;
200
		$this->languageFallbackChainFactory = $languageFallbackChainFactory;
201
		$this->entityUrlLookup = $entityUrlLookup;
202
		$this->linkTargetEntityIdLookup = $linkTargetEntityIdLookup;
203
		$this->federatedPropertiesSourceScriptUrl = $federatedPropertiesSourceScriptUrl;
204
		$this->federatedPropertiesEnabled = $federatedPropertiesEnabled;
205
	}
206
207
	/**
208
	 * @param LinkRenderer $linkRenderer
209
	 * @param Title $target
210
	 * @param HtmlArmor|string|null &$text
211
	 * @param array &$customAttribs
212
	 * @param RequestContext $context
213
	 * @param string|null &$html
214
	 *
215
	 * @return bool true to continue processing the link, false to use $html directly for the link
216
	 */
217
	public function doHtmlPageLinkRendererEnd(
218
		LinkRenderer $linkRenderer,
219
		Title $target,
220
		&$text,
221
		array &$customAttribs,
222
		RequestContext $context,
223
		&$html = null
224
	) {
225
		$outTitle = $context->getOutput()->getTitle();
226
		$linkFormatterFactory = call_user_func( $this->linkFormatterFactoryCallback, $context->getLanguage() );
227
228
		// For good measure: Don't do anything in case the OutputPage has no Title set.
229
		if ( !$outTitle ) {
230
			return true;
231
		}
232
233
		// if custom link text is given, there is no point in overwriting it
234
		// but not if it is similar to the plain title
235
		if ( $text !== null && $target->getFullText() !== HtmlArmor::getHtml( $text ) ) {
236
			return true;
237
		}
238
239
		// Only continue on pages with edit summaries (histories / diffs) or on special pages.
240
		// Don't run this code when accessing it through the api (eg. for parsing) as the title is
241
		// set to a special page dummy in api.php, see https://phabricator.wikimedia.org/T111346
242
		if ( defined( 'MW_API' ) || !$this->shouldConvert( $outTitle, $context ) ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->shouldConvert($outTitle, $context) of type null|boolean is loosely compared to false; this is ambiguous if the boolean can be false. You might want to explicitly use !== null instead.

If an expression can have both false, and null as possible values. It is generally a good practice to always use strict comparison to clearly distinguish between those two values.

$a = canBeFalseAndNull();

// Instead of
if ( ! $a) { }

// Better use one of the explicit versions:
if ($a !== null) { }
if ($a !== false) { }
if ($a !== null && $a !== false) { }
Loading history...
243
			return true;
244
		}
245
246
		try {
247
			return $this->internalDoHtmlPageLinkRendererEnd(
248
				$linkRenderer, $target, $text, $customAttribs, $context, $linkFormatterFactory, $html );
249
		} catch ( FederatedPropertiesException $ex ) {
250
			$this->federatedPropsDegradedDoHtmlPageLinkRendererEnd( $target, $text, $customAttribs );
251
252
			return true;
253
		}
254
	}
255
256
	/**
257
	 * Hook handling logic for the HtmlPageLinkRendererEnd hook in case federated properties are
258
	 * enabled, but access to the source wiki failed.
259
	 *
260
	 * @param Title $linkTarget
261
	 * @param HtmlArmor|string|null &$text
262
	 * @param array &$customAttribs
263
	 */
264
	private function federatedPropsDegradedDoHtmlPageLinkRendererEnd(
265
		LinkTarget $linkTarget,
266
		&$text,
267
		array &$customAttribs
268
		): void {
269
		$entityId = $this->linkTargetEntityIdLookup->getEntityId( $linkTarget );
270
		$text = $entityId->getSerialization();
271
272
		// This is a hack and could probably use the TitleIsAlwaysKnown hook instead.
273
		// Filter out the "new" class to avoid red links for existing entities.
274
		$customAttribs['class'] = $this->removeNewClass( $customAttribs['class'] ?? '' );
275
		// Use the entity id as title, as we can't lookup the label
276
		$customAttribs['title'] = $entityId->getSerialization();
277
278
		$customAttribs['href'] = $this->federatedPropertiesSourceScriptUrl .
279
			'index.php?title=Special:EntityData/' . urlencode( $entityId->getSerialization() );
280
	}
281
282
	/**
283
	 * Parts of the hook handling logic for the HtmlPageLinkRendererEnd hook that potentially
284
	 * interact with entity storage.
285
	 *
286
	 * @param LinkRenderer $linkRenderer
287
	 * @param Title $target
288
	 * @param HtmlArmor|string|null &$text
289
	 * @param array &$customAttribs
290
	 * @param RequestContext $context
291
	 * @param EntityLinkFormatterFactory $linkFormatterFactory
292
	 * @param string|null &$html
293
	 *
294
	 * @return bool true to continue processing the link, false to use $html directly for the link
295
	 */
296
	private function internalDoHtmlPageLinkRendererEnd(
297
		LinkRenderer $linkRenderer,
298
		Title $target,
299
		&$text,
300
		array &$customAttribs,
301
		RequestContext $context,
302
		EntityLinkFormatterFactory $linkFormatterFactory,
303
		&$html = null
304
	) {
305
		$out = $context->getOutput();
306
307
		$foreignEntityId = $this->parseForeignEntityId( $target );
308
		if ( !$foreignEntityId && !$this->entityNamespaceLookup->isEntityNamespace( $target->getNamespace() )
309
		) {
310
			return true;
311
		}
312
313
		$targetText = $target->getText();
314
315
		$entityId = $this->linkTargetEntityIdLookup->getEntityId( $target );
316
		if ( !$entityId ) {
317
			// Handle "fake" titles for new entities as generated by
318
			// EditEntity::getContextForEditFilter(). For instance, a link to Property:NewProperty
319
			// would be replaced by a link to Special:NewProperty. This is useful in logs,
320
			// to indicate that the logged action occurred while creating an entity.
321
			if ( $this->specialPageFactory->exists( $targetText ) ) {
322
				$target = Title::makeTitle( NS_SPECIAL, $targetText );
323
				$html = $linkRenderer->makeKnownLink( $target );
324
				return false;
325
			}
326
327
			return true;
328
		}
329
330
		if ( $target->isRedirect() ) {
331
			$customAttribs['href'] = wfAppendQuery( $this->entityUrlLookup->getLinkUrl( $entityId ), [ 'redirect' => 'no' ] );
332
		} else {
333
			$customAttribs['href'] = $this->entityUrlLookup->getLinkUrl( $entityId );
334
		}
335
336
		if ( !$this->entityExistenceChecker->exists( $entityId ) ) {
337
			// The link points to a non-existing entity.
338
			return true;
339
		}
340
341
		// This is a hack and could probably use the TitleIsAlwaysKnown hook instead.
342
		// Filter out the "new" class to avoid red links for existing entities.
343
		$customAttribs['class'] = $this->removeNewClass( $customAttribs['class'] ?? '' );
344
345
		$labelDescriptionLookup = $this->getLabelDescriptionLookup( $context );
346
		try {
347
			$label = $labelDescriptionLookup->getLabel( $entityId );
348
			$description = $labelDescriptionLookup->getDescription( $entityId );
349
		} catch ( LabelDescriptionLookupException $ex ) {
350
			return true;
351
		}
352
353
		$labelData = $this->termFallbackToTermData( $label );
354
		$descriptionData = $this->termFallbackToTermData( $description );
355
356
		$linkFormatter = $linkFormatterFactory->getLinkFormatter( $entityId->getEntityType() );
357
		$text = new HtmlArmor( $linkFormatter->getHtml( $entityId, $labelData ) );
0 ignored issues
show
Bug introduced by
It seems like $labelData defined by $this->termFallbackToTermData($label) on line 353 can also be of type array<string,string,{"va...","language":"string"}>; however, Wikibase\Repo\Hooks\Form...inkFormatter::getHtml() does only seem to accept null|array<integer,string>, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
358
359
		$customAttribs['title'] = $linkFormatter->getTitleAttribute(
360
			$entityId,
361
			$labelData,
0 ignored issues
show
Bug introduced by
It seems like $labelData defined by $this->termFallbackToTermData($label) on line 353 can also be of type array<string,string,{"va...","language":"string"}>; however, Wikibase\Repo\Hooks\Form...er::getTitleAttribute() does only seem to accept null|array<integer,string>, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
362
			$descriptionData
0 ignored issues
show
Bug introduced by
It seems like $descriptionData defined by $this->termFallbackToTermData($description) on line 354 can also be of type array<string,string,{"va...","language":"string"}>; however, Wikibase\Repo\Hooks\Form...er::getTitleAttribute() does only seem to accept null|array<integer,string>, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
363
		);
364
365
		$fragment = $linkFormatter->getFragment( $entityId, $target->getFragment() );
366
		$target->setFragment( '#' . $fragment );
367
368
		// add wikibase styles in all cases, so we can format the link properly:
369
		$out->addModuleStyles( [ 'wikibase.common' ] );
370
		if ( $this->federatedPropertiesEnabled && $entityId instanceof PropertyId ) {
371
			$customAttribs [ 'class' ] = $customAttribs [ 'class' ] == '' ? 'fedprop' : $customAttribs [ 'class' ] . ' fedprop';
372
			$out->addModules( 'wikibase.federatedPropertiesLeavingSiteNotice' );
373
		}
374
		return true;
375
	}
376
377
	/**
378
	 * Remove the new class from a space separated list of classes.
379
	 *
380
	 * @param string $classes
381
	 * @return string
382
	 */
383
	private function removeNewClass( string $classes ): string {
384
		return implode( ' ', array_filter(
385
			preg_split( '/\s+/', $classes ),
386
			function ( $class ) {
387
				return $class !== 'new';
388
			}
389
		) );
390
	}
391
392
	/**
393
	 * @param TermFallback|null $term
394
	 * @return string[]|null
395
	 */
396
	private function termFallbackToTermData( TermFallback $term = null ) {
397
		if ( $term ) {
398
			return [
399
				'value' => $term->getText(),
400
				'language' => $term->getActualLanguageCode(),
401
			];
402
		}
403
404
		return null;
405
	}
406
407
	/**
408
	 * @param LinkTarget $target
409
	 *
410
	 * @return EntityId|null
411
	 */
412
	private function parseForeignEntityId( LinkTarget $target ) {
413
		$interwiki = $target->getInterwiki();
414
415
		if ( $interwiki === '' || !$this->interwikiLookup->isValidInterwiki( $interwiki ) ) {
416
			return null;
417
		}
418
419
		$idPart = $this->extractForeignIdString( $target );
420
421
		$idPrefix = '';
422
423
		if ( $idPart !== null ) {
424
			try {
425
				// FIXME: This assumes repository name is equal to interwiki. This assumption might
426
				// become invalid
427
				return $this->entityIdParser->parse( EntityId::joinSerialization( [ $idPrefix, '', $idPart ] ) );
428
			} catch ( EntityIdParsingException $ex ) {
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
429
			}
430
		}
431
432
		return null;
433
	}
434
435
	/**
436
	 * Should be given an already confirmed valid interwiki link that uses Special:EntityPage
437
	 * to link to an entity on a remote Wikibase
438
	 */
439
	private function extractForeignIdString( LinkTarget $linkTarget ): ?string {
440
		return $this->extractForeignIdStringMainNs( $linkTarget ) ?: $this->extractForeignIdStringSpecialNs( $linkTarget );
441
	}
442
443
	private function extractForeignIdStringMainNs( LinkTarget $linkTarget ): ?string {
444
		if ( $linkTarget->getNamespace() !== NS_MAIN ) {
445
			return null;
446
		}
447
448
		$linkTargetChangedNamespace = Title::newFromText( $linkTarget->getText() );
449
450
		if ( $linkTargetChangedNamespace === null ) {
451
			return null;
452
		}
453
454
		return $this->extractForeignIdStringSpecialNs( $linkTargetChangedNamespace );
455
	}
456
457
	private function extractForeignIdStringSpecialNs( LinkTarget $linkTarget ): ?string {
458
		// FIXME: This encodes knowledge from EntityContentFactory::getTitleForId
459
		$prefix = 'EntityPage/';
460
		$prefixLength = strlen( $prefix );
461
		$pageName = $linkTarget->getText();
462
463
		if ( $linkTarget->getNamespace() === NS_SPECIAL && strncmp( $pageName, $prefix, $prefixLength ) === 0 ) {
464
			return substr( $pageName, $prefixLength );
465
		}
466
467
		return null;
468
	}
469
470
	/**
471
	 * Whether we should try to convert links on this page.
472
	 * This caches that result within a static variable,
473
	 * thus it can't change (except in phpunit tests).
474
	 *
475
	 * @param Title|null $currentTitle
476
	 * @param RequestContext $context
477
	 *
478
	 * @return bool
479
	 */
480
	private function shouldConvert( ?Title $currentTitle, RequestContext $context ) {
481
		static $shouldConvert = null;
482
		if ( $shouldConvert !== null && !defined( 'MW_PHPUNIT_TEST' ) ) {
483
			return $shouldConvert;
484
		}
485
486
		$actionName = Action::getActionName( $context );
487
		 // This is how Article detects diffs
488
		$isDiff = $actionName === 'view' && $context->getRequest()->getCheck( 'diff' );
489
490
		// Only continue on pages with edit summaries (histories / diffs) or on special pages.
491
		if (
492
			( $currentTitle === null || !$currentTitle->isSpecialPage() )
493
			&& $actionName !== 'history'
494
			&& !$isDiff
495
		) {
496
			// Note: this may not work right with special page transclusion. If $out->getTitle()
497
			// doesn't return the transcluded special page's title, the transcluded text will
498
			// not have entity IDs resolved to labels.
499
			$shouldConvert = false;
500
			return false;
501
		}
502
503
		$shouldConvert = true;
504
		return true;
505
	}
506
507
	private function getLabelDescriptionLookup( RequestContext $context ): LabelDescriptionLookup {
508
		if ( $this->labelDescriptionLookup === null ) {
509
			$this->labelDescriptionLookup = new LanguageFallbackLabelDescriptionLookup(
510
				$this->termLookup,
511
				$this->languageFallbackChainFactory->newFromContext( $context )
512
			);
513
		}
514
515
		return $this->labelDescriptionLookup;
516
	}
517
518
}
519