Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.71% covered (success)
97.71%
128 / 131
76.92% covered (warning)
76.92%
10 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
ImageRecommendationMetadataProvider
97.71% covered (success)
97.71%
128 / 131
76.92% covered (warning)
76.92%
10 / 13
40
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 filterExtendedMetadata
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 getExtendedMetadataField
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
6.05
 getWikipediaReasonOtherProject
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 getLanguageCodesFromProjects
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 getShownLanguageCodes
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 getLanguagesListParam
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
3
 getWikipediaReason
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 getWikidataSectionIntersectionReason
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
3
 getSuggestionReason
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
7.02
 getLocalizedContentLanguage
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getFileMetadata
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getMetadata
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3namespace GrowthExperiments\NewcomerTasks\AddImage;
4
5use DerivativeContext;
6use InvalidArgumentException;
7use MediaWiki\Languages\LanguageNameUtils;
8use MediaWiki\Site\SiteLookup;
9use Message;
10use StatusValue;
11
12class ImageRecommendationMetadataProvider {
13
14    /** @var ImageRecommendationMetadataService */
15    private $service;
16
17    /** @var string[] */
18    private $languages;
19
20    /** @var LanguageNameUtils */
21    private $languageNameUtils;
22
23    /** @var DerivativeContext */
24    private $localizer;
25
26    /** @var SiteLookup */
27    private $siteLookup;
28
29    /** @var string */
30    private $contentLanguage;
31
32    /** @var int Number of languages to show in the suggestion reason */
33    private const SUGGESTION_REASON_PROJECTS_SHOWN = 2;
34
35    /**
36     * @param ImageRecommendationMetadataService $service
37     * @param string $wikiLanguage
38     * @param string[] $fallbackLanguages
39     * @param LanguageNameUtils $languageNameUtils
40     * @param DerivativeContext $localizer
41     * @param SiteLookup $siteLookup
42     */
43    public function __construct(
44        ImageRecommendationMetadataService $service,
45        string $wikiLanguage,
46        array $fallbackLanguages,
47        LanguageNameUtils $languageNameUtils,
48        DerivativeContext $localizer,
49        SiteLookup $siteLookup
50    ) {
51        $this->service = $service;
52        $this->languages = array_merge( [ $wikiLanguage ], $fallbackLanguages );
53        $this->languageNameUtils = $languageNameUtils;
54        $this->localizer = $localizer;
55        $this->siteLookup = $siteLookup;
56        $this->contentLanguage = $wikiLanguage;
57    }
58
59    /**
60     * Returns an array with fields extracted from extended metadata fields. See
61     * {@see ImageRecommendationMetadataProvider::getMetadata()} for details.
62     *
63     * @param array $extendedMetadata
64     * @return array
65     */
66    private function filterExtendedMetadata( array $extendedMetadata ): array {
67        return [
68            // description field of {{information}} template - see
69            // https://commons.wikimedia.org/wiki/Template:Information
70            'description' => $this->getExtendedMetadataField( $extendedMetadata, 'ImageDescription' ),
71            // author field of {{information}} template
72            'author' => $this->getExtendedMetadataField( $extendedMetadata, 'Artist' ),
73            // short name like 'CC BY-SA 4.0',  typically parsed from the first license template on the page
74            'license' => $this->getExtendedMetadataField( $extendedMetadata, 'LicenseShortName' ),
75            // DateTimeOriginal is the date field of {{information}} template.
76            // DateTime is image creation date from EXIF or similar embedded metadata, with fallback
77            // to the file upload date.
78            'date' => $this->getExtendedMetadataField( $extendedMetadata, 'DateTimeOriginal' )
79                ?? $this->getExtendedMetadataField( $extendedMetadata, 'DateTime' ),
80        ];
81    }
82
83    /**
84     * @param array $extendedMetadata
85     * @param string $fieldName
86     * @return string|null
87     */
88    private function getExtendedMetadataField( array $extendedMetadata, string $fieldName ) {
89        if ( isset( $extendedMetadata[$fieldName]['value'] ) ) {
90            $value = $extendedMetadata[$fieldName]['value'];
91            if ( !is_array( $value ) ) {
92                return $value;
93            }
94            // Array means the field is multilingual, we need to select the best language.
95            foreach ( $this->languages as $language ) {
96                if ( isset( $value[$language] ) ) {
97                    return $value[$language];
98                }
99            }
100            // None of the languages are relevant to the user, we can't really rank them.
101            // Just pick the first one.
102            return $value ? reset( $value ) : null;
103        }
104        return null;
105    }
106
107    /**
108     * Construct the suggestion reason string when the suggested image is found in another project.
109     * Only return the localized string if the localized project name is available.
110     *
111     * @param string $projectId Wiki ID
112     * @param string $source 'wikipedia', 'wikidata-section-alignment' (see the
113     *   ImageRecommendationImage constants)
114     * @return string|null
115     */
116    private function getWikipediaReasonOtherProject( string $projectId, string $source ): ?string {
117        // Localized project name is from WikimediaMessages extension.
118        $projectName = $this->localizer->msg( 'project-localized-name-' . $projectId );
119        if ( $projectName->exists() ) {
120            return $this->localizer->msg(
121                "growthexperiments-addimage-reason-$source-project",
122                $projectName->text()
123            )->text();
124        }
125        return null;
126    }
127
128    /**
129     * Get an array of language codes for the projects in which the image suggestion is used.
130     *
131     * @param string[] $projects Projects in which the image suggestion is used
132     * @return array
133     */
134    private function getLanguageCodesFromProjects( array $projects ): array {
135        $siteLookup = $this->siteLookup;
136        return array_reduce( $projects,
137            static function ( array $result, string $projectId )
138            use ( $siteLookup ) {
139                $site = $siteLookup->getSite( $projectId );
140                // SiteLookup::getSite and Site::getLanguageCode can return null.
141                $languageCode = $site ? $site->getLanguageCode() : null;
142                if ( is_string( $languageCode ) && strlen( $languageCode ) ) {
143                    $result[] = $languageCode;
144                }
145                return $result;
146            }, [] );
147    }
148
149    /**
150     * Get an array of language codes to show in the suggestion reason, sorted by the fallback
151     * languages chain
152     *
153     * @param string[] $languageCodes Language codes of the projects in which the image is found
154     * @param int $targetCount Number of languages to show in the string
155     * @return array
156     */
157    public function getShownLanguageCodes( array $languageCodes, int $targetCount ): array {
158        $shownLanguageCodes = [];
159        foreach ( $this->languages as $fallbackLanguage ) {
160            if ( in_array( $fallbackLanguage, $languageCodes ) ) {
161                $shownLanguageCodes[] = $fallbackLanguage;
162                if ( count( $shownLanguageCodes ) === $targetCount ) {
163                    return $shownLanguageCodes;
164                }
165            }
166        }
167        return array_merge(
168            $shownLanguageCodes,
169            array_slice( array_diff( $languageCodes, $shownLanguageCodes ),
170                0,
171                $targetCount - count( $shownLanguageCodes )
172            )
173        );
174    }
175
176    /**
177     * Get the parameter for the concatenated list of languages shown in the suggestion reason.
178     * The parameter is used with "growthexperiments-addimage-reason-wikipedia-languages" key.
179     *
180     * @param string[] $languageCodes Language codes of the projects in which the image is found
181     * @return mixed
182     */
183    private function getLanguagesListParam( array $languageCodes ) {
184        $totalLanguages = count( $languageCodes );
185        $shownLanguageCodes = $this->getShownLanguageCodes(
186            $languageCodes,
187            self::SUGGESTION_REASON_PROJECTS_SHOWN
188        );
189        $inLanguage = $this->localizer->getLanguage()->getCode();
190        $shownLanguages = array_reduce( $shownLanguageCodes, function (
191            array $result,
192            string $languageCode
193        ) use ( $inLanguage ) {
194            $languageName = $this->languageNameUtils->getLanguageName( $languageCode, $inLanguage );
195            if ( $languageName ) {
196                $result[] = $languageName;
197            }
198            return $result;
199        }, [] );
200        $otherLanguagesCount = $totalLanguages - count( $shownLanguages );
201
202        if ( $otherLanguagesCount ) {
203            $shownLanguages[] = $this->localizer->msg(
204                'growthexperiments-addimage-reason-wikipedia-languages-others'
205            )->numParams( $otherLanguagesCount )->text();
206        }
207        return Message::listParam( $shownLanguages, 'text' );
208    }
209
210    /**
211     * Construct the suggestion reason string when the suggested image is found in other projects
212     *
213     * @param string[] $projects Wiki IDs of projects in which the image suggestion is used
214     * @param string $source 'wikipedia' or 'wikidata-section-alignment' (see the
215     *   ImageRecommendationImage constants)
216     * @return string
217     */
218    private function getWikipediaReason( array $projects, string $source ): string {
219        $totalCount = count( $projects );
220        if ( $totalCount === 1 ) {
221            $reason = $this->getWikipediaReasonOtherProject( $projects[0], $source );
222            if ( $reason ) {
223                return $reason;
224            }
225        }
226
227        $languageCodes = $this->getLanguageCodesFromProjects( $projects );
228        if ( count( $languageCodes ) === 0 ) {
229            return $this->localizer->msg( "growthexperiments-addimage-reason-$source" )
230                ->numParams( $totalCount )->text();
231        }
232        return $this->localizer->msg(
233            "growthexperiments-addimage-reason-$source-languages",
234                Message::numParam( $totalCount ),
235                $this->getLanguagesListParam( $languageCodes )
236            )->text();
237    }
238
239    /**
240     * Construct the suggestion reason string when the suggested image is based on other projects
241     *
242     * @param string[] $projects Wiki IDs of projects in which the image suggestion is used
243     * @return string
244     */
245    private function getWikidataSectionIntersectionReason( $projects ): string {
246        $firstProject = $projects[0];
247        // Localized project name is from WikimediaMessages extension.
248        $projectName = $this->localizer->msg( 'project-localized-name-' . $firstProject );
249        if ( $projectName->exists() ) {
250            if ( count( $projects ) === 1 ) {
251                return $this->localizer->msg(
252                    'growthexperiments-addimage-reason-wikidata-section-intersection-single',
253                    $projectName
254                )->text();
255            } else {
256                return $this->localizer->msg(
257                    'growthexperiments-addimage-reason-wikidata-section-intersection-multiple',
258                    $projectName,
259                    Message::numParam( count( $projects ) - 1 )
260                )->text();
261            }
262        } else {
263            return $this->localizer->msg(
264                'growthexperiments-addimage-reason-wikidata-section-intersection-languages',
265                Message::numParam( count( $projects ) )
266            )->text();
267        }
268    }
269
270    /**
271     * Get the localized string for suggestion reason
272     *
273     * @param array $suggestion Suggestion data
274     * @return string
275     */
276    private function getSuggestionReason( array $suggestion ): string {
277        $source = $suggestion['source'];
278        if ( $source === ImageRecommendationImage::SOURCE_WIKIDATA ) {
279            return $this->localizer->msg( 'growthexperiments-addimage-reason-wikidata' )->text();
280        } elseif ( $source === ImageRecommendationImage::SOURCE_COMMONS ) {
281            return $this->localizer->msg( 'growthexperiments-addimage-reason-commons' )->text();
282        } elseif ( $source === ImageRecommendationImage::SOURCE_WIKIDATA_SECTION_TOPICS ) {
283            return $this->localizer->msg( 'growthexperiments-addimage-reason-wikidata-section-topics' )->text();
284        } elseif ( $source === ImageRecommendationImage::SOURCE_WIKIPEDIA
285            || $source === ImageRecommendationImage::SOURCE_WIKIDATA_SECTION_ALIGNMENT
286        ) {
287            return $this->getWikipediaReason( $suggestion['projects'], $source );
288        } elseif ( $source === ImageRecommendationImage::SOURCE_WIKIDATA_SECTION_INTERSECTION ) {
289            return $this->getWikidataSectionIntersectionReason( $suggestion['projects'] );
290        }
291        throw new InvalidArgumentException( "Unknown suggestion source: $source" );
292    }
293
294    /**
295     * Get the name of the content language localized in the user's language
296     *
297     * @return string
298     */
299    private function getLocalizedContentLanguage(): string {
300        $inLanguage = $this->localizer->getLanguage()->getCode();
301        return $this->languageNameUtils->getLanguageName( $this->contentLanguage, $inLanguage );
302    }
303
304    /**
305     * @param string $filename
306     * @return array|StatusValue
307     */
308    public function getFileMetadata( string $filename ) {
309        return $this->service->getFileMetadata( $filename );
310    }
311
312    /**
313     * Get metadata for the specified image file name
314     *
315     * @param array $suggestion Suggestion data, as returned by the API.
316     * @return array|StatusValue On success, an array with the following fields:
317     *   Image metadata:
318     *   - descriptionUrl: image description page URL
319     *   - thumbUrl: URL to image scaled to THUMB_WIDTH
320     *   - fullUrl: URL to original image
321     *   - originalWidth: full image width
322     *   - originalHeight: full image height
323     *   - mustRender: true if the original image wouldn't display correctly in a browser
324     *   - isVectorized: whether the image is a vector image (ie. has no max size)
325     *   Extended metadata:
326     *   - description: image description in content language, or null. Might contain HTML.
327     *   - author: original author of image, in content language, or null. Might contain HTML.
328     *   - license: short license name, in content language, or null. Might contain HTML.
329     *   - date: date of original image creation. Can be pretty much any format - ISO timestamp,
330     *     text in any language, HTML. Always present.
331     *   Metadata from the API of the image host:
332     *   - caption: MediaInfo caption as a plaintext string in the current wiki's content language,
333     *     or null.
334     *   - categories: non-hidden categories of the image as an array of unprefixed title strings
335     *     with spaces.
336     *   Other:
337     *   - reason: a human-readable representation of the suggestion's 'source' and 'project' fields.
338     */
339    public function getMetadata( array $suggestion ) {
340        $filename = $suggestion['filename'];
341        $fileMetadata = $this->service->getFileMetadata( $filename );
342        $extendedMetadata = $this->service->getExtendedMetadata( $filename );
343        $apiMetadata = $this->service->getApiMetadata( $filename );
344        foreach ( [ $fileMetadata, $extendedMetadata, $apiMetadata ] as $metadata ) {
345            if ( $metadata instanceof StatusValue ) {
346                return $metadata;
347            }
348        }
349        return $fileMetadata + $this->filterExtendedMetadata( $extendedMetadata ) + $apiMetadata + [
350                'reason' => $this->getSuggestionReason( $suggestion ),
351                'contentLanguageName' => $this->getLocalizedContentLanguage(),
352        ];
353    }
354}