Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
71.31% |
169 / 237 |
|
31.25% |
5 / 16 |
CRAP | |
0.00% |
0 / 1 |
TitleLibrary | |
71.31% |
169 / 237 |
|
31.25% |
5 / 16 |
198.87 | |
0.00% |
0 / 1 |
register | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
2 | |||
checkNamespace | |
75.00% |
12 / 16 |
|
0.00% |
0 / 1 |
7.77 | |||
getInexpensiveTitleData | |
78.95% |
15 / 19 |
|
0.00% |
0 / 1 |
6.34 | |||
getExpensiveData | |
87.50% |
21 / 24 |
|
0.00% |
0 / 1 |
7.10 | |||
newTitle | |
90.00% |
18 / 20 |
|
0.00% |
0 / 1 |
9.08 | |||
makeTitle | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
2.01 | |||
getUrl | |
86.96% |
20 / 23 |
|
0.00% |
0 / 1 |
7.11 | |||
getContentInternal | |
47.83% |
11 / 23 |
|
0.00% |
0 / 1 |
13.96 | |||
getContent | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
getFileInfo | |
0.00% |
0 / 34 |
|
0.00% |
0 / 1 |
72 | |||
makeArrayOneBased | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
protectionLevels | |
81.82% |
9 / 11 |
|
0.00% |
0 / 1 |
3.05 | |||
cascadingProtection | |
90.00% |
18 / 20 |
|
0.00% |
0 / 1 |
3.01 | |||
redirectTarget | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
recordVaryFlag | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
getPageLangCode | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
2.03 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\Scribunto\Engines\LuaCommon; |
4 | |
5 | use Content; |
6 | use LogicException; |
7 | use MediaWiki\Logger\LoggerFactory; |
8 | use MediaWiki\MediaWikiServices; |
9 | use MediaWiki\Parser\ParserOutputFlags; |
10 | use MediaWiki\Revision\RevisionAccessException; |
11 | use MediaWiki\Revision\SlotRecord; |
12 | use MediaWiki\Title\Title; |
13 | |
14 | class TitleLibrary extends LibraryBase { |
15 | // Note these caches are naturally limited to |
16 | // $wgExpensiveParserFunctionLimit + 1 actual Title objects because any |
17 | // addition besides the one for the current page calls |
18 | // incrementExpensiveFunctionCount() |
19 | /** @var Title[] */ |
20 | private $titleCache = []; |
21 | /** @var (Title|null)[] */ |
22 | private $idCache = [ 0 => null ]; |
23 | |
24 | public function register() { |
25 | $lib = [ |
26 | 'newTitle' => [ $this, 'newTitle' ], |
27 | 'makeTitle' => [ $this, 'makeTitle' ], |
28 | 'getExpensiveData' => [ $this, 'getExpensiveData' ], |
29 | 'getUrl' => [ $this, 'getUrl' ], |
30 | 'getContent' => [ $this, 'getContent' ], |
31 | 'getFileInfo' => [ $this, 'getFileInfo' ], |
32 | 'protectionLevels' => [ $this, 'protectionLevels' ], |
33 | 'cascadingProtection' => [ $this, 'cascadingProtection' ], |
34 | 'redirectTarget' => [ $this, 'redirectTarget' ], |
35 | 'recordVaryFlag' => [ $this, 'recordVaryFlag' ], |
36 | 'getPageLangCode' => [ $this, 'getPageLangCode' ], |
37 | ]; |
38 | $title = $this->getTitle(); |
39 | return $this->getEngine()->registerInterface( 'mw.title.lua', $lib, [ |
40 | 'thisTitle' => $title ? $this->getInexpensiveTitleData( $title ) : null, |
41 | 'NS_MEDIA' => NS_MEDIA, |
42 | ] ); |
43 | } |
44 | |
45 | /** |
46 | * Check a namespace parameter |
47 | * @param string $name Function name (for errors) |
48 | * @param int $argIdx Argument index (for errors) |
49 | * @param mixed &$arg Argument |
50 | * @param int|null $default Default value, if $arg is null |
51 | */ |
52 | private function checkNamespace( $name, $argIdx, &$arg, $default = null ) { |
53 | if ( $arg === null && $default !== null ) { |
54 | $arg = $default; |
55 | } elseif ( is_numeric( $arg ) ) { |
56 | $arg = (int)$arg; |
57 | if ( !MediaWikiServices::getInstance()->getNamespaceInfo()->exists( $arg ) ) { |
58 | throw new LuaError( |
59 | "bad argument #$argIdx to '$name' (unrecognized namespace number '$arg')" |
60 | ); |
61 | } |
62 | } elseif ( is_string( $arg ) ) { |
63 | $ns = MediaWikiServices::getInstance()->getContentLanguage()->getNsIndex( $arg ); |
64 | if ( $ns === false ) { |
65 | throw new LuaError( |
66 | "bad argument #$argIdx to '$name' (unrecognized namespace name '$arg')" |
67 | ); |
68 | } |
69 | $arg = $ns; |
70 | } else { |
71 | $this->checkType( $name, $argIdx, $arg, 'namespace number or name' ); |
72 | } |
73 | } |
74 | |
75 | /** |
76 | * Extract inexpensive information from a Title object for return to Lua |
77 | * |
78 | * @param Title $title Title to return |
79 | * @return array Lua data |
80 | */ |
81 | private function getInexpensiveTitleData( Title $title ) { |
82 | $ns = $title->getNamespace(); |
83 | $ret = [ |
84 | 'isCurrentTitle' => (bool)$title->equals( $this->getTitle() ), |
85 | 'isLocal' => (bool)$title->isLocal(), |
86 | 'interwiki' => $title->getInterwiki(), |
87 | 'namespace' => $ns, |
88 | 'nsText' => $title->getNsText(), |
89 | 'text' => $title->getText(), |
90 | 'fragment' => $title->getFragment(), |
91 | 'thePartialUrl' => $title->getPartialURL(), |
92 | ]; |
93 | if ( $ns === NS_SPECIAL ) { |
94 | // Core doesn't currently record special page links, but it may in the future. |
95 | if ( $this->getParser() && !$title->equals( $this->getTitle() ) ) { |
96 | $this->getParser()->getOutput()->addLink( $title ); |
97 | } |
98 | $ret['exists'] = MediaWikiServices::getInstance() |
99 | ->getSpecialPageFactory()->exists( $title->getDBkey() ); |
100 | } |
101 | if ( $ns !== NS_FILE && $ns !== NS_MEDIA ) { |
102 | $ret['file'] = false; |
103 | } |
104 | return $ret; |
105 | } |
106 | |
107 | /** |
108 | * Extract expensive information from a Title object for return to Lua |
109 | * |
110 | * This records a link to this title in the current ParserOutput and caches the |
111 | * title for repeated lookups. It may call incrementExpensiveFunctionCount() if |
112 | * the title is not already cached. |
113 | * |
114 | * @internal |
115 | * @param string $text Title text |
116 | * @return array Lua data |
117 | */ |
118 | public function getExpensiveData( $text ) { |
119 | $this->checkType( 'getExpensiveData', 1, $text, 'string' ); |
120 | $title = Title::newFromText( $text ); |
121 | if ( !$title ) { |
122 | return [ null ]; |
123 | } |
124 | $dbKey = $title->getPrefixedDBkey(); |
125 | if ( isset( $this->titleCache[$dbKey] ) ) { |
126 | // It was already cached, so we already did the expensive work and added a link |
127 | $title = $this->titleCache[$dbKey]; |
128 | } else { |
129 | if ( !$title->equals( $this->getTitle() ) ) { |
130 | $this->incrementExpensiveFunctionCount(); |
131 | |
132 | // Record a link |
133 | if ( $this->getParser() ) { |
134 | $this->getParser()->getOutput()->addLink( $title ); |
135 | } |
136 | } |
137 | |
138 | // Cache it |
139 | $this->titleCache[$dbKey] = $title; |
140 | if ( $title->getArticleID() > 0 ) { |
141 | $this->idCache[$title->getArticleID()] = $title; |
142 | } |
143 | } |
144 | |
145 | $ret = [ |
146 | 'isRedirect' => (bool)$title->isRedirect(), |
147 | 'id' => $title->getArticleID(), |
148 | 'contentModel' => $title->getContentModel(), |
149 | ]; |
150 | if ( $title->getNamespace() === NS_SPECIAL ) { |
151 | $ret['exists'] = MediaWikiServices::getInstance() |
152 | ->getSpecialPageFactory()->exists( $title->getDBkey() ); |
153 | } else { |
154 | // bug 70495: don't just check whether the ID != 0 |
155 | $ret['exists'] = $title->exists(); |
156 | } |
157 | return [ $ret ]; |
158 | } |
159 | |
160 | /** |
161 | * Handler for title.new |
162 | * |
163 | * Calls Title::newFromID or Title::newFromTitle as appropriate for the |
164 | * arguments. |
165 | * |
166 | * @internal |
167 | * @param string|int $text_or_id Title or page_id to fetch |
168 | * @param string|int|null $defaultNamespace Namespace name or number to use if |
169 | * $text_or_id doesn't override |
170 | * @return array Lua data |
171 | */ |
172 | public function newTitle( $text_or_id, $defaultNamespace = null ) { |
173 | $type = $this->getLuaType( $text_or_id ); |
174 | if ( $type === 'number' ) { |
175 | if ( array_key_exists( $text_or_id, $this->idCache ) ) { |
176 | $title = $this->idCache[$text_or_id]; |
177 | } else { |
178 | $this->incrementExpensiveFunctionCount(); |
179 | $title = Title::newFromID( $text_or_id ); |
180 | $this->idCache[$text_or_id] = $title; |
181 | |
182 | // Record a link |
183 | if ( $title && $this->getParser() && !$title->equals( $this->getTitle() ) ) { |
184 | $this->getParser()->getOutput()->addLink( $title ); |
185 | } |
186 | } |
187 | if ( $title ) { |
188 | $this->titleCache[$title->getPrefixedDBkey()] = $title; |
189 | } else { |
190 | return [ null ]; |
191 | } |
192 | } elseif ( $type === 'string' ) { |
193 | $this->checkNamespace( 'title.new', 2, $defaultNamespace, NS_MAIN ); |
194 | |
195 | // Note this just fills in the given fields, it doesn't fetch from |
196 | // the page table. |
197 | $title = Title::newFromText( $text_or_id, $defaultNamespace ); |
198 | if ( !$title ) { |
199 | return [ null ]; |
200 | } |
201 | } else { |
202 | $this->checkType( 'title.new', 1, $text_or_id, 'number or string' ); |
203 | throw new LogicException( 'checkType above should have failed' ); |
204 | } |
205 | |
206 | return [ $this->getInexpensiveTitleData( $title ) ]; |
207 | } |
208 | |
209 | /** |
210 | * Handler for title.makeTitle |
211 | * |
212 | * Calls Title::makeTitleSafe. |
213 | * |
214 | * @internal |
215 | * @param string|int $ns Namespace |
216 | * @param string $text Title text |
217 | * @param string|null $fragment URI fragment |
218 | * @param string|null $interwiki Interwiki code |
219 | * @return array Lua data |
220 | */ |
221 | public function makeTitle( $ns, $text, $fragment = null, $interwiki = null ) { |
222 | $this->checkNamespace( 'makeTitle', 1, $ns ); |
223 | $this->checkType( 'makeTitle', 2, $text, 'string' ); |
224 | $this->checkTypeOptional( 'makeTitle', 3, $fragment, 'string', '' ); |
225 | $this->checkTypeOptional( 'makeTitle', 4, $interwiki, 'string', '' ); |
226 | |
227 | // Note this just fills in the given fields, it doesn't fetch from the |
228 | // page table. |
229 | $title = Title::makeTitleSafe( $ns, $text, $fragment, $interwiki ); |
230 | if ( !$title ) { |
231 | return [ null ]; |
232 | } |
233 | |
234 | return [ $this->getInexpensiveTitleData( $title ) ]; |
235 | } |
236 | |
237 | /** |
238 | * Get a URL referring to this title |
239 | * @internal |
240 | * @param string $text Title text. |
241 | * @param string $which 'fullUrl', 'localUrl', or 'canonicalUrl' |
242 | * @param string|array|null $query Query string or query string data. |
243 | * @param string|null $proto 'http', 'https', 'relative', or 'canonical' |
244 | * @return array |
245 | */ |
246 | public function getUrl( $text, $which, $query = null, $proto = null ) { |
247 | static $protoMap = [ |
248 | 'http' => PROTO_HTTP, |
249 | 'https' => PROTO_HTTPS, |
250 | 'relative' => PROTO_RELATIVE, |
251 | 'canonical' => PROTO_CANONICAL, |
252 | ]; |
253 | |
254 | $this->checkType( 'getUrl', 1, $text, 'string' ); |
255 | $this->checkType( 'getUrl', 2, $which, 'string' ); |
256 | if ( !in_array( $which, [ 'fullUrl', 'localUrl', 'canonicalUrl' ], true ) ) { |
257 | $this->checkType( 'getUrl', 2, $which, "'fullUrl', 'localUrl', or 'canonicalUrl'" ); |
258 | } |
259 | |
260 | // May call the following Title methods: |
261 | // getFullUrl, getLocalUrl, getCanonicalUrl |
262 | $func = "get" . ucfirst( $which ); |
263 | |
264 | $args = [ $query, false ]; |
265 | if ( !is_string( $query ) && !is_array( $query ) ) { |
266 | $this->checkTypeOptional( $which, 1, $query, 'table or string', '' ); |
267 | } |
268 | if ( $which === 'fullUrl' ) { |
269 | $this->checkTypeOptional( $which, 2, $proto, 'string', 'relative' ); |
270 | if ( !isset( $protoMap[$proto] ) ) { |
271 | $this->checkType( $which, 2, $proto, "'http', 'https', 'relative', or 'canonical'" ); |
272 | } |
273 | $args[] = $protoMap[$proto]; |
274 | } |
275 | |
276 | $title = Title::newFromText( $text ); |
277 | if ( !$title ) { |
278 | return [ null ]; |
279 | } |
280 | return [ $title->$func( ...$args ) ]; |
281 | } |
282 | |
283 | /** |
284 | * Utility to get a Content object from a title |
285 | * |
286 | * The title is counted as a transclusion. |
287 | * |
288 | * @param string $text Title text |
289 | * @return Content|null The Content object of the title, null if missing |
290 | */ |
291 | private function getContentInternal( $text ) { |
292 | $title = Title::newFromText( $text ); |
293 | if ( !$title || $title->isExternal() ) { |
294 | return null; |
295 | } |
296 | |
297 | // Record in templatelinks, so edits cause the page to be refreshed |
298 | $this->getParser()->getOutput()->addTemplate( |
299 | $title, $title->getArticleID(), $title->getLatestRevID() |
300 | ); |
301 | |
302 | $rev = $this->getParser()->fetchCurrentRevisionRecordOfTitle( $title ); |
303 | |
304 | if ( $title->equals( $this->getTitle() ) ) { |
305 | $parserOutput = $this->getParser()->getOutput(); |
306 | $parserOutput->setOutputFlag( ParserOutputFlags::VARY_REVISION_SHA1 ); |
307 | $parserOutput->setRevisionUsedSha1Base36( $rev ? $rev->getSha1() : '' ); |
308 | wfDebug( __METHOD__ . ": set vary-revision-sha1 for '$title'" ); |
309 | } |
310 | |
311 | if ( !$rev ) { |
312 | return null; |
313 | } |
314 | |
315 | try { |
316 | $content = $rev->getContent( SlotRecord::MAIN ); |
317 | } catch ( RevisionAccessException $ex ) { |
318 | $logger = LoggerFactory::getInstance( 'Scribunto' ); |
319 | $logger->warning( |
320 | __METHOD__ . ': Unable to transclude revision content', |
321 | [ 'exception' => $ex ] |
322 | ); |
323 | $content = null; |
324 | } |
325 | return $content; |
326 | } |
327 | |
328 | /** |
329 | * Handler for getContent |
330 | * @internal |
331 | * @param string $text |
332 | * @return string[]|null[] |
333 | */ |
334 | public function getContent( $text ) { |
335 | $this->checkType( 'getContent', 1, $text, 'string' ); |
336 | $content = $this->getContentInternal( $text ); |
337 | return [ $content ? $content->serialize() : null ]; |
338 | } |
339 | |
340 | /** |
341 | * Handler for getFileInfo |
342 | * @internal |
343 | * @param string $text |
344 | * @return array |
345 | */ |
346 | public function getFileInfo( $text ) { |
347 | $this->checkType( 'getFileInfo', 1, $text, 'string' ); |
348 | $title = Title::newFromText( $text ); |
349 | if ( !$title ) { |
350 | return [ false ]; |
351 | } |
352 | $ns = $title->getNamespace(); |
353 | if ( $ns !== NS_FILE && $ns !== NS_MEDIA ) { |
354 | return [ false ]; |
355 | } |
356 | |
357 | $this->incrementExpensiveFunctionCount(); |
358 | $file = MediaWikiServices::getInstance()->getRepoGroup()->findFile( $title ); |
359 | if ( !$file ) { |
360 | return [ [ 'exists' => false ] ]; |
361 | } |
362 | $this->getParser()->getOutput()->addImage( |
363 | $file->getName(), $file->getTimestamp(), $file->getSha1() |
364 | ); |
365 | if ( !$file->exists() ) { |
366 | return [ [ 'exists' => false ] ]; |
367 | } |
368 | $pageCount = $file->pageCount(); |
369 | if ( $pageCount === false ) { |
370 | $pages = null; |
371 | } else { |
372 | $pages = []; |
373 | for ( $i = 1; $i <= $pageCount; ++$i ) { |
374 | $pages[$i] = [ |
375 | 'width' => $file->getWidth( $i ), |
376 | 'height' => $file->getHeight( $i ) |
377 | ]; |
378 | } |
379 | } |
380 | return [ [ |
381 | 'exists' => true, |
382 | 'width' => $file->getWidth(), |
383 | 'height' => $file->getHeight(), |
384 | 'mimeType' => $file->getMimeType(), |
385 | 'length' => $file->getLength(), |
386 | 'size' => $file->getSize(), |
387 | 'pages' => $pages |
388 | ] ]; |
389 | } |
390 | |
391 | /** |
392 | * Renumber an array for return to Lua |
393 | * @param array $arr |
394 | * @return array |
395 | */ |
396 | private static function makeArrayOneBased( $arr ) { |
397 | if ( !$arr ) { |
398 | return $arr; |
399 | } |
400 | return array_combine( range( 1, count( $arr ) ), array_values( $arr ) ); |
401 | } |
402 | |
403 | /** |
404 | * Handler for protectionLevels |
405 | * @internal |
406 | * @param string $text |
407 | * @return array |
408 | */ |
409 | public function protectionLevels( $text ) { |
410 | $this->checkType( 'protectionLevels', 1, $text, 'string' ); |
411 | $title = Title::newFromText( $text ); |
412 | if ( !$title ) { |
413 | return [ null ]; |
414 | } |
415 | |
416 | $restrictionStore = MediaWikiServices::getInstance()->getRestrictionStore(); |
417 | |
418 | if ( !$restrictionStore->areRestrictionsLoaded( $title ) ) { |
419 | $this->incrementExpensiveFunctionCount(); |
420 | } |
421 | return [ array_map( |
422 | [ self::class, 'makeArrayOneBased' ], |
423 | $restrictionStore->getAllRestrictions( $title ) |
424 | ) ]; |
425 | } |
426 | |
427 | /** |
428 | * Handler for cascadingProtection |
429 | * @internal |
430 | * @param string $text |
431 | * @return array |
432 | */ |
433 | public function cascadingProtection( $text ) { |
434 | $this->checkType( 'cascadingProtection', 1, $text, 'string' ); |
435 | $title = Title::newFromText( $text ); |
436 | if ( !$title ) { |
437 | return [ null ]; |
438 | } |
439 | |
440 | $restrictionStore = MediaWikiServices::getInstance()->getRestrictionStore(); |
441 | $titleFormatter = MediaWikiServices::getInstance()->getTitleFormatter(); |
442 | |
443 | if ( !$restrictionStore->areCascadeProtectionSourcesLoaded( $title ) ) { |
444 | $this->incrementExpensiveFunctionCount(); |
445 | } |
446 | |
447 | [ $sources, $restrictions ] = $restrictionStore->getCascadeProtectionSources( $title ); |
448 | |
449 | return [ [ |
450 | 'sources' => self::makeArrayOneBased( array_map( |
451 | static function ( $t ) use ( $titleFormatter ) { |
452 | return $titleFormatter->getPrefixedText( $t ); |
453 | }, |
454 | $sources ) ), |
455 | 'restrictions' => array_map( |
456 | [ self::class, 'makeArrayOneBased' ], |
457 | $restrictions |
458 | ) |
459 | ] ]; |
460 | } |
461 | |
462 | /** |
463 | * Handler for redirectTarget |
464 | * @internal |
465 | * @param string $text |
466 | * @return string[]|null[] |
467 | */ |
468 | public function redirectTarget( $text ) { |
469 | $this->checkType( 'redirectTarget', 1, $text, 'string' ); |
470 | $content = $this->getContentInternal( $text ); |
471 | $redirTitle = $content ? $content->getRedirectTarget() : null; |
472 | return [ $redirTitle ? $this->getInexpensiveTitleData( $redirTitle ) : null ]; |
473 | } |
474 | |
475 | /** |
476 | * Record a ParserOutput flag when the current title is accessed |
477 | * @internal |
478 | * @param string $text |
479 | * @param string $flag |
480 | * @return array |
481 | */ |
482 | public function recordVaryFlag( $text, $flag ) { |
483 | $this->checkType( 'recordVaryFlag', 1, $text, 'string' ); |
484 | $this->checkType( 'recordVaryFlag', 2, $flag, 'string' ); |
485 | $title = Title::newFromText( $text ); |
486 | if ( $title && $title->equals( $this->getTitle() ) ) { |
487 | // XXX note that we don't check this against the values defined |
488 | // in ParserOutputFlags |
489 | $this->getParser()->getOutput()->setOutputFlag( $flag ); |
490 | } |
491 | return []; |
492 | } |
493 | |
494 | /** |
495 | * Handler for getPageLangCode |
496 | * @internal |
497 | * @param string $text Title text. |
498 | * @return array<?string> |
499 | */ |
500 | public function getPageLangCode( $text ) { |
501 | $title = Title::newFromText( $text ); |
502 | if ( $title ) { |
503 | // If the page language is coming from the page record, we've |
504 | // probably accounted for the cost of reading the title from |
505 | // the DB already. However, a PageContentLanguage hook handler |
506 | // might get invoked here, and who knows how much that costs. |
507 | // Be safe and increment here, even though this could over-count. |
508 | $this->incrementExpensiveFunctionCount(); |
509 | return [ $title->getPageLanguage()->getCode() ]; |
510 | } |
511 | return [ null ]; |
512 | } |
513 | } |