Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
27.59% covered (danger)
27.59%
24 / 87
16.67% covered (danger)
16.67%
1 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
HookHandler
27.59% covered (danger)
27.59%
24 / 87
16.67% covered (danger)
16.67%
1 / 6
205.78
0.00% covered (danger)
0.00%
0 / 1
 onGetExtendedMetadata
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
5
 onValidateExtendedMetadataCache
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 onContentAlterParserOutput
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
90
 getDataCollector
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
2
 onSkinAfterBottomScripts
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
20
 doSkinAfterBottomScripts
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace CommonsMetadata;
4
5use CommonsMetadata\Hooks\SkinAfterBottomScriptsHandler;
6use Content;
7use DerivativeContext;
8use File;
9use FormatMetadata;
10use IContextSource;
11use LocalRepo;
12use MediaWiki\Content\Hook\ContentAlterParserOutputHook;
13use MediaWiki\Hook\GetExtendedMetadataHook;
14use MediaWiki\Hook\SkinAfterBottomScriptsHook;
15use MediaWiki\Hook\ValidateExtendedMetadataCacheHook;
16use MediaWiki\MediaWikiServices;
17use MediaWiki\Title\Title;
18use ParserOutput;
19use Skin;
20use Wikimedia\Bcp47Code\Bcp47Code;
21
22/**
23 * Hook handler
24 */
25class HookHandler implements
26    GetExtendedMetadataHook,
27    ValidateExtendedMetadataCacheHook,
28    ContentAlterParserOutputHook,
29    SkinAfterBottomScriptsHook
30{
31    /**
32     * Metadata version. When getting metadata of a remote file via the API, sometimes
33     * we get the data generated by a CommonsMetadata extension installed at the remote,
34     * as well. We use this version number to keep track of whether that data is different
35     * from what would be generated here.
36     * @var float
37     */
38    private const VERSION = 1.2;
39
40    /**
41     * Hook handler for extended metadata
42     *
43     * @param array &$combinedMeta Metadata so far
44     * @param File $file The file object in question
45     * @param IContextSource $context Context. Used to select language
46     * @param bool $singleLang Get only target language, or all translations
47     * @param int &$maxCache How many seconds to cache the result
48     */
49    public function onGetExtendedMetadata(
50        &$combinedMeta, $file, $context, $singleLang, &$maxCache
51    ) {
52        global $wgCommonsMetadataForceRecalculate;
53
54        if (
55            isset( $combinedMeta['CommonsMetadataExtension']['value'] )
56            && $combinedMeta['CommonsMetadataExtension']['value'] == self::VERSION
57            && !$wgCommonsMetadataForceRecalculate
58        ) {
59            // This is a file from a remote API repo, and CommonsMetadata is installed on
60            // the remote as well, and generates the same metadata format. We have nothing to do.
61            return;
62        } else {
63            $combinedMeta['CommonsMetadataExtension'] = [
64                'value' => self::VERSION,
65                'source' => 'extension',
66            ];
67        }
68
69        $lang = $context->getLanguage();
70
71        $templateParser = new TemplateParser();
72        $templateParser->setMultiLanguage( !$singleLang );
73        $fallbacks = MediaWikiServices::getInstance()->getLanguageFallback()->getAll( $lang->getCode() );
74        array_unshift( $fallbacks, $lang->getCode() );
75        $templateParser->setPriorityLanguages( $fallbacks );
76        $templateParser->setArtistCreditSeparator( $context->msg( 'commonsmetadata-artistcredit-separator' )->text() );
77
78        $dataCollector = new DataCollector();
79        $dataCollector->setLanguage( $lang );
80        $dataCollector->setMultiLang( !$singleLang );
81        $dataCollector->setTemplateParser( $templateParser );
82        $dataCollector->setLicenseParser( new LicenseParser() );
83
84        $dataCollector->collect( $combinedMeta, $file );
85
86        if ( !$file->getDescriptionTouched() ) {
87            // Not all files provide the last update time of the description.
88            // If that's the case, just cache blindly for a shorter period.
89            $maxCache = 60 * 60 * 12;
90        }
91    }
92
93    /**
94     * Hook to check if cache is stale
95     *
96     * @param string $timestamp Timestamp of when cache taken
97     * @param File $file The file metadata is for
98     * @return bool Is metadata still valid
99     */
100    public function onValidateExtendedMetadataCache( $timestamp, $file ) {
101        return // use cached value if...
102            // we don't know when the file was last updated
103            !$file->getDescriptionTouched()
104            // or last update was before we cached it
105            || wfTimestamp( TS_UNIX, $file->getDescriptionTouched() )
106                <= wfTimestamp( TS_UNIX, $timestamp );
107    }
108
109    /**
110     * Check HTML output of a file page to see if it has all the basic metadata, and
111     * add tracking categories if it does not.
112     * @param Content $content
113     * @param Title $title
114     * @param ParserOutput $parserOutput
115     */
116    public function onContentAlterParserOutput(
117        $content, $title, $parserOutput
118    ) {
119        global $wgCommonsMetadataSetTrackingCategories;
120
121        if (
122            !$wgCommonsMetadataSetTrackingCategories
123            || !$title->inNamespace( NS_FILE )
124            || !$parserOutput->hasText()
125            || $content->getModel() !== CONTENT_MODEL_WIKITEXT
126        ) {
127            return;
128        }
129
130        /*
131         * We also need to check if the file can be found. This is pretty straightforward, except
132         * for when a file gets moved: the old & new file details are cached, and cache is purged
133         * later on, in a DeferredUpdate.
134         * We could just `$repo->findFile( $title, [ 'ignoreRedirect' => true, 'latest' => true ] )`
135         * to force it to always check the database, but apart from file moves, the data in cache
136         * (if any) is usually just fine.
137         * Instead, we'll:
138         * * first test if `$title->isRedirect()`, to weed out the old (now renamed) title
139         * * attempt to fetch from cache, which should usually be fine
140         * * then fallback to DB, for files that have just been renamed
141         */
142        $services = MediaWikiServices::getInstance();
143        $trackingCategories = $services->getTrackingCategories();
144        $repo = $services->getRepoGroup()->getLocalRepo();
145        if ( $title->isRedirect() ) {
146            return;
147        }
148        $file = $repo->findFile( $title, [ 'ignoreRedirect' => true ] );
149        if ( $file === false ) {
150            $file = $repo->findFile( $title, [ 'ignoreRedirect' => true, 'latest' => true ] );
151            if ( $file === false ) {
152                return;
153            }
154        }
155
156        $langCode = $parserOutput->getLanguage() ?? $services->getContentLanguage();
157        $dataCollector = self::getDataCollector( $langCode, true );
158
159        $categoryKeys = $dataCollector->verifyAttributionMetadata( $parserOutput, $file );
160        foreach ( $categoryKeys as $key ) {
161            $trackingCategories->addTrackingCategory(
162                $parserOutput,
163                'commonsmetadata-trackingcategory-' . $key,
164                $title
165            );
166        }
167    }
168
169    /**
170     * @param Bcp47Code $langCode
171     * @param bool $singleLang
172     * @return DataCollector
173     */
174    private static function getDataCollector( Bcp47Code $langCode, $singleLang ) {
175        $templateParser = new TemplateParser();
176        $templateParser->setMultiLanguage( !$singleLang );
177        $fallbacks = MediaWikiServices::getInstance()->getLanguageFallback()->getAll( $langCode->toBcp47Code() );
178        array_unshift( $fallbacks, $langCode->toBcp47Code() );
179        $templateParser->setPriorityLanguages( $fallbacks );
180        $templateParser->setArtistCreditSeparator( wfMessage( 'commonsmetadata-artistcredit-separator' )->text() );
181
182        $lang = MediaWikiServices::getInstance()->getLanguageFactory()->getLanguage( $langCode );
183
184        $dataCollector = new DataCollector();
185        $dataCollector->setLanguage( $lang );
186        $dataCollector->setMultiLang( !$singleLang );
187        $dataCollector->setTemplateParser( $templateParser );
188        $dataCollector->setLicenseParser( new LicenseParser() );
189
190        return $dataCollector;
191    }
192
193    /**
194     * Injects an inline JSON-LD script schema with image license info.
195     *
196     * See https://phabricator.wikimedia.org/T254039. This only adds the script
197     * to File pages.
198     *
199     * @param Skin $skin
200     * @param string &$html
201     */
202    public function onSkinAfterBottomScripts( $skin, &$html ) {
203        $title = $skin->getOutput()->getTitle();
204        $isFilePage = $title->inNamespace( NS_FILE );
205
206        if (
207            !$title ||
208            !$title->exists() ||
209            !$isFilePage
210        ) {
211            return;
212        }
213
214        $localRepo = MediaWikiServices::getInstance()->getRepoGroup()->getLocalRepo();
215
216        // Get and prepare FormatMetadata object.
217        $format = new FormatMetadata;
218        $context = new DerivativeContext( $format->getContext() );
219        // Language doesn't matter so just use en to improve performance.
220        $format->setSingleLanguage( true );
221        $context->setLanguage( 'en' );
222        $format->setContext( $context );
223
224        // Get URL for public domain page from config.
225        $config = MediaWikiServices::getInstance()->getConfigFactory()->makeConfig( 'CommonsMetadata' );
226        $publicDomainPageUrl = $config->get( 'CommonsMetadataPublicDomainPageUrl' );
227
228        $handler = new SkinAfterBottomScriptsHandler( $format, $publicDomainPageUrl );
229
230        $html .= $this->doSkinAfterBottomScripts(
231            $localRepo,
232            $handler,
233            $title
234        );
235    }
236
237    /**
238     * Get schema script html (or empty string).
239     *
240     * @param LocalRepo $localRepo
241     * @param SkinAfterBottomScriptsHandler $handler
242     * @param Title $title
243     * @return string
244     */
245    public function doSkinAfterBottomScripts(
246        LocalRepo $localRepo,
247        SkinAfterBottomScriptsHandler $handler,
248        Title $title
249    ) {
250        $file = $localRepo->newFile( $title );
251        return $handler->getSchemaElement( $title, $file );
252    }
253}