Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
79.39% |
104 / 131 |
|
53.85% |
7 / 13 |
CRAP | |
0.00% |
0 / 1 |
ImageRecommendationMetadataProvider | |
79.39% |
104 / 131 |
|
53.85% |
7 / 13 |
54.01 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
filterExtendedMetadata | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
getExtendedMetadataField | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
6.05 | |||
getWikipediaReasonOtherProject | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
2.01 | |||
getLanguageCodesFromProjects | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
4 | |||
getShownLanguageCodes | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
4 | |||
getLanguagesListParam | |
85.71% |
18 / 21 |
|
0.00% |
0 / 1 |
3.03 | |||
getWikipediaReason | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
4 | |||
getWikidataSectionIntersectionReason | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
12 | |||
getSuggestionReason | |
69.23% |
9 / 13 |
|
0.00% |
0 / 1 |
8.43 | |||
getLocalizedContentLanguage | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
getFileMetadata | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getMetadata | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
3 |
1 | <?php |
2 | |
3 | namespace GrowthExperiments\NewcomerTasks\AddImage; |
4 | |
5 | use InvalidArgumentException; |
6 | use MediaWiki\Context\DerivativeContext; |
7 | use MediaWiki\Languages\LanguageNameUtils; |
8 | use MediaWiki\Message\Message; |
9 | use MediaWiki\Site\SiteLookup; |
10 | use StatusValue; |
11 | |
12 | class 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 | } |