Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
44.92% |
177 / 394 |
|
28.00% |
7 / 25 |
CRAP | |
0.00% |
0 / 1 |
WikibaseMediaInfoHooks | |
44.92% |
177 / 394 |
|
28.00% |
7 / 25 |
1205.36 | |
0.00% |
0 / 1 |
onWikibaseRepoEntityNamespaces | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
onWikibaseEntityTypes | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
onParserOutputPostCacheTransform | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
onRegistration | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
6 | |||
isMediaInfoPage | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
onBeforePageDisplay | |
79.41% |
27 / 34 |
|
0.00% |
0 / 1 |
7.43 | |||
doBeforePageDisplay | |
75.93% |
41 / 54 |
|
0.00% |
0 / 1 |
14.01 | |||
generateWbTermsLanguages | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
generateWbMonolingualTextLanguages | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
tabifyStructuredData | |
24.39% |
20 / 82 |
|
0.00% |
0 / 1 |
21.56 | |||
extractStructuredDataHtml | |
64.71% |
11 / 17 |
|
0.00% |
0 / 1 |
2.18 | |||
createEmptyStructuredData | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
1 | |||
deleteMediaInfoData | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
getProtectionMsg | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
6 | |||
onGetEntityByLinkedTitleLookup | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
onGetEntityContentModelForTitle | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
12 | |||
onCirrusSearchProfileService | |
93.75% |
30 / 32 |
|
0.00% |
0 / 1 |
6.01 | |||
onCirrusSearchRegisterFullTextQueryClassifiers | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
onGetPreferences | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
onScribuntoExternalLibraries | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
20 | |||
onRevisionUndeleted | |
0.00% |
0 / 55 |
|
0.00% |
0 / 1 |
90 | |||
onArticleUndelete | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
12 | |||
onSidebarBeforeOutput | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
12 | |||
onCirrusSearchAddQueryFeatures | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
onMultiContentSave | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 |
1 | <?php |
2 | |
3 | namespace Wikibase\MediaInfo; |
4 | |
5 | use CirrusSearch\Parser\ParsedQueryClassifiersRepository; |
6 | use CirrusSearch\Profile\SearchProfileService; |
7 | use ExtensionRegistry; |
8 | use MediaWiki\CommentStore\CommentStoreComment; |
9 | use MediaWiki\Config\ConfigException; |
10 | use MediaWiki\Context\RequestContext; |
11 | use MediaWiki\Hook\ParserOutputPostCacheTransformHook; |
12 | use MediaWiki\Hook\SidebarBeforeOutputHook; |
13 | use MediaWiki\Html\Html; |
14 | use MediaWiki\MediaWikiServices; |
15 | use MediaWiki\Output\Hook\BeforePageDisplayHook; |
16 | use MediaWiki\Output\OutputPage; |
17 | use MediaWiki\Page\Hook\ArticleUndeleteHook; |
18 | use MediaWiki\Page\Hook\RevisionUndeletedHook; |
19 | use MediaWiki\Parser\ParserOutput; |
20 | use MediaWiki\Preferences\Hook\GetPreferencesHook; |
21 | use MediaWiki\Revision\RenderedRevision; |
22 | use MediaWiki\Revision\RevisionRecord; |
23 | use MediaWiki\Revision\SlotRecord; |
24 | use MediaWiki\Status\Status; |
25 | use MediaWiki\Storage\BlobStore; |
26 | use MediaWiki\Storage\Hook\MultiContentSaveHook; |
27 | use MediaWiki\Title\Title; |
28 | use MediaWiki\User\TempUser\TempUserConfig; |
29 | use MediaWiki\User\User; |
30 | use MediaWiki\User\UserIdentity; |
31 | use OOUI\HtmlSnippet; |
32 | use OOUI\IndexLayout; |
33 | use OOUI\PanelLayout; |
34 | use OOUI\TabPanelLayout; |
35 | use Skin; |
36 | use Wikibase\Client\WikibaseClient; |
37 | use Wikibase\DataModel\Entity\NumericPropertyId; |
38 | use Wikibase\DataModel\Services\Lookup\PropertyDataTypeLookupException; |
39 | use Wikibase\DataModel\Statement\StatementGuid; |
40 | use Wikibase\Lib\LanguageFallbackChainFactory; |
41 | use Wikibase\Lib\Store\EntityByLinkedTitleLookup; |
42 | use Wikibase\Lib\UserLanguageLookup; |
43 | use Wikibase\MediaInfo\Content\MediaInfoContent; |
44 | use Wikibase\MediaInfo\DataAccess\Scribunto\Scribunto_LuaWikibaseMediaInfoEntityLibrary; |
45 | use Wikibase\MediaInfo\DataAccess\Scribunto\Scribunto_LuaWikibaseMediaInfoLibrary; |
46 | use Wikibase\MediaInfo\DataModel\MediaInfo; |
47 | use Wikibase\MediaInfo\Search\Feature\CustomMatchFeature; |
48 | use Wikibase\MediaInfo\Search\MediaSearchASTClassifier; |
49 | use Wikibase\MediaInfo\Search\MediaSearchQueryBuilder; |
50 | use Wikibase\MediaInfo\Services\MediaInfoByLinkedTitleLookup; |
51 | use Wikibase\MediaInfo\Services\MediaInfoServices; |
52 | use Wikibase\Repo\BabelUserLanguageLookup; |
53 | use Wikibase\Repo\Content\EntityInstanceHolder; |
54 | use Wikibase\Repo\MediaWikiLocalizedTextProvider; |
55 | use Wikibase\Repo\ParserOutput\DispatchingEntityViewFactory; |
56 | use Wikibase\Repo\WikibaseRepo; |
57 | |
58 | /** |
59 | * MediaWiki hook handlers for the Wikibase MediaInfo extension. |
60 | * |
61 | * @license GPL-2.0-or-later |
62 | * @author Bene* < benestar.wikimedia@gmail.com > |
63 | */ |
64 | class WikibaseMediaInfoHooks implements |
65 | BeforePageDisplayHook, |
66 | ParserOutputPostCacheTransformHook, |
67 | GetPreferencesHook, |
68 | RevisionUndeletedHook, |
69 | ArticleUndeleteHook, |
70 | SidebarBeforeOutputHook, |
71 | MultiContentSaveHook |
72 | { |
73 | |
74 | public const MEDIAINFO_SLOT_HEADER_PLACEHOLDER = '<mediainfoslotheader />'; |
75 | |
76 | /** |
77 | * Hook to register the MediaInfo entity namespaces for EntityNamespaceLookup. |
78 | * |
79 | * @param int[] &$entityNamespacesSetting |
80 | */ |
81 | public static function onWikibaseRepoEntityNamespaces( &$entityNamespacesSetting ) { |
82 | // Tell Wikibase where to put our entity content. |
83 | $entityNamespacesSetting[ MediaInfo::ENTITY_TYPE ] = NS_FILE . '/' . MediaInfo::ENTITY_TYPE; |
84 | } |
85 | |
86 | /** |
87 | * Adds the definition of the media info entity type to the definitions array Wikibase uses. |
88 | * |
89 | * @see WikibaseMediaInfo.entitytypes.php |
90 | * |
91 | * @note This is bootstrap code, it is executed for EVERY request. Avoid instantiating |
92 | * objects or loading classes here! |
93 | * |
94 | * @param array[] &$entityTypeDefinitions |
95 | */ |
96 | public static function onWikibaseEntityTypes( array &$entityTypeDefinitions ) { |
97 | $entityTypeDefinitions = array_merge( |
98 | $entityTypeDefinitions, |
99 | require __DIR__ . '/../WikibaseMediaInfo.entitytypes.php' |
100 | ); |
101 | } |
102 | |
103 | /** |
104 | * The placeholder mw:slotheader is replaced by default with the name of the slot |
105 | * |
106 | * Replace it with a different placeholder so we can replace it with a message later |
107 | * on in onBeforePageDisplay() - can't replace it here because RequestContext (and therefore |
108 | * the language) is not available |
109 | * |
110 | * Won't be necessary when T205444 is done |
111 | * |
112 | * @see https://phabricator.wikimedia.org/T205444 |
113 | * @see onBeforePageDisplay() |
114 | * |
115 | * @param ParserOutput $parserOutput |
116 | * @param string &$text |
117 | * @param array &$options |
118 | */ |
119 | public function onParserOutputPostCacheTransform( |
120 | $parserOutput, |
121 | &$text, |
122 | &$options |
123 | ): void { |
124 | $text = str_replace( |
125 | '<mw:slotheader>mediainfo</mw:slotheader>', |
126 | self::MEDIAINFO_SLOT_HEADER_PLACEHOLDER, |
127 | $text |
128 | ); |
129 | } |
130 | |
131 | public static function onRegistration() { |
132 | if ( !ExtensionRegistry::getInstance()->isLoaded( 'WikibaseRepository' ) ) { |
133 | // HACK: Declaring a dependency on Wikibase in extension.json |
134 | // requires more work. See T258822. |
135 | throw new \ExtensionDependencyError( [ [ |
136 | 'msg' => 'WikibaseMediaInfo requires Wikibase to be installed.', |
137 | 'type' => 'missing-phpExtension', |
138 | 'missing' => 'Wikibase', |
139 | ] ] ); |
140 | } |
141 | } |
142 | |
143 | /** |
144 | * @param Title|null $title |
145 | * @return bool |
146 | */ |
147 | public static function isMediaInfoPage( Title $title = null ) { |
148 | // Check if the page exists and the page is a file |
149 | return $title !== null && |
150 | $title->exists() && |
151 | $title->inNamespace( NS_FILE ); |
152 | } |
153 | |
154 | /** |
155 | * Replace mediainfo-specific placeholders (if any), move structured data, add data and modules |
156 | * |
157 | * @param OutputPage $out |
158 | * @param \Skin $skin |
159 | * @throws ConfigException |
160 | * @throws \OOUI\Exception |
161 | */ |
162 | public function onBeforePageDisplay( $out, $skin ): void { |
163 | $config = MediaWikiServices::getInstance()->getMainConfig(); |
164 | |
165 | // Hide any MediaInfo content and UI on a page, if the target page is a redirect. |
166 | if ( $out->getTitle()->isRedirect() ) { |
167 | $out = self::deleteMediaInfoData( $out ); |
168 | return; |
169 | } |
170 | |
171 | $imgTitle = $out->getTitle(); |
172 | |
173 | $isMediaInfoPage = static::isMediaInfoPage( $imgTitle ) && |
174 | // … the page view is a read |
175 | $out->getActionName() === 'view'; |
176 | |
177 | $properties = $config->get( 'MediaInfoProperties' ); |
178 | $propertyTypes = []; |
179 | $propertyTitles = []; |
180 | foreach ( $properties as $name => $property ) { |
181 | try { |
182 | // some properties/statements may have custom titles, in addition to their property |
183 | // label, to help clarify what data is expected there |
184 | // possible messages include: |
185 | // wikibasemediainfo-statements-title-depicts |
186 | $message = wfMessage( 'wikibasemediainfo-statements-title-' . ( $name ?: '' ) ); |
187 | if ( $message->exists() ) { |
188 | $propertyTitles[$property] = $message->text(); |
189 | } |
190 | |
191 | // get data type for values associated with this property |
192 | $propertyTypes[$property] = WBMIHooksHelper::getPropertyType( new NumericPropertyId( $property ) ); |
193 | } catch ( PropertyDataTypeLookupException $e ) { |
194 | // ignore invalid properties... |
195 | } |
196 | } |
197 | |
198 | $hooksObject = new self(); |
199 | $hooksObject->doBeforePageDisplay( |
200 | $out, |
201 | $skin, |
202 | $isMediaInfoPage, |
203 | new BabelUserLanguageLookup(), |
204 | WikibaseRepo::getEntityViewFactory(), |
205 | MediaWikiServices::getInstance()->getTempUserConfig(), |
206 | [ |
207 | 'wbmiDefaultProperties' => array_values( $properties ), |
208 | 'wbmiPropertyTitles' => $propertyTitles, |
209 | 'wbmiPropertyTypes' => $propertyTypes, |
210 | 'wbmiRepoApiUrl' => wfScript( 'api' ), |
211 | 'wbmiHelpUrls' => $config->get( 'MediaInfoHelpUrls' ), |
212 | 'wbmiExternalEntitySearchBaseUri' => $config->get( 'MediaInfoExternalEntitySearchBaseUri' ), |
213 | 'wbmiSupportedDataTypes' => $config->get( 'MediaInfoSupportedDataTypes' ), |
214 | ] |
215 | ); |
216 | } |
217 | |
218 | /** |
219 | * @param OutputPage $out |
220 | * @param \Skin $skin |
221 | * @param bool $isMediaInfoPage |
222 | * @param UserLanguageLookup $userLanguageLookup |
223 | * @param DispatchingEntityViewFactory $entityViewFactory |
224 | * @param TempUserConfig $tempUserConfig |
225 | * @param array $jsConfigVars Variables to expose to JavaScript |
226 | * @throws \OOUI\Exception |
227 | */ |
228 | public function doBeforePageDisplay( |
229 | $out, |
230 | $skin, |
231 | $isMediaInfoPage, |
232 | UserLanguageLookup $userLanguageLookup, |
233 | DispatchingEntityViewFactory $entityViewFactory, |
234 | TempUserConfig $tempUserConfig, |
235 | array $jsConfigVars = [] |
236 | ) { |
237 | // Site-wide config |
238 | $modules = []; |
239 | $moduleStyles = []; |
240 | |
241 | if ( $isMediaInfoPage ) { |
242 | OutputPage::setupOOUI(); |
243 | $out = $this->tabifyStructuredData( $out, $entityViewFactory ); |
244 | $out->setPreventClickjacking( true ); |
245 | $imgTitle = $out->getTitle(); |
246 | $entityId = MediaInfoServices::getMediaInfoIdLookup()->getEntityIdForTitle( $imgTitle ); |
247 | |
248 | $entityLookup = WikibaseRepo::getEntityLookup(); |
249 | $entityRevisionId = $entityLookup->hasEntity( $entityId ) ? $imgTitle->getLatestRevID() : null; |
250 | $entity = $entityLookup->getEntity( $entityId ); |
251 | $serializer = WikibaseRepo::getAllTypesEntitySerializer(); |
252 | $entityData = ( $entity ? $serializer->serialize( $entity ) : null ); |
253 | |
254 | $existingPropertyTypes = []; |
255 | if ( $entity instanceof MediaInfo ) { |
256 | foreach ( $entity->getStatements() as $statement ) { |
257 | $propertyId = $statement->getPropertyId(); |
258 | try { |
259 | $existingPropertyTypes[$propertyId->getSerialization()] = |
260 | WBMIHooksHelper::getPropertyType( $propertyId ); |
261 | } catch ( PropertyDataTypeLookupException $e ) { |
262 | // ignore when property can't be found - it likely no longer exists; |
263 | // either way, we can't find what datatype is has, so there's no |
264 | // useful data to be gathered here |
265 | } |
266 | foreach ( $statement->getQualifiers() as $qualifierSnak ) { |
267 | $qualifierPropertyId = $qualifierSnak->getPropertyId(); |
268 | try { |
269 | $existingPropertyTypes[$qualifierPropertyId->getSerialization()] = |
270 | WBMIHooksHelper::getPropertyType( $qualifierPropertyId ); |
271 | } catch ( PropertyDataTypeLookupException $e ) { |
272 | // ignore when property can't be found - it likely no longer exists; |
273 | // either way, we can't find what datatype is has, so there's no |
274 | // useful data to be gathered here |
275 | } |
276 | } |
277 | } |
278 | } |
279 | |
280 | $modules[] = 'wikibase.mediainfo.filePageDisplay'; |
281 | $moduleStyles[] = 'wikibase.mediainfo.filepage.styles'; |
282 | $moduleStyles[] = 'wikibase.mediainfo.statements.styles'; |
283 | |
284 | $jsConfigVars = array_merge( $jsConfigVars, [ |
285 | 'wbUserSpecifiedLanguages' => $userLanguageLookup->getAllUserLanguages( |
286 | $out->getUser() |
287 | ), |
288 | 'wbCurrentRevision' => $entityRevisionId, |
289 | 'wbEntityId' => $entityId->getSerialization(), |
290 | 'wbEntity' => $entityData, |
291 | 'wbmiMinCaptionLength' => 5, |
292 | 'wbmiMaxCaptionLength' => WBMIHooksHelper::getMaxCaptionLength(), |
293 | 'wbmiParsedMessageAnonEditWarning' => $out->msg( |
294 | 'anoneditwarning', |
295 | // Log-in link |
296 | '{{fullurl:Special:UserLogin|returnto={{FULLPAGENAMEE}}}}', |
297 | // Sign-up link |
298 | '{{fullurl:Special:UserLogin/signup|returnto={{FULLPAGENAMEE}}}}' |
299 | )->parseAsBlock(), |
300 | 'wbmiProtectionMsg' => $this->getProtectionMsg( $out ), |
301 | 'wbmiShowIPEditingWarning' => !$tempUserConfig->isEnabled(), |
302 | // extend/override wbmiPropertyTypes (which already contains a property type map |
303 | // for all default properties) with property types for existing statements |
304 | 'wbmiPropertyTypes' => $jsConfigVars['wbmiPropertyTypes'] + $existingPropertyTypes, |
305 | ] ); |
306 | |
307 | if ( ExtensionRegistry::getInstance()->isLoaded( 'WikibaseQualityConstraints' ) ) { |
308 | // Don't display constraints violations unless the user is logged in and can edit |
309 | if ( !$out->getUser()->isAnon() && $out->getUser()->probablyCan( 'edit', $imgTitle ) ) { |
310 | $modules[] = 'wikibase.quality.constraints.ui'; |
311 | $modules[] = 'wikibase.quality.constraints.icon'; |
312 | $jsConfigVars['wbmiDoConstraintCheck'] = true; |
313 | } |
314 | } |
315 | } |
316 | |
317 | $out->addJsConfigVars( $jsConfigVars ); |
318 | $out->addModuleStyles( $moduleStyles ); |
319 | $out->addModules( $modules ); |
320 | } |
321 | |
322 | /** |
323 | * Generate the list of languages that can be used in terms. |
324 | * This will be exposed as part of a ResourceLoader package module. |
325 | * |
326 | * @return string[] language codes as keys, autonyms as values |
327 | */ |
328 | public static function generateWbTermsLanguages() { |
329 | $services = MediaWikiServices::getInstance(); |
330 | $allLanguages = $services->getLanguageNameUtils()->getLanguageNames(); |
331 | $termsLanguages = WikibaseRepo::getTermsLanguages( $services ) |
332 | ->getLanguages(); |
333 | |
334 | // use <code> => <name> for known languages; and add |
335 | // <code> => <code> for all additional acceptable language |
336 | // (that are not known to mediawiki) |
337 | return $allLanguages + array_combine( $termsLanguages, $termsLanguages ); |
338 | } |
339 | |
340 | /** |
341 | * Generate the list of languages that can be used in monolingual text. |
342 | * This will be exposed as part of a ResourceLoader package module. |
343 | * |
344 | * @return string[] language codes as keys, autonyms as values |
345 | */ |
346 | public static function generateWbMonolingualTextLanguages() { |
347 | $services = MediaWikiServices::getInstance(); |
348 | $allLanguages = $services->getLanguageNameUtils()->getLanguageNames(); |
349 | $monolingualTextLanguages = WikibaseRepo::getMonolingualTextLanguages( $services ) |
350 | ->getLanguages(); |
351 | |
352 | // use <code> => <name> for known languages; and add |
353 | // <code> => <code> for all additional acceptable language |
354 | // (that are not known to mediawiki) |
355 | return $allLanguages + array_combine( $monolingualTextLanguages, $monolingualTextLanguages ); |
356 | } |
357 | |
358 | /** |
359 | * @param OutputPage $out |
360 | * @param DispatchingEntityViewFactory $entityViewFactory |
361 | * @return OutputPage $out |
362 | * @throws \OOUI\Exception |
363 | */ |
364 | private function tabifyStructuredData( |
365 | OutputPage $out, |
366 | DispatchingEntityViewFactory $entityViewFactory |
367 | ) { |
368 | $html = $out->getHTML(); |
369 | $out->clearHTML(); |
370 | $textProvider = new MediaWikiLocalizedTextProvider( $out->getLanguage() ); |
371 | |
372 | // Remove the slot header, as it's made redundant by the tabs |
373 | $html = preg_replace( WBMIHooksHelper::getStructuredDataHeaderRegex(), '', $html ); |
374 | |
375 | // Snip out out the structured data sections ($captions, $statements) |
376 | $extractedHtml = $this->extractStructuredDataHtml( $html, $out, $entityViewFactory ); |
377 | if ( preg_match( |
378 | WBMIHooksHelper::getMediaInfoCaptionsRegex(), |
379 | $extractedHtml['structured'], |
380 | $matches |
381 | ) ) { |
382 | $captions = $matches[1]; |
383 | } |
384 | |
385 | if ( preg_match( |
386 | WBMIHooksHelper::getMediaInfoStatementsRegex(), |
387 | $extractedHtml['structured'], |
388 | $matches |
389 | ) ) { |
390 | $statements = $matches[1]; |
391 | } |
392 | |
393 | if ( empty( $captions ) || empty( $statements ) ) { |
394 | // Something has gone wrong - markup should have been created for empty/missing data. |
395 | // Return the html unmodified (this should not be reachable, it's here just in case) |
396 | $out->addHTML( $html ); |
397 | return $out; |
398 | } |
399 | |
400 | // Add a title to statements for no-js |
401 | $statements = Html::element( |
402 | 'h2', |
403 | [ 'class' => 'wbmi-structured-data-header' ], |
404 | $textProvider->get( 'wikibasemediainfo-filepage-structured-data-heading' ) |
405 | ) . $statements; |
406 | |
407 | // Tab 1 will be everything after (and including) <div id="mw-imagepage-content"> |
408 | // except for children of #mw-imagepage-content before .mw-parser-output (e.g. diffs) |
409 | $tab1ContentRegex = '/(<div\b[^>]*\bid=(\'|")mw-imagepage-content\\2[^>]*>)(.*)' . |
410 | '(<div\b[^>]*\bclass=(\'|")[^\'"]+mw-parser-output\\5[^>]*>.*$)/is'; |
411 | // Snip out the div, and replace with a placeholder |
412 | if ( |
413 | preg_match( |
414 | $tab1ContentRegex, |
415 | $extractedHtml['unstructured'], |
416 | $matches |
417 | ) |
418 | ) { |
419 | $tab1Html = $matches[1] . $matches[4]; |
420 | |
421 | // insert captions at the beginning of Tab1 |
422 | $tab1Html = $captions . $tab1Html; |
423 | |
424 | $html = preg_replace( |
425 | $tab1ContentRegex, |
426 | '$3<WBMI_TABS_PLACEHOLDER>', |
427 | $extractedHtml['unstructured'] |
428 | ); |
429 | // Add a title for no-js |
430 | $tab1Html = Html::element( |
431 | 'h2', |
432 | [ 'class' => 'wbmi-captions-header' ], |
433 | $textProvider->get( 'wikibasemediainfo-filepage-captions-title' ) |
434 | ) . $tab1Html; |
435 | } else { |
436 | // If the div isn't found, something has gone wrong - return unmodified html |
437 | // (this should not be reachable, it's here just in case) |
438 | $out->addHTML( $html ); |
439 | return $out; |
440 | } |
441 | |
442 | // Prepare tab panels |
443 | $tab1 = new TabPanelLayout( |
444 | 'wikiTextPlusCaptions', |
445 | [ |
446 | 'classes' => [ 'wbmi-tab' ], |
447 | 'label' => $textProvider->get( 'wikibasemediainfo-filepage-fileinfo-heading' ), |
448 | 'content' => new HtmlSnippet( $tab1Html ), |
449 | 'expanded' => false, |
450 | ] |
451 | ); |
452 | $tab2 = new TabPanelLayout( |
453 | 'statements', |
454 | [ |
455 | 'classes' => [ 'wbmi-tab' ], |
456 | 'label' => $textProvider->get( 'wikibasemediainfo-filepage-structured-data-heading' ), |
457 | 'content' => new HtmlSnippet( $statements ), |
458 | 'expanded' => false, |
459 | ] |
460 | ); |
461 | $tabs = new IndexLayout( [ |
462 | 'autoFocus' => false, |
463 | 'classes' => [ 'wbmi-tabs' ], |
464 | 'expanded' => false, |
465 | 'framed' => false, |
466 | ] ); |
467 | $tabs->addTabPanels( [ $tab1, $tab2 ] ); |
468 | // This shouldn't be needed, as this is the first tab, but it is (T340803) |
469 | $tabs->setTabPanel( 'wikiTextPlusCaptions' ); |
470 | $tabs->setInfusable( true ); |
471 | |
472 | $tabWrapper = new PanelLayout( [ |
473 | 'classes' => [ 'wbmi-tabs-container' ], |
474 | 'content' => $tabs, |
475 | 'expanded' => false, |
476 | 'framed' => false, |
477 | ] ); |
478 | |
479 | // Replace the placeholder with the tabs |
480 | $html = str_replace( '<WBMI_TABS_PLACEHOLDER>', $tabWrapper, $html ); |
481 | |
482 | $out->addHTML( $html ); |
483 | return $out; |
484 | } |
485 | |
486 | /** |
487 | * Returns an array with 2 elements |
488 | * [ |
489 | * 'unstructured' => html output with structured data removed |
490 | * 'structured' => structured data as html ... if there is no structured data an empty |
491 | * mediainfoview is used to create the html |
492 | * ] |
493 | * |
494 | * @param string $html |
495 | * @param OutputPage $out |
496 | * @param DispatchingEntityViewFactory $entityViewFactory |
497 | * @return string[] |
498 | */ |
499 | private function extractStructuredDataHtml( |
500 | $html, |
501 | OutputPage $out, |
502 | DispatchingEntityViewFactory $entityViewFactory |
503 | ) { |
504 | if ( preg_match( |
505 | WBMIHooksHelper::getMediaInfoViewRegex(), |
506 | $html, |
507 | $matches |
508 | ) ) { |
509 | $structured = $matches[1]; |
510 | $unstructured = preg_replace( |
511 | WBMIHooksHelper::getMediaInfoViewRegex(), |
512 | '', |
513 | $html |
514 | ); |
515 | } else { |
516 | $unstructured = $html; |
517 | $structured = $this->createEmptyStructuredData( $out, $entityViewFactory ); |
518 | } |
519 | return [ |
520 | 'unstructured' => $unstructured, |
521 | 'structured' => $structured, |
522 | ]; |
523 | } |
524 | |
525 | private function createEmptyStructuredData( |
526 | OutputPage $out, |
527 | DispatchingEntityViewFactory $entityViewFactory |
528 | ) { |
529 | $emptyMediaInfo = new MediaInfo(); |
530 | $fallbackChainFactory = new LanguageFallbackChainFactory(); |
531 | $view = $entityViewFactory->newEntityView( |
532 | $out->getLanguage(), |
533 | $fallbackChainFactory->newFromLanguage( $out->getLanguage() ), |
534 | $emptyMediaInfo |
535 | ); |
536 | |
537 | $structured = $view->getContent( |
538 | $emptyMediaInfo, |
539 | /* EntityRevision::UNSAVED_REVISION */ |
540 | 0 |
541 | )->getHtml(); |
542 | |
543 | // Strip out the surrounding <mediaInfoView> tag |
544 | $structured = preg_replace( |
545 | WBMIHooksHelper::getMediaInfoViewRegex(), |
546 | '$1', |
547 | $structured |
548 | ); |
549 | |
550 | return $structured; |
551 | } |
552 | |
553 | /** |
554 | * Delete all MediaInfo data from the output |
555 | * |
556 | * @param OutputPage $out |
557 | * @return OutputPage |
558 | */ |
559 | private static function deleteMediaInfoData( $out ) { |
560 | $html = $out->getHTML(); |
561 | $out->clearHTML(); |
562 | $html = preg_replace( WBMIHooksHelper::getMediaInfoViewRegex(), '', $html ); |
563 | $html = preg_replace( WBMIHooksHelper::getStructuredDataHeaderRegex(), '', $html ); |
564 | $out->addHTML( $html ); |
565 | return $out; |
566 | } |
567 | |
568 | /** |
569 | * If this file is protected, get the appropriate message for the user. |
570 | * |
571 | * Passing the message HTML to JS may not be ideal, but some messages are |
572 | * templates and template syntax isn't supported in JS. See |
573 | * https://www.mediawiki.org/wiki/Manual:Messages_API#Using_messages_in_JavaScript. |
574 | * |
575 | * @param OutputPage $out |
576 | * @return string|null |
577 | */ |
578 | private function getProtectionMsg( $out ) { |
579 | $imgTitle = $out->getTitle(); |
580 | $msg = null; |
581 | |
582 | $services = MediaWikiServices::getInstance(); |
583 | $restrictionStore = $services->getRestrictionStore(); |
584 | |
585 | // Full protection. |
586 | if ( $restrictionStore->isProtected( $imgTitle, 'edit' ) && |
587 | !$restrictionStore->isSemiProtected( $imgTitle, 'edit' ) |
588 | ) { |
589 | $msg = $out->msg( 'protectedpagetext', 'editprotected', 'edit' )->parseAsBlock(); |
590 | } |
591 | |
592 | // Semi-protection. |
593 | if ( $restrictionStore->isSemiProtected( $imgTitle, 'edit' ) ) { |
594 | $msg = $out->msg( 'protectedpagetext', 'editsemiprotected', 'edit' )->parseAsBlock(); |
595 | } |
596 | |
597 | // Cascading protection. |
598 | if ( $restrictionStore->isCascadeProtected( $imgTitle ) ) { |
599 | // Get the protected page(s) causing this file to be protected. |
600 | [ $cascadeSources ] = $restrictionStore->getCascadeProtectionSources( $imgTitle ); |
601 | $sources = ''; |
602 | $titleFormatter = $services->getTitleFormatter(); |
603 | foreach ( $cascadeSources as $pageIdentity ) { |
604 | $sources .= '* [[:' . $titleFormatter->getPrefixedText( $pageIdentity ) . "]]\n"; |
605 | } |
606 | |
607 | $msg = $out->msg( 'cascadeprotected', count( $cascadeSources ), $sources )->parseAsBlock(); |
608 | } |
609 | |
610 | return $msg; |
611 | } |
612 | |
613 | public static function onGetEntityByLinkedTitleLookup( EntityByLinkedTitleLookup &$lookup ) { |
614 | $lookup = new MediaInfoByLinkedTitleLookup( $lookup ); |
615 | } |
616 | |
617 | public static function onGetEntityContentModelForTitle( Title $title, &$contentModel ) { |
618 | if ( $title->inNamespace( NS_FILE ) && $title->getArticleID() ) { |
619 | $contentModel = MediaInfoContent::CONTENT_MODEL_ID; |
620 | } |
621 | } |
622 | |
623 | /** |
624 | * Register a ProfileContext for cirrus that will mean that queries in NS_FILE will use |
625 | * the MediaQueryBuilder class for searching |
626 | * |
627 | * @param SearchProfileService $service |
628 | */ |
629 | public static function onCirrusSearchProfileService( SearchProfileService $service ) { |
630 | global $wgWBCSUseCirrus; |
631 | if ( !$wgWBCSUseCirrus ) { |
632 | // avoid leaking into CirrusSearch test suite, where $wgWBCSUseCirrus |
633 | // will be false |
634 | return; |
635 | } |
636 | |
637 | // Register the query builder profiles so that they are usable in interleaved A/B test |
638 | $service->registerFileRepository( SearchProfileService::FT_QUERY_BUILDER, |
639 | // this string is to prevent overwriting, not used for retrieval |
640 | 'mediainfo_base', |
641 | __DIR__ . '/Search/MediaSearchProfiles.php' ); |
642 | |
643 | $service->registerFileRepository( SearchProfileService::RESCORE, |
644 | // this string is to prevent overwriting, not used for retrieval |
645 | 'mediainfo_base', |
646 | __DIR__ . '/Search/MediaSearchRescoreProfiles.php' ); |
647 | |
648 | $service->registerFileRepository( SearchProfileService::RESCORE_FUNCTION_CHAINS, |
649 | // this string is to prevent overwriting, not used for retrieval |
650 | 'mediainfo_base', |
651 | __DIR__ . '/Search/MediaSearchRescoreFunctionChains.php' ); |
652 | |
653 | $searchProfileContextName = MediaSearchQueryBuilder::SEARCH_PROFILE_CONTEXT_NAME; |
654 | // array key in MediaSearchProfiles.php |
655 | $rescoreProfileName = 'classic_noboostlinks_max_boost_template'; |
656 | |
657 | // Need to register a rescore profile for the profile context |
658 | $service->registerDefaultProfile( SearchProfileService::RESCORE, |
659 | $searchProfileContextName, $rescoreProfileName ); |
660 | |
661 | $request = RequestContext::getMain()->getRequest(); |
662 | $mwServices = MediaWikiServices::getInstance(); |
663 | $config = $mwServices->getMainConfig(); |
664 | $profiles = array_keys( $config->get( 'MediaInfoMediaSearchProfiles' ) ?: [] ); |
665 | if ( $profiles ) { |
666 | // first profile is the default mediasearch profile |
667 | $fulltextProfileName = $profiles[0]; |
668 | |
669 | foreach ( $profiles as $profile ) { |
670 | if ( $request->getCheck( $profile ) ) { |
671 | // switch to non-default implementations (only) when explicitly requested |
672 | $fulltextProfileName = $profile; |
673 | } |
674 | } |
675 | |
676 | $service->registerDefaultProfile( SearchProfileService::FT_QUERY_BUILDER, |
677 | $searchProfileContextName, $fulltextProfileName ); |
678 | |
679 | $service->registerFTSearchQueryRoute( |
680 | $searchProfileContextName, |
681 | 1, |
682 | // only for NS_FILE searches |
683 | [ NS_FILE ], |
684 | // only when the search query is found to be something mediasearch |
685 | // is capable of dealing with (as determined by MediaSearchASTClassifier) |
686 | [ $fulltextProfileName ] |
687 | ); |
688 | } |
689 | } |
690 | |
691 | public static function onCirrusSearchRegisterFullTextQueryClassifiers( |
692 | ParsedQueryClassifiersRepository $repository |
693 | ) { |
694 | $mwServices = MediaWikiServices::getInstance(); |
695 | $config = $mwServices->getMainConfig(); |
696 | $profiles = array_keys( $config->get( 'MediaInfoMediaSearchProfiles' ) ?: [] ); |
697 | $repository->registerClassifier( new MediaSearchASTClassifier( $profiles ) ); |
698 | } |
699 | |
700 | /** |
701 | * Handler for the GetPreferences hook |
702 | * |
703 | * @param User $user |
704 | * @param array[] &$preferences |
705 | */ |
706 | public function onGetPreferences( $user, &$preferences ) { |
707 | $preferences['wbmi-cc0-confirmed'] = [ |
708 | 'type' => 'api' |
709 | ]; |
710 | |
711 | $preferences['wbmi-wikidata-link-notice-dismissed'] = [ |
712 | 'type' => 'api' |
713 | ]; |
714 | } |
715 | |
716 | /** |
717 | * External libraries for Scribunto |
718 | * |
719 | * @param string $engine |
720 | * @param string[] &$extraLibraries |
721 | */ |
722 | public static function onScribuntoExternalLibraries( $engine, array &$extraLibraries ) { |
723 | if ( !ExtensionRegistry::getInstance()->isLoaded( 'WikibaseClient' ) ) { |
724 | return; |
725 | } |
726 | $allowDataTransclusion = WikibaseClient::getSettings()->getSetting( 'allowDataTransclusion' ); |
727 | if ( $engine === 'lua' && $allowDataTransclusion === true ) { |
728 | $extraLibraries['mw.wikibase.mediainfo'] = Scribunto_LuaWikibaseMediaInfoLibrary::class; |
729 | $extraLibraries['mw.wikibase.mediainfo.entity'] = [ |
730 | 'class' => Scribunto_LuaWikibaseMediaInfoEntityLibrary::class, |
731 | 'deferLoad' => true, |
732 | ]; |
733 | } |
734 | } |
735 | |
736 | /** |
737 | * @param RevisionRecord $revision |
738 | * @param ?int $oldPageID |
739 | */ |
740 | public function onRevisionUndeleted( $revision, $oldPageID ) { |
741 | $title = Title::newFromLinkTarget( $revision->getPageAsLinkTarget() ); |
742 | if ( !$title->inNamespace( NS_FILE ) ) { |
743 | // short-circuit if we're not even dealing with a file |
744 | return; |
745 | } |
746 | |
747 | if ( !$revision->hasSlot( 'mediainfo' ) ) { |
748 | // no mediainfo content found |
749 | return; |
750 | } |
751 | |
752 | $mwServices = MediaWikiServices::getInstance(); |
753 | $dbw = $mwServices->getDBLoadBalancerFactory()->getPrimaryDatabase(); |
754 | $blobStore = $mwServices->getBlobStoreFactory()->newSqlBlobStore(); |
755 | $statementGuidParser = WikibaseRepo::getStatementGuidParser( $mwServices ); |
756 | |
757 | // fetch existing entity data from old revision |
758 | $slot = $revision->getSlot( 'mediainfo', RevisionRecord::RAW ); |
759 | $existingContentId = $slot->getContentId(); |
760 | $existingContent = $slot->getContent(); |
761 | if ( !( $existingContent instanceof MediaInfoContent ) ) { |
762 | return; |
763 | } |
764 | $existingEntity = $existingContent->getEntity(); |
765 | $existingEntityId = $existingEntity->getId(); |
766 | |
767 | // generate actual correct entity id for this title |
768 | $entityIdLookup = MediaInfoServices::getMediaInfoIdLookup(); |
769 | $newEntityId = $entityIdLookup->getEntityIdForTitle( $title ); |
770 | if ( $existingEntityId === null || $newEntityId === null || $existingEntityId->equals( $newEntityId ) ) { |
771 | return; |
772 | } |
773 | |
774 | // create new content object with the same content, but this id |
775 | $newEntity = $existingEntity->copy(); |
776 | $newEntity->setId( $newEntityId ); |
777 | foreach ( $newEntity->getStatements()->toArray() as $statement ) { |
778 | // statement GUIDs also contain the M-id, so let's go fix those too |
779 | $existingStatementGuidString = $statement->getGuid(); |
780 | // cast GUID to non-null for Phan (we know it exists) |
781 | '@phan-var string $existingStatementGuidString'; |
782 | $existingStatementGuid = $statementGuidParser->parse( $existingStatementGuidString ); |
783 | if ( !$newEntityId->equals( $existingStatementGuid->getEntityId() ) ) { |
784 | $newStatementGuid = new StatementGuid( $newEntityId, $existingStatementGuid->getGuidPart() ); |
785 | $statement->setGuid( (string)$newStatementGuid ); |
786 | } |
787 | } |
788 | $newContent = new MediaInfoContent( new EntityInstanceHolder( $newEntity ) ); |
789 | |
790 | // store updated content in blob store |
791 | $unsavedSlot = SlotRecord::newUnsaved( 'mediainfo', $newContent ); |
792 | $blobAddress = $blobStore->storeBlob( |
793 | $newContent->serialize( $newContent->getDefaultFormat() ), |
794 | [ |
795 | BlobStore::PAGE_HINT => $revision->getPageId(), |
796 | BlobStore::REVISION_HINT => $revision->getId(), |
797 | BlobStore::PARENT_HINT => $revision->getParentId(), |
798 | BlobStore::DESIGNATION_HINT => 'page-content', |
799 | BlobStore::ROLE_HINT => $unsavedSlot->getRole(), |
800 | BlobStore::SHA1_HINT => $unsavedSlot->getSha1(), |
801 | BlobStore::MODEL_HINT => $newContent->getModel(), |
802 | BlobStore::FORMAT_HINT => $newContent->getDefaultFormat(), |
803 | ] |
804 | ); |
805 | |
806 | // update content record to point to new, corrected, content blob |
807 | $dbw->newUpdateQueryBuilder() |
808 | ->update( 'content' ) |
809 | ->set( [ |
810 | 'content_size' => $unsavedSlot->getSize(), |
811 | 'content_sha1' => $unsavedSlot->getSha1(), |
812 | 'content_address' => $blobAddress, |
813 | ] ) |
814 | ->where( [ |
815 | 'content_id' => $existingContentId, |
816 | ] ) |
817 | ->caller( __METHOD__ ) |
818 | ->execute(); |
819 | } |
820 | |
821 | /** |
822 | * @param Title $title |
823 | * @param bool $create |
824 | * @param string $comment |
825 | * @param int $oldPageId |
826 | * @param array $restoredPages |
827 | */ |
828 | public function onArticleUndelete( $title, $create, $comment, $oldPageId, $restoredPages ) { |
829 | if ( !$title->inNamespace( NS_FILE ) || $oldPageId === $title->getArticleID() ) { |
830 | return; |
831 | } |
832 | |
833 | // above onArticleRevisionUndeleted hook has been fixing MediaInfo ids |
834 | // for every undeleted revision, but now that that process is done, we |
835 | // need to clear the parser caches that (may have) been created during |
836 | // the undelete process as they were based on incorrect entities |
837 | $page = MediaWikiServices::getInstance()->getWikiPageFactory()->newFromTitle( $title ); |
838 | $page->updateParserCache( [ 'causeAction' => 'mediainfo-id-splitting' ] ); |
839 | } |
840 | |
841 | /** |
842 | * Add Concept URI link to the toolbox section of the sidebar. |
843 | * |
844 | * @param Skin $skin |
845 | * @param string[] &$sidebar |
846 | * @return void |
847 | */ |
848 | public function onSidebarBeforeOutput( $skin, &$sidebar ): void { |
849 | $title = $skin->getTitle(); |
850 | if ( !static::isMediaInfoPage( $title ) ) { |
851 | return; |
852 | } |
853 | |
854 | $entityId = MediaInfoServices::getMediaInfoIdLookup()->getEntityIdForTitle( $title ); |
855 | if ( $entityId === null ) { |
856 | return; |
857 | } |
858 | |
859 | $baseConceptUri = WikibaseRepo::getLocalEntitySource() |
860 | ->getConceptBaseUri(); |
861 | |
862 | $sidebar['TOOLBOX']['wb-concept-uri'] = [ |
863 | 'id' => 't-wb-concept-uri', |
864 | 'text' => $skin->msg( 'wikibase-concept-uri' )->text(), |
865 | 'href' => $baseConceptUri . $entityId->getSerialization(), |
866 | 'title' => $skin->msg( 'wikibase-concept-uri-tooltip' )->text() |
867 | ]; |
868 | } |
869 | |
870 | /** |
871 | * Add extra cirrus search query features for wikibase |
872 | * |
873 | * @param \CirrusSearch\SearchConfig $config (not used, required by hook) |
874 | * @param array &$extraFeatures |
875 | */ |
876 | public static function onCirrusSearchAddQueryFeatures( $config, array &$extraFeatures ) { |
877 | $featureConfig = MediaWikiServices::getInstance()->getMainConfig() |
878 | ->get( 'MediaInfoCustomMatchFeature' ); |
879 | if ( $featureConfig ) { |
880 | $extraFeatures[] = new CustomMatchFeature( $featureConfig ); |
881 | } |
882 | } |
883 | |
884 | /** |
885 | * @param RenderedRevision $renderedRevision |
886 | * @param UserIdentity $author |
887 | * @param CommentStoreComment $summary |
888 | * @param int $flags |
889 | * @param Status $hookStatus |
890 | */ |
891 | public function onMultiContentSave( |
892 | $renderedRevision, |
893 | $author, |
894 | $summary, |
895 | $flags, |
896 | $hookStatus |
897 | ) { |
898 | if ( ( $flags & EDIT_AUTOSUMMARY ) !== 0 && $renderedRevision->getRevision()->hasSlot( 'mediainfo' ) ) { |
899 | // remove coordinates from edit summaries when deleting location statements |
900 | // @see https://phabricator.wikimedia.org/T298700 |
901 | $coordinate = '\d+°(\d+\'(\d+(\.\d+)?")?)?'; |
902 | $summary->text = preg_replace( |
903 | "/(\/\* wbremoveclaims-remove:.+? \*\/ .+?): {$coordinate}[NS], {$coordinate}[EW]/u", |
904 | '$1', |
905 | $summary->text |
906 | ); |
907 | } |
908 | } |
909 | } |