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