Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
34.31% |
35 / 102 |
|
54.55% |
6 / 11 |
CRAP | |
0.00% |
0 / 1 |
PageImages | |
34.31% |
35 / 102 |
|
54.55% |
6 / 11 |
303.36 | |
0.00% |
0 / 1 |
factory | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
__construct | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
getPropName | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
getPropNames | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
getPageImage | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getPageImageInternal | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
fetchPageImage | |
23.81% |
5 / 21 |
|
0.00% |
0 / 1 |
16.06 | |||
onInfoAction | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
12 | |||
onApiOpenSearchSuggest | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
30 | |||
getImages | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
12 | |||
onBeforePageDisplay | |
53.33% |
8 / 15 |
|
0.00% |
0 / 1 |
9.66 |
1 | <?php |
2 | |
3 | namespace PageImages; |
4 | |
5 | use File; |
6 | use MapCacheLRU; |
7 | use MediaWiki\Api\ApiBase; |
8 | use MediaWiki\Api\ApiMain; |
9 | use MediaWiki\Api\Hook\ApiOpenSearchSuggestHook; |
10 | use MediaWiki\Cache\CacheKeyHelper; |
11 | use MediaWiki\Config\Config; |
12 | use MediaWiki\Context\IContextSource; |
13 | use MediaWiki\Hook\InfoActionHook; |
14 | use MediaWiki\MainConfigNames; |
15 | use MediaWiki\MediaWikiServices; |
16 | use MediaWiki\Output\Hook\BeforePageDisplayHook; |
17 | use MediaWiki\Output\OutputPage; |
18 | use MediaWiki\Request\FauxRequest; |
19 | use MediaWiki\Title\Title; |
20 | use MediaWiki\User\Options\UserOptionsLookup; |
21 | use RepoGroup; |
22 | use Skin; |
23 | use Wikimedia\Rdbms\IConnectionProvider; |
24 | |
25 | /** |
26 | * @license WTFPL |
27 | * @author Max Semenik |
28 | * @author Brad Jorsch |
29 | * @author Thiemo Kreuz |
30 | */ |
31 | class PageImages implements |
32 | ApiOpenSearchSuggestHook, |
33 | BeforePageDisplayHook, |
34 | InfoActionHook |
35 | { |
36 | /** |
37 | * @const value for free images |
38 | */ |
39 | public const LICENSE_FREE = 'free'; |
40 | |
41 | /** |
42 | * @const value for images with any type of license |
43 | */ |
44 | public const LICENSE_ANY = 'any'; |
45 | |
46 | /** |
47 | * Page property used to store the best page image information. |
48 | * If the best image is the same as the best image with free license, |
49 | * then nothing is stored under this property. |
50 | * Note changing this value is not advised as it will invalidate all |
51 | * existing page property names on a production instance |
52 | * and cause them to be regenerated. |
53 | * @see PageImages::PROP_NAME_FREE |
54 | */ |
55 | public const PROP_NAME = 'page_image'; |
56 | |
57 | /** |
58 | * Page property used to store the best free page image information |
59 | * Note changing this value is not advised as it will invalidate all |
60 | * existing page property names on a production instance |
61 | * and cause them to be regenerated. |
62 | */ |
63 | public const PROP_NAME_FREE = 'page_image_free'; |
64 | |
65 | /** @var Config */ |
66 | private $config; |
67 | |
68 | /** @var IConnectionProvider */ |
69 | private $dbProvider; |
70 | |
71 | /** @var RepoGroup */ |
72 | private $repoGroup; |
73 | |
74 | /** @var UserOptionsLookup */ |
75 | private $userOptionsLookup; |
76 | |
77 | /** @var MapCacheLRU */ |
78 | private static $cache = null; |
79 | |
80 | /** |
81 | * @return PageImages |
82 | */ |
83 | private static function factory(): self { |
84 | $services = MediaWikiServices::getInstance(); |
85 | return new self( |
86 | $services->getMainConfig(), |
87 | $services->getDBLoadBalancerFactory(), |
88 | $services->getRepoGroup(), |
89 | $services->getUserOptionsLookup() |
90 | ); |
91 | } |
92 | |
93 | /** |
94 | * @param Config $config |
95 | * @param IConnectionProvider $dbProvider |
96 | * @param RepoGroup $repoGroup |
97 | * @param UserOptionsLookup $userOptionsLookup |
98 | */ |
99 | public function __construct( |
100 | Config $config, |
101 | IConnectionProvider $dbProvider, |
102 | RepoGroup $repoGroup, |
103 | UserOptionsLookup $userOptionsLookup |
104 | ) { |
105 | $this->config = $config; |
106 | $this->dbProvider = $dbProvider; |
107 | $this->repoGroup = $repoGroup; |
108 | $this->userOptionsLookup = $userOptionsLookup; |
109 | } |
110 | |
111 | /** |
112 | * Get property name used in page_props table. When a page image |
113 | * is stored it will be stored under this property name on the corresponding |
114 | * article. |
115 | * |
116 | * @param bool $isFree Whether the image is a free-license image |
117 | * @return string |
118 | */ |
119 | public static function getPropName( $isFree ) { |
120 | return $isFree ? self::PROP_NAME_FREE : self::PROP_NAME; |
121 | } |
122 | |
123 | /** |
124 | * Get property names used in page_props table |
125 | * |
126 | * If the license is free, then only the free property name will be returned, |
127 | * otherwise both free and non-free property names will be returned. That's |
128 | * because we save the image name only once if it's free and the best image. |
129 | * |
130 | * @param string $license either LICENSE_FREE or LICENSE_ANY, |
131 | * specifying whether to return the non-free property name or not |
132 | * @return string|array |
133 | */ |
134 | public static function getPropNames( $license ) { |
135 | if ( $license === self::LICENSE_FREE ) { |
136 | return self::getPropName( true ); |
137 | } |
138 | return [ self::getPropName( true ), self::getPropName( false ) ]; |
139 | } |
140 | |
141 | /** |
142 | * Return page image for a given title |
143 | * |
144 | * @param Title $title Title to get page image for |
145 | * @return File|false |
146 | */ |
147 | public static function getPageImage( Title $title ) { |
148 | // Cast any cacheable null to false |
149 | return self::factory()->getPageImageInternal( $title ) ?? false; |
150 | } |
151 | |
152 | /** |
153 | * Return page image for a given title |
154 | * |
155 | * @param Title $title Title to get page image for |
156 | * @return File|null |
157 | */ |
158 | public function getPageImageInternal( Title $title ): ?File { |
159 | self::$cache ??= new MapCacheLRU( 100 ); |
160 | |
161 | $file = self::$cache->getWithSetCallback( |
162 | CacheKeyHelper::getKeyForPage( $title ), |
163 | fn () => $this->fetchPageImage( $title ) |
164 | ); |
165 | |
166 | // Cast false to null |
167 | return $file ?: null; |
168 | } |
169 | |
170 | /** |
171 | * @param Title $title Title to get page image for |
172 | * @return File|null|false |
173 | */ |
174 | private function fetchPageImage( Title $title ) { |
175 | if ( !$title->canExist() ) { |
176 | // Optimization: Do not query for special pages or other titles never in the database |
177 | return false; |
178 | } |
179 | |
180 | if ( $title->inNamespace( NS_FILE ) ) { |
181 | return $this->repoGroup->findFile( $title ); |
182 | } |
183 | |
184 | $pageId = $title->getArticleID(); |
185 | if ( !$pageId ) { |
186 | // No page id to select from |
187 | // Allow caching, cast null to false later |
188 | return null; |
189 | } |
190 | |
191 | $dbr = $this->dbProvider->getReplicaDatabase(); |
192 | $fileName = $dbr->newSelectQueryBuilder() |
193 | ->select( 'pp_value' ) |
194 | ->from( 'page_props' ) |
195 | ->where( [ |
196 | 'pp_page' => $pageId, |
197 | 'pp_propname' => [ self::PROP_NAME, self::PROP_NAME_FREE ] |
198 | ] ) |
199 | ->orderBy( 'pp_propname' ) |
200 | ->caller( __METHOD__ ) |
201 | ->fetchField(); |
202 | if ( !$fileName ) { |
203 | // Return not found without caching. |
204 | return false; |
205 | } |
206 | |
207 | return $this->repoGroup->findFile( $fileName ); |
208 | } |
209 | |
210 | /** |
211 | * InfoAction hook handler, adds the page image to the info=action page |
212 | * |
213 | * @see https://www.mediawiki.org/wiki/Manual:Hooks/InfoAction |
214 | * |
215 | * @param IContextSource $context Context, used to extract the title of the page |
216 | * @param array[] &$pageInfo Auxillary information about the page. |
217 | */ |
218 | public function onInfoAction( $context, &$pageInfo ) { |
219 | $imageFile = $this->getPageImageInternal( $context->getTitle() ); |
220 | if ( !$imageFile ) { |
221 | // The page has no image |
222 | return; |
223 | } |
224 | |
225 | $thumbSetting = $this->userOptionsLookup->getOption( $context->getUser(), 'thumbsize' ); |
226 | $thumbSize = $this->config->get( MainConfigNames::ThumbLimits )[$thumbSetting]; |
227 | |
228 | $thumb = $imageFile->transform( [ 'width' => $thumbSize ] ); |
229 | if ( !$thumb ) { |
230 | return; |
231 | } |
232 | $imageHtml = $thumb->toHtml( |
233 | [ |
234 | 'alt' => $imageFile->getTitle()->getText(), |
235 | 'desc-link' => true, |
236 | ] |
237 | ); |
238 | |
239 | $pageInfo['header-basic'][] = [ |
240 | $context->msg( 'pageimages-info-label' ), |
241 | $imageHtml |
242 | ]; |
243 | } |
244 | |
245 | /** |
246 | * ApiOpenSearchSuggest hook handler, enhances ApiOpenSearch results with this extension's data |
247 | * |
248 | * @param array[] &$results Array of results to add page images too |
249 | */ |
250 | public function onApiOpenSearchSuggest( &$results ) { |
251 | if ( !$this->config->get( 'PageImagesExpandOpenSearchXml' ) || !count( $results ) ) { |
252 | return; |
253 | } |
254 | |
255 | $pageIds = array_keys( $results ); |
256 | $data = self::getImages( $pageIds, 50 ); |
257 | foreach ( $pageIds as $id ) { |
258 | if ( isset( $data[$id]['thumbnail'] ) ) { |
259 | $results[$id]['image'] = $data[$id]['thumbnail']; |
260 | } else { |
261 | $results[$id]['image'] = null; |
262 | } |
263 | } |
264 | } |
265 | |
266 | /** |
267 | * Returns image information for pages with given ids |
268 | * |
269 | * @param int[] $pageIds |
270 | * @param int $size |
271 | * |
272 | * @return array[] |
273 | */ |
274 | public static function getImages( array $pageIds, $size = 0 ) { |
275 | $ret = []; |
276 | foreach ( array_chunk( $pageIds, ApiBase::LIMIT_SML1 ) as $chunk ) { |
277 | $request = [ |
278 | 'action' => 'query', |
279 | 'prop' => 'pageimages', |
280 | 'piprop' => 'name', |
281 | 'pageids' => implode( '|', $chunk ), |
282 | 'pilimit' => 'max', |
283 | ]; |
284 | |
285 | if ( $size ) { |
286 | $request['piprop'] = 'thumbnail'; |
287 | $request['pithumbsize'] = $size; |
288 | } |
289 | |
290 | $api = new ApiMain( new FauxRequest( $request ) ); |
291 | $api->execute(); |
292 | |
293 | $ret += (array)$api->getResult()->getResultData( |
294 | [ 'query', 'pages' ], [ 'Strip' => 'base' ] |
295 | ); |
296 | } |
297 | return $ret; |
298 | } |
299 | |
300 | /** |
301 | * @param OutputPage $out The page being output. |
302 | * @param Skin $skin Skin object used to generate the page. Ignored |
303 | */ |
304 | public function onBeforePageDisplay( $out, $skin ): void { |
305 | if ( !$out->getConfig()->get( 'PageImagesOpenGraph' ) ) { |
306 | return; |
307 | } |
308 | $imageFile = $this->getPageImageInternal( $out->getContext()->getTitle() ); |
309 | if ( !$imageFile ) { |
310 | $fallback = $out->getConfig()->get( 'PageImagesOpenGraphFallbackImage' ); |
311 | if ( $fallback ) { |
312 | $out->addMeta( 'og:image', wfExpandUrl( $fallback, PROTO_CANONICAL ) ); |
313 | } |
314 | return; |
315 | } |
316 | |
317 | // Open Graph protocol -- https://ogp.me/ |
318 | // Multiple images are supported according to https://ogp.me/#array |
319 | // See https://developers.facebook.com/docs/sharing/best-practices?locale=en_US#images |
320 | // See T282065: WhatsApp expects an image <300kB |
321 | foreach ( [ 1200, 800, 640 ] as $width ) { |
322 | $thumb = $imageFile->transform( [ 'width' => $width ] ); |
323 | if ( !$thumb ) { |
324 | continue; |
325 | } |
326 | $out->addMeta( 'og:image', wfExpandUrl( $thumb->getUrl(), PROTO_CANONICAL ) ); |
327 | $out->addMeta( 'og:image:width', strval( $thumb->getWidth() ) ); |
328 | $out->addMeta( 'og:image:height', strval( $thumb->getHeight() ) ); |
329 | } |
330 | } |
331 | |
332 | } |