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