100% test coverage for NamespaceInfo
[lhc/web/wiklou.git] / includes / title / NamespaceInfo.php
1 <?php
2 /**
3 * Provide things related to namespaces.
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 */
22
23 use MediaWiki\Config\ServiceOptions;
24
25 /**
26 * This is a utility class for dealing with namespaces that encodes all the "magic" behaviors of
27 * them based on index. The textual names of the namespaces are handled by Language.php.
28 *
29 * @since 1.34
30 */
31 class NamespaceInfo {
32
33 /**
34 * These namespaces should always be first-letter capitalized, now and
35 * forevermore. Historically, they could've probably been lowercased too,
36 * but some things are just too ingrained now. :)
37 */
38 private $alwaysCapitalizedNamespaces = [ NS_SPECIAL, NS_USER, NS_MEDIAWIKI ];
39
40 /** @var string[]|null Canonical namespaces cache */
41 private $canonicalNamespaces = null;
42
43 /** @var array|false Canonical namespaces index cache */
44 private $namespaceIndexes = false;
45
46 /** @var int[]|null Valid namespaces cache */
47 private $validNamespaces = null;
48
49 /** @var ServiceOptions */
50 private $options;
51
52 /**
53 * TODO Make this const when HHVM support is dropped (T192166)
54 *
55 * @since 1.34
56 * @var array
57 */
58 public static $constructorOptions = [
59 'AllowImageMoving',
60 'CanonicalNamespaceNames',
61 'CapitalLinkOverrides',
62 'CapitalLinks',
63 'ContentNamespaces',
64 'ExtraNamespaces',
65 'ExtraSignatureNamespaces',
66 'NamespaceContentModels',
67 'NamespaceProtection',
68 'NamespacesWithSubpages',
69 'NonincludableNamespaces',
70 'RestrictionLevels',
71 ];
72
73 /**
74 * @param ServiceOptions $options
75 */
76 public function __construct( ServiceOptions $options ) {
77 $options->assertRequiredOptions( self::$constructorOptions );
78 $this->options = $options;
79 }
80
81 /**
82 * Throw an exception when trying to get the subject or talk page
83 * for a given namespace where it does not make sense.
84 * Special namespaces are defined in includes/Defines.php and have
85 * a value below 0 (ex: NS_SPECIAL = -1 , NS_MEDIA = -2)
86 *
87 * @param int $index
88 * @param string $method
89 *
90 * @throws MWException
91 * @return bool
92 */
93 private function isMethodValidFor( $index, $method ) {
94 if ( $index < NS_MAIN ) {
95 throw new MWException( "$method does not make any sense for given namespace $index" );
96 }
97 return true;
98 }
99
100 /**
101 * Can pages in the given namespace be moved?
102 *
103 * @param int $index Namespace index
104 * @return bool
105 */
106 public function isMovable( $index ) {
107 $result = $index >= NS_MAIN &&
108 ( $index != NS_FILE || $this->options->get( 'AllowImageMoving' ) );
109
110 /**
111 * @since 1.20
112 */
113 Hooks::run( 'NamespaceIsMovable', [ $index, &$result ] );
114
115 return $result;
116 }
117
118 /**
119 * Is the given namespace is a subject (non-talk) namespace?
120 *
121 * @param int $index Namespace index
122 * @return bool
123 */
124 public function isSubject( $index ) {
125 return !$this->isTalk( $index );
126 }
127
128 /**
129 * Is the given namespace a talk namespace?
130 *
131 * @param int $index Namespace index
132 * @return bool
133 */
134 public function isTalk( $index ) {
135 return $index > NS_MAIN
136 && $index % 2;
137 }
138
139 /**
140 * Get the talk namespace index for a given namespace
141 *
142 * @param int $index Namespace index
143 * @return int
144 */
145 public function getTalk( $index ) {
146 $this->isMethodValidFor( $index, __METHOD__ );
147 return $this->isTalk( $index )
148 ? $index
149 : $index + 1;
150 }
151
152 /**
153 * Get the subject namespace index for a given namespace
154 * Special namespaces (NS_MEDIA, NS_SPECIAL) are always the subject.
155 *
156 * @param int $index Namespace index
157 * @return int
158 */
159 public function getSubject( $index ) {
160 # Handle special namespaces
161 if ( $index < NS_MAIN ) {
162 return $index;
163 }
164
165 return $this->isTalk( $index )
166 ? $index - 1
167 : $index;
168 }
169
170 /**
171 * Get the associated namespace.
172 * For talk namespaces, returns the subject (non-talk) namespace
173 * For subject (non-talk) namespaces, returns the talk namespace
174 *
175 * @param int $index Namespace index
176 * @return int
177 */
178 public function getAssociated( $index ) {
179 $this->isMethodValidFor( $index, __METHOD__ );
180
181 if ( $this->isSubject( $index ) ) {
182 return $this->getTalk( $index );
183 }
184 return $this->getSubject( $index );
185 }
186
187 /**
188 * Returns whether the specified namespace exists
189 *
190 * @param int $index
191 *
192 * @return bool
193 */
194 public function exists( $index ) {
195 $nslist = $this->getCanonicalNamespaces();
196 return isset( $nslist[$index] );
197 }
198
199 /**
200 * Returns whether the specified namespaces are the same namespace
201 *
202 * @note It's possible that in the future we may start using something
203 * other than just namespace indexes. Under that circumstance making use
204 * of this function rather than directly doing comparison will make
205 * sure that code will not potentially break.
206 *
207 * @param int $ns1 The first namespace index
208 * @param int $ns2 The second namespace index
209 *
210 * @return bool
211 */
212 public function equals( $ns1, $ns2 ) {
213 return $ns1 == $ns2;
214 }
215
216 /**
217 * Returns whether the specified namespaces share the same subject.
218 * eg: NS_USER and NS_USER wil return true, as well
219 * NS_USER and NS_USER_TALK will return true.
220 *
221 * @param int $ns1 The first namespace index
222 * @param int $ns2 The second namespace index
223 *
224 * @return bool
225 */
226 public function subjectEquals( $ns1, $ns2 ) {
227 return $this->getSubject( $ns1 ) == $this->getSubject( $ns2 );
228 }
229
230 /**
231 * Returns array of all defined namespaces with their canonical
232 * (English) names.
233 *
234 * @return array
235 */
236 public function getCanonicalNamespaces() {
237 if ( $this->canonicalNamespaces === null ) {
238 $this->canonicalNamespaces =
239 [ NS_MAIN => '' ] + $this->options->get( 'CanonicalNamespaceNames' );
240 $this->canonicalNamespaces +=
241 ExtensionRegistry::getInstance()->getAttribute( 'ExtensionNamespaces' );
242 if ( is_array( $this->options->get( 'ExtraNamespaces' ) ) ) {
243 $this->canonicalNamespaces += $this->options->get( 'ExtraNamespaces' );
244 }
245 Hooks::run( 'CanonicalNamespaces', [ &$this->canonicalNamespaces ] );
246 }
247 return $this->canonicalNamespaces;
248 }
249
250 /**
251 * Returns the canonical (English) name for a given index
252 *
253 * @param int $index Namespace index
254 * @return string|bool If no canonical definition.
255 */
256 public function getCanonicalName( $index ) {
257 $nslist = $this->getCanonicalNamespaces();
258 return $nslist[$index] ?? false;
259 }
260
261 /**
262 * Returns the index for a given canonical name, or NULL
263 * The input *must* be converted to lower case first
264 *
265 * @param string $name Namespace name
266 * @return int|null
267 */
268 public function getCanonicalIndex( $name ) {
269 if ( $this->namespaceIndexes === false ) {
270 $this->namespaceIndexes = [];
271 foreach ( $this->getCanonicalNamespaces() as $i => $text ) {
272 $this->namespaceIndexes[strtolower( $text )] = $i;
273 }
274 }
275 if ( array_key_exists( $name, $this->namespaceIndexes ) ) {
276 return $this->namespaceIndexes[$name];
277 } else {
278 return null;
279 }
280 }
281
282 /**
283 * Returns an array of the namespaces (by integer id) that exist on the wiki. Used primarily by
284 * the API in help documentation. The array is sorted numerically and omits negative namespaces.
285 * @return array
286 */
287 public function getValidNamespaces() {
288 if ( is_null( $this->validNamespaces ) ) {
289 foreach ( array_keys( $this->getCanonicalNamespaces() ) as $ns ) {
290 if ( $ns >= 0 ) {
291 $this->validNamespaces[] = $ns;
292 }
293 }
294 // T109137: sort numerically
295 sort( $this->validNamespaces, SORT_NUMERIC );
296 }
297
298 return $this->validNamespaces;
299 }
300
301 /*
302
303 /**
304 * Does this namespace ever have a talk namespace?
305 *
306 * @param int $index Namespace ID
307 * @return bool True if this namespace either is or has a corresponding talk namespace.
308 */
309 public function hasTalkNamespace( $index ) {
310 return $index >= NS_MAIN;
311 }
312
313 /**
314 * Does this namespace contain content, for the purposes of calculating
315 * statistics, etc?
316 *
317 * @param int $index Index to check
318 * @return bool
319 */
320 public function isContent( $index ) {
321 return $index == NS_MAIN || in_array( $index, $this->options->get( 'ContentNamespaces' ) );
322 }
323
324 /**
325 * Might pages in this namespace require the use of the Signature button on
326 * the edit toolbar?
327 *
328 * @param int $index Index to check
329 * @return bool
330 */
331 public function wantSignatures( $index ) {
332 return $this->isTalk( $index ) ||
333 in_array( $index, $this->options->get( 'ExtraSignatureNamespaces' ) );
334 }
335
336 /**
337 * Can pages in a namespace be watched?
338 *
339 * @param int $index
340 * @return bool
341 */
342 public function isWatchable( $index ) {
343 return $index >= NS_MAIN;
344 }
345
346 /**
347 * Does the namespace allow subpages?
348 *
349 * @param int $index Index to check
350 * @return bool
351 */
352 public function hasSubpages( $index ) {
353 return !empty( $this->options->get( 'NamespacesWithSubpages' )[$index] );
354 }
355
356 /**
357 * Get a list of all namespace indices which are considered to contain content
358 * @return array Array of namespace indices
359 */
360 public function getContentNamespaces() {
361 $contentNamespaces = $this->options->get( 'ContentNamespaces' );
362 if ( !is_array( $contentNamespaces ) || $contentNamespaces === [] ) {
363 return [ NS_MAIN ];
364 } elseif ( !in_array( NS_MAIN, $contentNamespaces ) ) {
365 // always force NS_MAIN to be part of array (to match the algorithm used by isContent)
366 return array_merge( [ NS_MAIN ], $contentNamespaces );
367 } else {
368 return $contentNamespaces;
369 }
370 }
371
372 /**
373 * List all namespace indices which are considered subject, aka not a talk
374 * or special namespace. See also NamespaceInfo::isSubject
375 *
376 * @return array Array of namespace indices
377 */
378 public function getSubjectNamespaces() {
379 return array_filter(
380 $this->getValidNamespaces(),
381 [ $this, 'isSubject' ]
382 );
383 }
384
385 /**
386 * List all namespace indices which are considered talks, aka not a subject
387 * or special namespace. See also NamespaceInfo::isTalk
388 *
389 * @return array Array of namespace indices
390 */
391 public function getTalkNamespaces() {
392 return array_filter(
393 $this->getValidNamespaces(),
394 [ $this, 'isTalk' ]
395 );
396 }
397
398 /**
399 * Is the namespace first-letter capitalized?
400 *
401 * @param int $index Index to check
402 * @return bool
403 */
404 public function isCapitalized( $index ) {
405 // Turn NS_MEDIA into NS_FILE
406 $index = $index === NS_MEDIA ? NS_FILE : $index;
407
408 // Make sure to get the subject of our namespace
409 $index = $this->getSubject( $index );
410
411 // Some namespaces are special and should always be upper case
412 if ( in_array( $index, $this->alwaysCapitalizedNamespaces ) ) {
413 return true;
414 }
415 $overrides = $this->options->get( 'CapitalLinkOverrides' );
416 if ( isset( $overrides[$index] ) ) {
417 // CapitalLinkOverrides is explicitly set
418 return $overrides[$index];
419 }
420 // Default to the global setting
421 return $this->options->get( 'CapitalLinks' );
422 }
423
424 /**
425 * Does the namespace (potentially) have different aliases for different
426 * genders. Not all languages make a distinction here.
427 *
428 * @param int $index Index to check
429 * @return bool
430 */
431 public function hasGenderDistinction( $index ) {
432 return $index == NS_USER || $index == NS_USER_TALK;
433 }
434
435 /**
436 * It is not possible to use pages from this namespace as template?
437 *
438 * @param int $index Index to check
439 * @return bool
440 */
441 public function isNonincludable( $index ) {
442 $namespaces = $this->options->get( 'NonincludableNamespaces' );
443 return $namespaces && in_array( $index, $namespaces );
444 }
445
446 /**
447 * Get the default content model for a namespace
448 * This does not mean that all pages in that namespace have the model
449 *
450 * @note To determine the default model for a new page's main slot, or any slot in general,
451 * use SlotRoleHandler::getDefaultModel() together with SlotRoleRegistry::getRoleHandler().
452 *
453 * @param int $index Index to check
454 * @return null|string Default model name for the given namespace, if set
455 */
456 public function getNamespaceContentModel( $index ) {
457 return $this->options->get( 'NamespaceContentModels' )[$index] ?? null;
458 }
459
460 /**
461 * Determine which restriction levels it makes sense to use in a namespace,
462 * optionally filtered by a user's rights.
463 *
464 * @todo Move this to PermissionManager and remove the dependency here on permissions-related
465 * config settings.
466 *
467 * @param int $index Index to check
468 * @param User|null $user User to check
469 * @return array
470 */
471 public function getRestrictionLevels( $index, User $user = null ) {
472 if ( !isset( $this->options->get( 'NamespaceProtection' )[$index] ) ) {
473 // All levels are valid if there's no namespace restriction.
474 // But still filter by user, if necessary
475 $levels = $this->options->get( 'RestrictionLevels' );
476 if ( $user ) {
477 $levels = array_values( array_filter( $levels, function ( $level ) use ( $user ) {
478 $right = $level;
479 if ( $right == 'sysop' ) {
480 $right = 'editprotected'; // BC
481 }
482 if ( $right == 'autoconfirmed' ) {
483 $right = 'editsemiprotected'; // BC
484 }
485 return ( $right == '' || $user->isAllowed( $right ) );
486 } ) );
487 }
488 return $levels;
489 }
490
491 // First, get the list of groups that can edit this namespace.
492 $namespaceGroups = [];
493 $combine = 'array_merge';
494 foreach ( (array)$this->options->get( 'NamespaceProtection' )[$index] as $right ) {
495 if ( $right == 'sysop' ) {
496 $right = 'editprotected'; // BC
497 }
498 if ( $right == 'autoconfirmed' ) {
499 $right = 'editsemiprotected'; // BC
500 }
501 if ( $right != '' ) {
502 $namespaceGroups = call_user_func( $combine, $namespaceGroups,
503 User::getGroupsWithPermission( $right ) );
504 $combine = 'array_intersect';
505 }
506 }
507
508 // Now, keep only those restriction levels where there is at least one
509 // group that can edit the namespace but would be blocked by the
510 // restriction.
511 $usableLevels = [ '' ];
512 foreach ( $this->options->get( 'RestrictionLevels' ) as $level ) {
513 $right = $level;
514 if ( $right == 'sysop' ) {
515 $right = 'editprotected'; // BC
516 }
517 if ( $right == 'autoconfirmed' ) {
518 $right = 'editsemiprotected'; // BC
519 }
520 if ( $right != '' && ( !$user || $user->isAllowed( $right ) ) &&
521 array_diff( $namespaceGroups, User::getGroupsWithPermission( $right ) )
522 ) {
523 $usableLevels[] = $level;
524 }
525 }
526
527 return $usableLevels;
528 }
529
530 /**
531 * Returns the link type to be used for categories.
532 *
533 * This determines which section of a category page titles
534 * in the namespace will appear within.
535 *
536 * @param int $index Namespace index
537 * @return string One of 'subcat', 'file', 'page'
538 */
539 public function getCategoryLinkType( $index ) {
540 $this->isMethodValidFor( $index, __METHOD__ );
541
542 if ( $index == NS_CATEGORY ) {
543 return 'subcat';
544 } elseif ( $index == NS_FILE ) {
545 return 'file';
546 } else {
547 return 'page';
548 }
549 }
550 }