Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
44.92% covered (danger)
44.92%
177 / 394
28.00% covered (danger)
28.00%
7 / 25
CRAP
0.00% covered (danger)
0.00%
0 / 1
WikibaseMediaInfoHooks
44.92% covered (danger)
44.92%
177 / 394
28.00% covered (danger)
28.00%
7 / 25
1205.36
0.00% covered (danger)
0.00%
0 / 1
 onWikibaseRepoEntityNamespaces
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 onWikibaseEntityTypes
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 onParserOutputPostCacheTransform
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 onRegistration
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 isMediaInfoPage
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 onBeforePageDisplay
79.41% covered (warning)
79.41%
27 / 34
0.00% covered (danger)
0.00%
0 / 1
7.43
 doBeforePageDisplay
75.93% covered (warning)
75.93%
41 / 54
0.00% covered (danger)
0.00%
0 / 1
14.01
 generateWbTermsLanguages
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 generateWbMonolingualTextLanguages
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 tabifyStructuredData
24.39% covered (danger)
24.39%
20 / 82
0.00% covered (danger)
0.00%
0 / 1
21.56
 extractStructuredDataHtml
64.71% covered (warning)
64.71%
11 / 17
0.00% covered (danger)
0.00%
0 / 1
2.18
 createEmptyStructuredData
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
1
 deleteMediaInfoData
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getProtectionMsg
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
6
 onGetEntityByLinkedTitleLookup
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 onGetEntityContentModelForTitle
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
12
 onCirrusSearchProfileService
93.75% covered (success)
93.75%
30 / 32
0.00% covered (danger)
0.00%
0 / 1
6.01
 onCirrusSearchRegisterFullTextQueryClassifiers
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 onGetPreferences
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 onScribuntoExternalLibraries
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 onRevisionUndeleted
0.00% covered (danger)
0.00%
0 / 55
0.00% covered (danger)
0.00%
0 / 1
90
 onArticleUndelete
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 onSidebarBeforeOutput
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
 onCirrusSearchAddQueryFeatures
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 onMultiContentSave
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3namespace Wikibase\MediaInfo;
4
5use CirrusSearch\Parser\ParsedQueryClassifiersRepository;
6use CirrusSearch\Profile\SearchProfileService;
7use ExtensionRegistry;
8use MediaWiki\CommentStore\CommentStoreComment;
9use MediaWiki\Config\ConfigException;
10use MediaWiki\Hook\BeforePageDisplayHook;
11use MediaWiki\Hook\ParserOutputPostCacheTransformHook;
12use MediaWiki\Hook\SidebarBeforeOutputHook;
13use MediaWiki\Html\Html;
14use MediaWiki\MediaWikiServices;
15use MediaWiki\Output\OutputPage;
16use MediaWiki\Page\Hook\ArticleUndeleteHook;
17use MediaWiki\Page\Hook\RevisionUndeletedHook;
18use MediaWiki\Parser\ParserOutput;
19use MediaWiki\Preferences\Hook\GetPreferencesHook;
20use MediaWiki\Revision\RenderedRevision;
21use MediaWiki\Revision\RevisionRecord;
22use MediaWiki\Revision\SlotRecord;
23use MediaWiki\Status\Status;
24use MediaWiki\Storage\BlobStore;
25use MediaWiki\Storage\Hook\MultiContentSaveHook;
26use MediaWiki\Title\Title;
27use MediaWiki\User\TempUser\TempUserConfig;
28use MediaWiki\User\User;
29use MediaWiki\User\UserIdentity;
30use OOUI\HtmlSnippet;
31use OOUI\IndexLayout;
32use OOUI\PanelLayout;
33use OOUI\TabPanelLayout;
34use RequestContext;
35use Skin;
36use Wikibase\Client\WikibaseClient;
37use Wikibase\DataModel\Entity\NumericPropertyId;
38use Wikibase\DataModel\Services\Lookup\PropertyDataTypeLookupException;
39use Wikibase\DataModel\Statement\StatementGuid;
40use Wikibase\Lib\LanguageFallbackChainFactory;
41use Wikibase\Lib\Store\EntityByLinkedTitleLookup;
42use Wikibase\Lib\UserLanguageLookup;
43use Wikibase\MediaInfo\Content\MediaInfoContent;
44use Wikibase\MediaInfo\DataAccess\Scribunto\Scribunto_LuaWikibaseMediaInfoEntityLibrary;
45use Wikibase\MediaInfo\DataAccess\Scribunto\Scribunto_LuaWikibaseMediaInfoLibrary;
46use Wikibase\MediaInfo\DataModel\MediaInfo;
47use Wikibase\MediaInfo\Search\Feature\CustomMatchFeature;
48use Wikibase\MediaInfo\Search\MediaSearchASTClassifier;
49use Wikibase\MediaInfo\Search\MediaSearchQueryBuilder;
50use Wikibase\MediaInfo\Services\MediaInfoByLinkedTitleLookup;
51use Wikibase\MediaInfo\Services\MediaInfoServices;
52use Wikibase\Repo\BabelUserLanguageLookup;
53use Wikibase\Repo\Content\EntityInstanceHolder;
54use Wikibase\Repo\MediaWikiLocalizedTextProvider;
55use Wikibase\Repo\ParserOutput\DispatchingEntityViewFactory;
56use 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 */
64class 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}