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
23use MediaWiki\Config\ServiceOptions;
24use MediaWiki\HookContainer\HookContainer;
25use MediaWiki\HookContainer\HookRunner;
26use MediaWiki\Linker\LinkTarget;
27use MediaWiki\MediaWikiServices;
28
29/**
30 * This is a utility class for dealing with namespaces that encodes all the "magic" behaviors of
31 * them based on index.  The textual names of the namespaces are handled by Language.php.
32 *
33 * @since 1.34
34 */
35class NamespaceInfo {
36
37	/**
38	 * These namespaces should always be first-letter capitalized, now and
39	 * forevermore. Historically, they could've probably been lowercased too,
40	 * but some things are just too ingrained now. :)
41	 */
42	private $alwaysCapitalizedNamespaces = [ NS_SPECIAL, NS_USER, NS_MEDIAWIKI ];
43
44	/** @var string[]|null Canonical namespaces cache */
45	private $canonicalNamespaces = null;
46
47	/** @var array|false Canonical namespaces index cache */
48	private $namespaceIndexes = false;
49
50	/** @var int[]|null Valid namespaces cache */
51	private $validNamespaces = null;
52
53	/** @var ServiceOptions */
54	private $options;
55
56	/** @var HookRunner */
57	private $hookRunner;
58
59	/**
60	 * Definitions of the NS_ constants are in Defines.php
61	 *
62	 * @internal
63	 */
64	public const CANONICAL_NAMES = [
65		NS_MEDIA            => 'Media',
66		NS_SPECIAL          => 'Special',
67		NS_MAIN             => '',
68		NS_TALK             => 'Talk',
69		NS_USER             => 'User',
70		NS_USER_TALK        => 'User_talk',
71		NS_PROJECT          => 'Project',
72		NS_PROJECT_TALK     => 'Project_talk',
73		NS_FILE             => 'File',
74		NS_FILE_TALK        => 'File_talk',
75		NS_MEDIAWIKI        => 'MediaWiki',
76		NS_MEDIAWIKI_TALK   => 'MediaWiki_talk',
77		NS_TEMPLATE         => 'Template',
78		NS_TEMPLATE_TALK    => 'Template_talk',
79		NS_HELP             => 'Help',
80		NS_HELP_TALK        => 'Help_talk',
81		NS_CATEGORY         => 'Category',
82		NS_CATEGORY_TALK    => 'Category_talk',
83	];
84
85	/**
86	 * @internal For use by ServiceWiring
87	 */
88	public const CONSTRUCTOR_OPTIONS = [
89		'CanonicalNamespaceNames',
90		'CapitalLinkOverrides',
91		'CapitalLinks',
92		'ContentNamespaces',
93		'ExtraNamespaces',
94		'ExtraSignatureNamespaces',
95		'NamespaceContentModels',
96		'NamespacesWithSubpages',
97		'NonincludableNamespaces',
98	];
99
100	/**
101	 * @param ServiceOptions $options
102	 * @param HookContainer $hookContainer
103	 */
104	public function __construct( ServiceOptions $options, HookContainer $hookContainer ) {
105		$options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
106		$this->options = $options;
107		$this->hookRunner = new HookRunner( $hookContainer );
108	}
109
110	/**
111	 * Throw an exception when trying to get the subject or talk page
112	 * for a given namespace where it does not make sense.
113	 * Special namespaces are defined in includes/Defines.php and have
114	 * a value below 0 (ex: NS_SPECIAL = -1 , NS_MEDIA = -2)
115	 *
116	 * @param int $index
117	 * @param string $method
118	 *
119	 * @throws MWException
120	 * @return bool
121	 */
122	private function isMethodValidFor( $index, $method ) {
123		if ( $index < NS_MAIN ) {
124			throw new MWException( "$method does not make any sense for given namespace $index" );
125		}
126		return true;
127	}
128
129	/**
130	 * Throw if given index isn't an integer or integer-like string and so can't be a valid namespace.
131	 *
132	 * @param int|string $index
133	 * @param string $method
134	 *
135	 * @throws InvalidArgumentException
136	 * @return int Cleaned up namespace index
137	 */
138	private function makeValidNamespace( $index, $method ) {
139		if ( !(
140			is_int( $index )
141			// Namespace index numbers as strings
142			|| ctype_digit( $index )
143			// Negative numbers as strings
144			|| ( $index[0] === '-' && ctype_digit( substr( $index, 1 ) ) )
145		) ) {
146			throw new InvalidArgumentException(
147				"$method called with non-integer (" . gettype( $index ) . ") namespace '$index'"
148			);
149		}
150
151		return intval( $index );
152	}
153
154	/**
155	 * Can pages in the given namespace be moved?
156	 *
157	 * @param int $index Namespace index
158	 * @return bool
159	 */
160	public function isMovable( $index ) {
161		$extensionRegistry = ExtensionRegistry::getInstance();
162		$extNamespaces = $extensionRegistry->getAttribute( 'ImmovableNamespaces' );
163
164		$result = $index >= NS_MAIN && !in_array( $index, $extNamespaces );
165
166		/**
167		 * @since 1.20
168		 */
169		$this->hookRunner->onNamespaceIsMovable( $index, $result );
170
171		return $result;
172	}
173
174	/**
175	 * Is the given namespace is a subject (non-talk) namespace?
176	 *
177	 * @param int $index Namespace index
178	 * @return bool
179	 */
180	public function isSubject( $index ) {
181		return !$this->isTalk( $index );
182	}
183
184	/**
185	 * Is the given namespace a talk namespace?
186	 *
187	 * @param int $index Namespace index
188	 * @return bool
189	 */
190	public function isTalk( $index ) {
191		$index = $this->makeValidNamespace( $index, __METHOD__ );
192
193		return $index > NS_MAIN
194			&& $index % 2;
195	}
196
197	/**
198	 * Get the talk namespace index for a given namespace
199	 *
200	 * @param int $index Namespace index
201	 * @return int
202	 * @throws MWException if the given namespace doesn't have an associated talk namespace
203	 *         (e.g. NS_SPECIAL).
204	 */
205	public function getTalk( $index ) {
206		$index = $this->makeValidNamespace( $index, __METHOD__ );
207
208		$this->isMethodValidFor( $index, __METHOD__ );
209		return $this->isTalk( $index )
210			? $index
211			: $index + 1;
212	}
213
214	/**
215	 * Get a LinkTarget referring to the talk page of $target.
216	 *
217	 * @see canHaveTalkPage
218	 * @param LinkTarget $target
219	 * @return LinkTarget Talk page for $target
220	 * @throws MWException if $target doesn't have talk pages, e.g. because it's in NS_SPECIAL,
221	 *         because it's a relative section-only link, or it's an interwiki link.
222	 */
223	public function getTalkPage( LinkTarget $target ) : LinkTarget {
224		if ( $target->getText() === '' ) {
225			throw new MWException( 'Can\'t determine talk page associated with relative section link' );
226		}
227
228		if ( $target->getInterwiki() !== '' ) {
229			throw new MWException( 'Can\'t determine talk page associated with interwiki link' );
230		}
231
232		if ( $this->isTalk( $target->getNamespace() ) ) {
233			return $target;
234		}
235
236		// NOTE: getTalk throws on bad namespaces!
237		return new TitleValue( $this->getTalk( $target->getNamespace() ), $target->getDBkey() );
238	}
239
240	/**
241	 * Can the title have a corresponding talk page?
242	 *
243	 * False for relative section-only links (with getText() === ''),
244	 * interwiki links (with getInterwiki() !== ''), and pages in NS_SPECIAL.
245	 *
246	 * @see getTalkPage
247	 *
248	 * @param LinkTarget $target
249	 * @return bool True if this title either is a talk page or can have a talk page associated.
250	 */
251	public function canHaveTalkPage( LinkTarget $target ) {
252		if ( $target->getText() === '' || $target->getInterwiki() !== '' ) {
253			return false;
254		}
255
256		if ( $target->getNamespace() < NS_MAIN ) {
257			return false;
258		}
259
260		return true;
261	}
262
263	/**
264	 * Get the subject namespace index for a given namespace
265	 * Special namespaces (NS_MEDIA, NS_SPECIAL) are always the subject.
266	 *
267	 * @param int $index Namespace index
268	 * @return int
269	 */
270	public function getSubject( $index ) {
271		$index = $this->makeValidNamespace( $index, __METHOD__ );
272
273		# Handle special namespaces
274		if ( $index < NS_MAIN ) {
275			return $index;
276		}
277
278		return $this->isTalk( $index )
279			? $index - 1
280			: $index;
281	}
282
283	/**
284	 * @param LinkTarget $target
285	 * @return LinkTarget Subject page for $target
286	 */
287	public function getSubjectPage( LinkTarget $target ) : LinkTarget {
288		if ( $this->isSubject( $target->getNamespace() ) ) {
289			return $target;
290		}
291		return new TitleValue( $this->getSubject( $target->getNamespace() ), $target->getDBkey() );
292	}
293
294	/**
295	 * Get the associated namespace.
296	 * For talk namespaces, returns the subject (non-talk) namespace
297	 * For subject (non-talk) namespaces, returns the talk namespace
298	 *
299	 * @param int $index Namespace index
300	 * @return int
301	 * @throws MWException if called on a namespace that has no talk pages (e.g., NS_SPECIAL)
302	 */
303	public function getAssociated( $index ) {
304		$this->isMethodValidFor( $index, __METHOD__ );
305
306		if ( $this->isSubject( $index ) ) {
307			return $this->getTalk( $index );
308		}
309		return $this->getSubject( $index );
310	}
311
312	/**
313	 * @param LinkTarget $target
314	 * @return LinkTarget Talk page for $target if it's a subject page, subject page if it's a talk
315	 *   page
316	 * @throws MWException if $target's namespace doesn't have talk pages (e.g., NS_SPECIAL)
317	 */
318	public function getAssociatedPage( LinkTarget $target ) : LinkTarget {
319		if ( $target->getText() === '' ) {
320			throw new MWException( 'Can\'t determine talk page associated with relative section link' );
321		}
322
323		if ( $target->getInterwiki() !== '' ) {
324			throw new MWException( 'Can\'t determine talk page associated with interwiki link' );
325		}
326
327		return new TitleValue(
328			$this->getAssociated( $target->getNamespace() ), $target->getDBkey() );
329	}
330
331	/**
332	 * Returns whether the specified namespace exists
333	 *
334	 * @param int $index
335	 *
336	 * @return bool
337	 */
338	public function exists( $index ) {
339		$nslist = $this->getCanonicalNamespaces();
340		return isset( $nslist[$index] );
341	}
342
343	/**
344	 * Returns whether the specified namespaces are the same namespace
345	 *
346	 * @note It's possible that in the future we may start using something
347	 * other than just namespace indexes. Under that circumstance making use
348	 * of this function rather than directly doing comparison will make
349	 * sure that code will not potentially break.
350	 *
351	 * @param int $ns1 The first namespace index
352	 * @param int $ns2 The second namespace index
353	 *
354	 * @return bool
355	 */
356	public function equals( $ns1, $ns2 ) {
357		return $ns1 == $ns2;
358	}
359
360	/**
361	 * Returns whether the specified namespaces share the same subject.
362	 * eg: NS_USER and NS_USER wil return true, as well
363	 *     NS_USER and NS_USER_TALK will return true.
364	 *
365	 * @param int $ns1 The first namespace index
366	 * @param int $ns2 The second namespace index
367	 *
368	 * @return bool
369	 */
370	public function subjectEquals( $ns1, $ns2 ) {
371		return $this->getSubject( $ns1 ) == $this->getSubject( $ns2 );
372	}
373
374	/**
375	 * Returns array of all defined namespaces with their canonical
376	 * (English) names.
377	 *
378	 * @return string[]
379	 */
380	public function getCanonicalNamespaces() {
381		if ( $this->canonicalNamespaces === null ) {
382			$this->canonicalNamespaces =
383				[ NS_MAIN => '' ] + $this->options->get( 'CanonicalNamespaceNames' );
384			$this->canonicalNamespaces +=
385				ExtensionRegistry::getInstance()->getAttribute( 'ExtensionNamespaces' );
386			if ( is_array( $this->options->get( 'ExtraNamespaces' ) ) ) {
387				$this->canonicalNamespaces += $this->options->get( 'ExtraNamespaces' );
388			}
389			$this->hookRunner->onCanonicalNamespaces( $this->canonicalNamespaces );
390		}
391		return $this->canonicalNamespaces;
392	}
393
394	/**
395	 * Returns the canonical (English) name for a given index
396	 *
397	 * @param int $index Namespace index
398	 * @return string|bool If no canonical definition.
399	 */
400	public function getCanonicalName( $index ) {
401		$nslist = $this->getCanonicalNamespaces();
402		return $nslist[$index] ?? false;
403	}
404
405	/**
406	 * Returns the index for a given canonical name, or NULL
407	 * The input *must* be converted to lower case first
408	 *
409	 * @param string $name Namespace name
410	 * @return int|null
411	 */
412	public function getCanonicalIndex( $name ) {
413		if ( $this->namespaceIndexes === false ) {
414			$this->namespaceIndexes = [];
415			foreach ( $this->getCanonicalNamespaces() as $i => $text ) {
416				$this->namespaceIndexes[strtolower( $text )] = $i;
417			}
418		}
419		if ( array_key_exists( $name, $this->namespaceIndexes ) ) {
420			return $this->namespaceIndexes[$name];
421		} else {
422			return null;
423		}
424	}
425
426	/**
427	 * Returns an array of the namespaces (by integer id) that exist on the wiki. Used primarily by
428	 * the API in help documentation. The array is sorted numerically and omits negative namespaces.
429	 * @return array
430	 */
431	public function getValidNamespaces() {
432		if ( $this->validNamespaces === null ) {
433			$this->validNamespaces = [];
434			foreach ( array_keys( $this->getCanonicalNamespaces() ) as $ns ) {
435				if ( $ns >= 0 ) {
436					$this->validNamespaces[] = $ns;
437				}
438			}
439			// T109137: sort numerically
440			sort( $this->validNamespaces, SORT_NUMERIC );
441		}
442
443		return $this->validNamespaces;
444	}
445
446	/**
447	 * Does this namespace ever have a talk namespace?
448	 *
449	 * @param int $index Namespace ID
450	 * @return bool True if this namespace either is or has a corresponding talk namespace.
451	 */
452	public function hasTalkNamespace( $index ) {
453		return $index >= NS_MAIN;
454	}
455
456	/**
457	 * Does this namespace contain content, for the purposes of calculating
458	 * statistics, etc?
459	 *
460	 * @param int $index Index to check
461	 * @return bool
462	 */
463	public function isContent( $index ) {
464		return $index == NS_MAIN || in_array( $index, $this->options->get( 'ContentNamespaces' ) );
465	}
466
467	/**
468	 * Might pages in this namespace require the use of the Signature button on
469	 * the edit toolbar?
470	 *
471	 * @param int $index Index to check
472	 * @return bool
473	 */
474	public function wantSignatures( $index ) {
475		return $this->isTalk( $index ) ||
476			in_array( $index, $this->options->get( 'ExtraSignatureNamespaces' ) );
477	}
478
479	/**
480	 * Can pages in a namespace be watched?
481	 *
482	 * @param int $index
483	 * @return bool
484	 */
485	public function isWatchable( $index ) {
486		return $index >= NS_MAIN;
487	}
488
489	/**
490	 * Does the namespace allow subpages? Note that this refers to structured
491	 * handling of subpages, and does not include SpecialPage subpage parameters.
492	 *
493	 * @param int $index Index to check
494	 * @return bool
495	 */
496	public function hasSubpages( $index ) {
497		return !empty( $this->options->get( 'NamespacesWithSubpages' )[$index] );
498	}
499
500	/**
501	 * Get a list of all namespace indices which are considered to contain content
502	 * @return int[] Array of namespace indices
503	 */
504	public function getContentNamespaces() {
505		$contentNamespaces = $this->options->get( 'ContentNamespaces' );
506		if ( !is_array( $contentNamespaces ) || $contentNamespaces === [] ) {
507			return [ NS_MAIN ];
508		} elseif ( !in_array( NS_MAIN, $contentNamespaces ) ) {
509			// always force NS_MAIN to be part of array (to match the algorithm used by isContent)
510			return array_merge( [ NS_MAIN ], $contentNamespaces );
511		} else {
512			return $contentNamespaces;
513		}
514	}
515
516	/**
517	 * List all namespace indices which are considered subject, aka not a talk
518	 * or special namespace. See also NamespaceInfo::isSubject
519	 *
520	 * @return int[] Array of namespace indices
521	 */
522	public function getSubjectNamespaces() {
523		return array_filter(
524			$this->getValidNamespaces(),
525			[ $this, 'isSubject' ]
526		);
527	}
528
529	/**
530	 * List all namespace indices which are considered talks, aka not a subject
531	 * or special namespace. See also NamespaceInfo::isTalk
532	 *
533	 * @return int[] Array of namespace indices
534	 */
535	public function getTalkNamespaces() {
536		return array_filter(
537			$this->getValidNamespaces(),
538			[ $this, 'isTalk' ]
539		);
540	}
541
542	/**
543	 * Is the namespace first-letter capitalized?
544	 *
545	 * @param int $index Index to check
546	 * @return bool
547	 */
548	public function isCapitalized( $index ) {
549		// Turn NS_MEDIA into NS_FILE
550		$index = $index === NS_MEDIA ? NS_FILE : $index;
551
552		// Make sure to get the subject of our namespace
553		$index = $this->getSubject( $index );
554
555		// Some namespaces are special and should always be upper case
556		if ( in_array( $index, $this->alwaysCapitalizedNamespaces ) ) {
557			return true;
558		}
559		$overrides = $this->options->get( 'CapitalLinkOverrides' );
560		if ( isset( $overrides[$index] ) ) {
561			// CapitalLinkOverrides is explicitly set
562			return $overrides[$index];
563		}
564		// Default to the global setting
565		return $this->options->get( 'CapitalLinks' );
566	}
567
568	/**
569	 * Does the namespace (potentially) have different aliases for different
570	 * genders. Not all languages make a distinction here.
571	 *
572	 * @param int $index Index to check
573	 * @return bool
574	 */
575	public function hasGenderDistinction( $index ) {
576		return $index == NS_USER || $index == NS_USER_TALK;
577	}
578
579	/**
580	 * It is not possible to use pages from this namespace as template?
581	 *
582	 * @param int $index Index to check
583	 * @return bool
584	 */
585	public function isNonincludable( $index ) {
586		$namespaces = $this->options->get( 'NonincludableNamespaces' );
587		return $namespaces && in_array( $index, $namespaces );
588	}
589
590	/**
591	 * Get the default content model for a namespace
592	 * This does not mean that all pages in that namespace have the model
593	 *
594	 * @note To determine the default model for a new page's main slot, or any slot in general,
595	 * use SlotRoleHandler::getDefaultModel() together with SlotRoleRegistry::getRoleHandler().
596	 *
597	 * @param int $index Index to check
598	 * @return null|string Default model name for the given namespace, if set
599	 */
600	public function getNamespaceContentModel( $index ) {
601		return $this->options->get( 'NamespaceContentModels' )[$index] ?? null;
602	}
603
604	/**
605	 * Determine which restriction levels it makes sense to use in a namespace,
606	 * optionally filtered by a user's rights.
607	 *
608	 * @deprecated since 1.34 User PermissionManager::getNamespaceRestrictionLevels instead.
609	 * @param int $index Index to check
610	 * @param User|null $user User to check
611	 * @return array
612	 */
613	public function getRestrictionLevels( $index, User $user = null ) {
614		// PermissionManager is not injected because adding an explicit dependency
615		// breaks MW installer by adding a dependency chain on the database before
616		// it was set up. Also, the method is deprecated and will be soon removed.
617		wfDeprecated( __METHOD__, '1.34' );
618		return MediaWikiServices::getInstance()
619			->getPermissionManager()
620			->getNamespaceRestrictionLevels( $index, $user );
621	}
622
623	/**
624	 * Returns the link type to be used for categories.
625	 *
626	 * This determines which section of a category page titles
627	 * in the namespace will appear within.
628	 *
629	 * @param int $index Namespace index
630	 * @return string One of 'subcat', 'file', 'page'
631	 */
632	public function getCategoryLinkType( $index ) {
633		$this->isMethodValidFor( $index, __METHOD__ );
634
635		if ( $index == NS_CATEGORY ) {
636			return 'subcat';
637		} elseif ( $index == NS_FILE ) {
638			return 'file';
639		} else {
640			return 'page';
641		}
642	}
643
644	/**
645	 * Retrieve the indexes for the namespaces defined by core.
646	 *
647	 * @since 1.34
648	 *
649	 * @return int[]
650	 */
651	public static function getCommonNamespaces() {
652		return array_keys( self::CANONICAL_NAMES );
653	}
654}
655