Merge "resourceloader: Improve test cases for MessageBlobStore"
[lhc/web/wiklou.git] / resources / src / mediawiki.Title / Title.js
1 /*!
2 * @author Neil Kandalgaonkar, 2010
3 * @author Timo Tijhof
4 * @since 1.18
5 */
6
7 ( function () {
8 /**
9 * Parse titles into an object structure. Note that when using the constructor
10 * directly, passing invalid titles will result in an exception. Use #newFromText to use the
11 * logic directly and get null for invalid titles which is easier to work with.
12 *
13 * Note that in the constructor and #newFromText method, `namespace` is the **default** namespace
14 * only, and can be overridden by a namespace prefix in `title`. If you do not want this behavior,
15 * use #makeTitle. Compare:
16 *
17 * new mw.Title( 'Foo', NS_TEMPLATE ).getPrefixedText(); // => 'Template:Foo'
18 * mw.Title.newFromText( 'Foo', NS_TEMPLATE ).getPrefixedText(); // => 'Template:Foo'
19 * mw.Title.makeTitle( NS_TEMPLATE, 'Foo' ).getPrefixedText(); // => 'Template:Foo'
20 *
21 * new mw.Title( 'Category:Foo', NS_TEMPLATE ).getPrefixedText(); // => 'Category:Foo'
22 * mw.Title.newFromText( 'Category:Foo', NS_TEMPLATE ).getPrefixedText(); // => 'Category:Foo'
23 * mw.Title.makeTitle( NS_TEMPLATE, 'Category:Foo' ).getPrefixedText(); // => 'Template:Category:Foo'
24 *
25 * new mw.Title( 'Template:Foo', NS_TEMPLATE ).getPrefixedText(); // => 'Template:Foo'
26 * mw.Title.newFromText( 'Template:Foo', NS_TEMPLATE ).getPrefixedText(); // => 'Template:Foo'
27 * mw.Title.makeTitle( NS_TEMPLATE, 'Template:Foo' ).getPrefixedText(); // => 'Template:Template:Foo'
28 *
29 * @class mw.Title
30 */
31
32 /* Private members */
33
34 var
35 mwString = require( 'mediawiki.String' ),
36
37 toUpperMapping = require( './phpCharToUpper.json' ),
38
39 namespaceIds = mw.config.get( 'wgNamespaceIds' ),
40
41 /**
42 * @private
43 * @static
44 * @property NS_MAIN
45 */
46 NS_MAIN = namespaceIds[ '' ],
47
48 /**
49 * @private
50 * @static
51 * @property NS_TALK
52 */
53 NS_TALK = namespaceIds.talk,
54
55 /**
56 * @private
57 * @static
58 * @property NS_SPECIAL
59 */
60 NS_SPECIAL = namespaceIds.special,
61
62 /**
63 * @private
64 * @static
65 * @property NS_MEDIA
66 */
67 NS_MEDIA = namespaceIds.media,
68
69 /**
70 * @private
71 * @static
72 * @property NS_FILE
73 */
74 NS_FILE = namespaceIds.file,
75
76 /**
77 * @private
78 * @static
79 * @property FILENAME_MAX_BYTES
80 */
81 FILENAME_MAX_BYTES = 240,
82
83 /**
84 * @private
85 * @static
86 * @property TITLE_MAX_BYTES
87 */
88 TITLE_MAX_BYTES = 255,
89
90 /**
91 * Get the namespace id from a namespace name (either from the localized, canonical or alias
92 * name).
93 *
94 * Example: On a German wiki this would return 6 for any of 'File', 'Datei', 'Image' or
95 * even 'Bild'.
96 *
97 * @private
98 * @static
99 * @method getNsIdByName
100 * @param {string} ns Namespace name (case insensitive, leading/trailing space ignored)
101 * @return {number|boolean} Namespace id or boolean false
102 */
103 getNsIdByName = function ( ns ) {
104 var id;
105
106 // Don't cast non-strings to strings, because null or undefined should not result in
107 // returning the id of a potential namespace called "Null:" (e.g. on null.example.org/wiki)
108 // Also, toLowerCase throws exception on null/undefined, because it is a String method.
109 if ( typeof ns !== 'string' ) {
110 return false;
111 }
112 // TODO: Should just use local var namespaceIds here but it
113 // breaks test which modify the config
114 id = mw.config.get( 'wgNamespaceIds' )[ ns.toLowerCase() ];
115 if ( id === undefined ) {
116 return false;
117 }
118 return id;
119 },
120
121 /**
122 * @private
123 * @method getNamespacePrefix_
124 * @param {number} namespace
125 * @return {string}
126 */
127 getNamespacePrefix = function ( namespace ) {
128 return namespace === NS_MAIN ?
129 '' :
130 ( mw.config.get( 'wgFormattedNamespaces' )[ namespace ].replace( / /g, '_' ) + ':' );
131 },
132
133 rUnderscoreTrim = /^_+|_+$/g,
134
135 rSplit = /^(.+?)_*:_*(.*)$/,
136
137 // See MediaWikiTitleCodec.php#getTitleInvalidRegex
138 rInvalid = new RegExp(
139 '[^' + mw.config.get( 'wgLegalTitleChars' ) + ']' +
140 // URL percent encoding sequences interfere with the ability
141 // to round-trip titles -- you can't link to them consistently.
142 '|%[0-9A-Fa-f]{2}' +
143 // XML/HTML character references produce similar issues.
144 '|&[A-Za-z0-9\u0080-\uFFFF]+;' +
145 '|&#[0-9]+;' +
146 '|&#x[0-9A-Fa-f]+;'
147 ),
148
149 // From MediaWikiTitleCodec::splitTitleString() in PHP
150 // Note that this is not equivalent to /\s/, e.g. underscore is included, tab is not included.
151 rWhitespace = /[ _\u00A0\u1680\u180E\u2000-\u200A\u2028\u2029\u202F\u205F\u3000]+/g,
152
153 // From MediaWikiTitleCodec::splitTitleString() in PHP
154 rUnicodeBidi = /[\u200E\u200F\u202A-\u202E]/g,
155
156 /**
157 * Slightly modified from Flinfo. Credit goes to Lupo and Flominator.
158 * @private
159 * @static
160 * @property sanitationRules
161 */
162 sanitationRules = [
163 // "signature"
164 {
165 pattern: /~{3}/g,
166 replace: '',
167 generalRule: true
168 },
169 // control characters
170 {
171 // eslint-disable-next-line no-control-regex
172 pattern: /[\x00-\x1f\x7f]/g,
173 replace: '',
174 generalRule: true
175 },
176 // URL encoding (possibly)
177 {
178 pattern: /%([0-9A-Fa-f]{2})/g,
179 replace: '% $1',
180 generalRule: true
181 },
182 // HTML-character-entities
183 {
184 pattern: /&(([A-Za-z0-9\x80-\xff]+|#[0-9]+|#x[0-9A-Fa-f]+);)/g,
185 replace: '& $1',
186 generalRule: true
187 },
188 // slash, colon (not supported by file systems like NTFS/Windows, Mac OS 9 [:], ext4 [/])
189 {
190 pattern: new RegExp( '[' + mw.config.get( 'wgIllegalFileChars', '' ) + ']', 'g' ),
191 replace: '-',
192 fileRule: true
193 },
194 // brackets, greater than
195 {
196 pattern: /[}\]>]/g,
197 replace: ')',
198 generalRule: true
199 },
200 // brackets, lower than
201 {
202 pattern: /[{[<]/g,
203 replace: '(',
204 generalRule: true
205 },
206 // everything that wasn't covered yet
207 {
208 pattern: new RegExp( rInvalid.source, 'g' ),
209 replace: '-',
210 generalRule: true
211 },
212 // directory structures
213 {
214 pattern: /^(\.|\.\.|\.\/.*|\.\.\/.*|.*\/\.\/.*|.*\/\.\.\/.*|.*\/\.|.*\/\.\.)$/g,
215 replace: '',
216 generalRule: true
217 }
218 ],
219
220 /**
221 * Internal helper for #constructor and #newFromText.
222 *
223 * Based on Title.php#secureAndSplit
224 *
225 * @private
226 * @static
227 * @method parse
228 * @param {string} title
229 * @param {number} [defaultNamespace=NS_MAIN]
230 * @return {Object|boolean}
231 */
232 parse = function ( title, defaultNamespace ) {
233 var namespace, m, id, i, fragment, ext;
234
235 namespace = defaultNamespace === undefined ? NS_MAIN : defaultNamespace;
236
237 title = title
238 // Strip Unicode bidi override characters
239 .replace( rUnicodeBidi, '' )
240 // Normalise whitespace to underscores and remove duplicates
241 .replace( rWhitespace, '_' )
242 // Trim underscores
243 .replace( rUnderscoreTrim, '' );
244
245 // Process initial colon
246 if ( title !== '' && title[ 0 ] === ':' ) {
247 // Initial colon means main namespace instead of specified default
248 namespace = NS_MAIN;
249 title = title
250 // Strip colon
251 .slice( 1 )
252 // Trim underscores
253 .replace( rUnderscoreTrim, '' );
254 }
255
256 if ( title === '' ) {
257 return false;
258 }
259
260 // Process namespace prefix (if any)
261 m = title.match( rSplit );
262 if ( m ) {
263 id = getNsIdByName( m[ 1 ] );
264 if ( id !== false ) {
265 // Ordinary namespace
266 namespace = id;
267 title = m[ 2 ];
268
269 // For Talk:X pages, make sure X has no "namespace" prefix
270 if ( namespace === NS_TALK && ( m = title.match( rSplit ) ) ) {
271 // Disallow titles like Talk:File:x (subject should roundtrip: talk:file:x -> file:x -> file_talk:x)
272 if ( getNsIdByName( m[ 1 ] ) !== false ) {
273 return false;
274 }
275 }
276 }
277 }
278
279 // Process fragment
280 i = title.indexOf( '#' );
281 if ( i === -1 ) {
282 fragment = null;
283 } else {
284 fragment = title
285 // Get segment starting after the hash
286 .slice( i + 1 )
287 // Convert to text
288 // NB: Must not be trimmed ("Example#_foo" is not the same as "Example#foo")
289 .replace( /_/g, ' ' );
290
291 title = title
292 // Strip hash
293 .slice( 0, i )
294 // Trim underscores, again (strips "_" from "bar" in "Foo_bar_#quux")
295 .replace( rUnderscoreTrim, '' );
296 }
297
298 // Reject illegal characters
299 if ( title.match( rInvalid ) ) {
300 return false;
301 }
302
303 // Disallow titles that browsers or servers might resolve as directory navigation
304 if (
305 title.indexOf( '.' ) !== -1 && (
306 title === '.' || title === '..' ||
307 title.indexOf( './' ) === 0 ||
308 title.indexOf( '../' ) === 0 ||
309 title.indexOf( '/./' ) !== -1 ||
310 title.indexOf( '/../' ) !== -1 ||
311 title.slice( -2 ) === '/.' ||
312 title.slice( -3 ) === '/..'
313 )
314 ) {
315 return false;
316 }
317
318 // Disallow magic tilde sequence
319 if ( title.indexOf( '~~~' ) !== -1 ) {
320 return false;
321 }
322
323 // Disallow titles exceeding the TITLE_MAX_BYTES byte size limit (size of underlying database field)
324 // Except for special pages, e.g. [[Special:Block/Long name]]
325 // Note: The PHP implementation also asserts that even in NS_SPECIAL, the title should
326 // be less than 512 bytes.
327 if ( namespace !== NS_SPECIAL && mwString.byteLength( title ) > TITLE_MAX_BYTES ) {
328 return false;
329 }
330
331 // Can't make a link to a namespace alone.
332 if ( title === '' && namespace !== NS_MAIN ) {
333 return false;
334 }
335
336 // Any remaining initial :s are illegal.
337 if ( title[ 0 ] === ':' ) {
338 return false;
339 }
340
341 // For backwards-compatibility with old mw.Title, we separate the extension from the
342 // rest of the title.
343 i = title.lastIndexOf( '.' );
344 if ( i === -1 || title.length <= i + 1 ) {
345 // Extensions are the non-empty segment after the last dot
346 ext = null;
347 } else {
348 ext = title.slice( i + 1 );
349 title = title.slice( 0, i );
350 }
351
352 return {
353 namespace: namespace,
354 title: title,
355 ext: ext,
356 fragment: fragment
357 };
358 },
359
360 /**
361 * Convert db-key to readable text.
362 *
363 * @private
364 * @static
365 * @method text
366 * @param {string} s
367 * @return {string}
368 */
369 text = function ( s ) {
370 return s.replace( /_/g, ' ' );
371 },
372
373 /**
374 * Sanitizes a string based on a rule set and a filter
375 *
376 * @private
377 * @static
378 * @method sanitize
379 * @param {string} s
380 * @param {Array} filter
381 * @return {string}
382 */
383 sanitize = function ( s, filter ) {
384 var i, ruleLength, rule, m, filterLength,
385 rules = sanitationRules;
386
387 for ( i = 0, ruleLength = rules.length; i < ruleLength; ++i ) {
388 rule = rules[ i ];
389 for ( m = 0, filterLength = filter.length; m < filterLength; ++m ) {
390 if ( rule[ filter[ m ] ] ) {
391 s = s.replace( rule.pattern, rule.replace );
392 }
393 }
394 }
395 return s;
396 },
397
398 /**
399 * Cuts a string to a specific byte length, assuming UTF-8
400 * or less, if the last character is a multi-byte one
401 *
402 * @private
403 * @static
404 * @method trimToByteLength
405 * @param {string} s
406 * @param {number} length
407 * @return {string}
408 */
409 trimToByteLength = function ( s, length ) {
410 return mwString.trimByteLength( '', s, length ).newVal;
411 },
412
413 /**
414 * Cuts a file name to a specific byte length
415 *
416 * @private
417 * @static
418 * @method trimFileNameToByteLength
419 * @param {string} name without extension
420 * @param {string} extension file extension
421 * @return {string} The full name, including extension
422 */
423 trimFileNameToByteLength = function ( name, extension ) {
424 // There is a special byte limit for file names and ... remember the dot
425 return trimToByteLength( name, FILENAME_MAX_BYTES - extension.length - 1 ) + '.' + extension;
426 };
427
428 /**
429 * @method constructor
430 * @param {string} title Title of the page. If no second argument given,
431 * this will be searched for a namespace
432 * @param {number} [namespace=NS_MAIN] If given, will used as default namespace for the given title
433 * @throws {Error} When the title is invalid
434 */
435 function Title( title, namespace ) {
436 var parsed = parse( title, namespace );
437 if ( !parsed ) {
438 throw new Error( 'Unable to parse title' );
439 }
440
441 this.namespace = parsed.namespace;
442 this.title = parsed.title;
443 this.ext = parsed.ext;
444 this.fragment = parsed.fragment;
445 }
446
447 /* Static members */
448
449 /**
450 * Constructor for Title objects with a null return instead of an exception for invalid titles.
451 *
452 * Note that `namespace` is the **default** namespace only, and can be overridden by a namespace
453 * prefix in `title`. If you do not want this behavior, use #makeTitle. See #constructor for
454 * details.
455 *
456 * @static
457 * @param {string} title
458 * @param {number} [namespace=NS_MAIN] Default namespace
459 * @return {mw.Title|null} A valid Title object or null if the title is invalid
460 */
461 Title.newFromText = function ( title, namespace ) {
462 var t, parsed = parse( title, namespace );
463 if ( !parsed ) {
464 return null;
465 }
466
467 t = Object.create( Title.prototype );
468 t.namespace = parsed.namespace;
469 t.title = parsed.title;
470 t.ext = parsed.ext;
471 t.fragment = parsed.fragment;
472
473 return t;
474 };
475
476 /**
477 * Constructor for Title objects with predefined namespace.
478 *
479 * Unlike #newFromText or #constructor, this function doesn't allow the given `namespace` to be
480 * overridden by a namespace prefix in `title`. See #constructor for details about this behavior.
481 *
482 * The single exception to this is when `namespace` is 0, indicating the main namespace. The
483 * function behaves like #newFromText in that case.
484 *
485 * @static
486 * @param {number} namespace Namespace to use for the title
487 * @param {string} title
488 * @return {mw.Title|null} A valid Title object or null if the title is invalid
489 */
490 Title.makeTitle = function ( namespace, title ) {
491 return mw.Title.newFromText( getNamespacePrefix( namespace ) + title );
492 };
493
494 /**
495 * Constructor for Title objects from user input altering that input to
496 * produce a title that MediaWiki will accept as legal
497 *
498 * @static
499 * @param {string} title
500 * @param {number|Object} [defaultNamespaceOrOptions=NS_MAIN]
501 * If given, will used as default namespace for the given title.
502 * This method can also be called with two arguments, in which case
503 * this becomes options (see below).
504 * @param {Object} [options] additional options
505 * @param {boolean} [options.forUploading=true]
506 * Makes sure that a file is uploadable under the title returned.
507 * There are pages in the file namespace under which file upload is impossible.
508 * Automatically assumed if the title is created in the Media namespace.
509 * @return {mw.Title|null} A valid Title object or null if the input cannot be turned into a valid title
510 */
511 Title.newFromUserInput = function ( title, defaultNamespaceOrOptions, options ) {
512 var namespace, m, id, ext, parts,
513 defaultNamespace;
514
515 // defaultNamespace is optional; check whether options moves up
516 if ( arguments.length < 3 && typeof defaultNamespace === 'object' ) {
517 options = defaultNamespaceOrOptions;
518 } else {
519 defaultNamespace = defaultNamespaceOrOptions;
520 }
521
522 // merge options into defaults
523 options = $.extend( {
524 forUploading: true
525 }, options );
526
527 namespace = defaultNamespace === undefined ? NS_MAIN : defaultNamespace;
528
529 // Normalise additional whitespace
530 title = title.replace( /\s/g, ' ' ).trim();
531
532 // Process initial colon
533 if ( title !== '' && title[ 0 ] === ':' ) {
534 // Initial colon means main namespace instead of specified default
535 namespace = NS_MAIN;
536 title = title
537 // Strip colon
538 .substr( 1 )
539 // Trim underscores
540 .replace( rUnderscoreTrim, '' );
541 }
542
543 // Process namespace prefix (if any)
544 m = title.match( rSplit );
545 if ( m ) {
546 id = getNsIdByName( m[ 1 ] );
547 if ( id !== false ) {
548 // Ordinary namespace
549 namespace = id;
550 title = m[ 2 ];
551 }
552 }
553
554 if (
555 namespace === NS_MEDIA ||
556 ( options.forUploading && ( namespace === NS_FILE ) )
557 ) {
558
559 title = sanitize( title, [ 'generalRule', 'fileRule' ] );
560
561 // Operate on the file extension
562 // Although it is possible having spaces between the name and the ".ext" this isn't nice for
563 // operating systems hiding file extensions -> strip them later on
564 parts = title.split( '.' );
565
566 if ( parts.length > 1 ) {
567
568 // Get the last part, which is supposed to be the file extension
569 ext = parts.pop();
570
571 // Remove whitespace of the name part (that W/O extension)
572 title = parts.join( '.' ).trim();
573
574 // Cut, if too long and append file extension
575 title = trimFileNameToByteLength( title, ext );
576
577 } else {
578
579 // Missing file extension
580 title = parts.join( '.' ).trim();
581
582 // Name has no file extension and a fallback wasn't provided either
583 return null;
584 }
585 } else {
586
587 title = sanitize( title, [ 'generalRule' ] );
588
589 // Cut titles exceeding the TITLE_MAX_BYTES byte size limit
590 // (size of underlying database field)
591 if ( namespace !== NS_SPECIAL ) {
592 title = trimToByteLength( title, TITLE_MAX_BYTES );
593 }
594 }
595
596 // Any remaining initial :s are illegal.
597 title = title.replace( /^:+/, '' );
598
599 return Title.newFromText( title, namespace );
600 };
601
602 /**
603 * Sanitizes a file name as supplied by the user, originating in the user's file system
604 * so it is most likely a valid MediaWiki title and file name after processing.
605 * Returns null on fatal errors.
606 *
607 * @static
608 * @param {string} uncleanName The unclean file name including file extension but
609 * without namespace
610 * @return {mw.Title|null} A valid Title object or null if the title is invalid
611 */
612 Title.newFromFileName = function ( uncleanName ) {
613
614 return Title.newFromUserInput( 'File:' + uncleanName, {
615 forUploading: true
616 } );
617 };
618
619 /**
620 * Get the file title from an image element
621 *
622 * var title = mw.Title.newFromImg( $( 'img:first' ) );
623 *
624 * @static
625 * @param {HTMLElement|jQuery} img The image to use as a base
626 * @return {mw.Title|null} The file title or null if unsuccessful
627 */
628 Title.newFromImg = function ( img ) {
629 var matches, i, regex, src, decodedSrc,
630
631 // thumb.php-generated thumbnails
632 thumbPhpRegex = /thumb\.php/,
633 regexes = [
634 // Thumbnails
635 /\/[a-f0-9]\/[a-f0-9]{2}\/([^\s/]+)\/[^\s/]+-[^\s/]*$/,
636
637 // Full size images
638 /\/[a-f0-9]\/[a-f0-9]{2}\/([^\s/]+)$/,
639
640 // Thumbnails in non-hashed upload directories
641 /\/([^\s/]+)\/[^\s/]+-(?:\1|thumbnail)[^\s/]*$/,
642
643 // Full-size images in non-hashed upload directories
644 /\/([^\s/]+)$/
645 ],
646
647 recount = regexes.length;
648
649 src = img.jquery ? img[ 0 ].src : img.src;
650
651 matches = src.match( thumbPhpRegex );
652
653 if ( matches ) {
654 return mw.Title.newFromText( 'File:' + mw.util.getParamValue( 'f', src ) );
655 }
656
657 decodedSrc = decodeURIComponent( src );
658
659 for ( i = 0; i < recount; i++ ) {
660 regex = regexes[ i ];
661 matches = decodedSrc.match( regex );
662
663 if ( matches && matches[ 1 ] ) {
664 return mw.Title.newFromText( 'File:' + matches[ 1 ] );
665 }
666 }
667
668 return null;
669 };
670
671 /**
672 * Check if a given namespace is a talk namespace
673 *
674 * See MWNamespace::isTalk in PHP
675 *
676 * @param {number} namespaceId Namespace ID
677 * @return {boolean} Namespace is a talk namespace
678 */
679 Title.isTalkNamespace = function ( namespaceId ) {
680 return !!( namespaceId > NS_MAIN && namespaceId % 2 );
681 };
682
683 /**
684 * Check if signature buttons should be shown in a given namespace
685 *
686 * See MWNamespace::wantSignatures in PHP
687 *
688 * @param {number} namespaceId Namespace ID
689 * @return {boolean} Namespace is a signature namespace
690 */
691 Title.wantSignaturesNamespace = function ( namespaceId ) {
692 return Title.isTalkNamespace( namespaceId ) ||
693 mw.config.get( 'wgExtraSignatureNamespaces' ).indexOf( namespaceId ) !== -1;
694 };
695
696 /**
697 * Whether this title exists on the wiki.
698 *
699 * @static
700 * @param {string|mw.Title} title prefixed db-key name (string) or instance of Title
701 * @return {boolean|null} Boolean if the information is available, otherwise null
702 * @throws {Error} If title is not a string or mw.Title
703 */
704 Title.exists = function ( title ) {
705 var match,
706 obj = Title.exist.pages;
707
708 if ( typeof title === 'string' ) {
709 match = obj[ title ];
710 } else if ( title instanceof Title ) {
711 match = obj[ title.toString() ];
712 } else {
713 throw new Error( 'mw.Title.exists: title must be a string or an instance of Title' );
714 }
715
716 if ( typeof match !== 'boolean' ) {
717 return null;
718 }
719
720 return match;
721 };
722
723 /**
724 * Store page existence
725 *
726 * @static
727 * @property {Object} exist
728 * @property {Object} exist.pages Keyed by title. Boolean true value indicates page does exist.
729 *
730 * @property {Function} exist.set The setter function.
731 *
732 * Example to declare existing titles:
733 *
734 * Title.exist.set( ['User:John_Doe', ...] );
735 *
736 * Example to declare titles nonexistent:
737 *
738 * Title.exist.set( ['File:Foo_bar.jpg', ...], false );
739 *
740 * @property {string|Array} exist.set.titles Title(s) in strict prefixedDb title form
741 * @property {boolean} [exist.set.state=true] State of the given titles
742 * @return {boolean}
743 */
744 Title.exist = {
745 pages: {},
746
747 set: function ( titles, state ) {
748 var i, len,
749 pages = this.pages;
750
751 titles = Array.isArray( titles ) ? titles : [ titles ];
752 state = state === undefined ? true : !!state;
753
754 for ( i = 0, len = titles.length; i < len; i++ ) {
755 pages[ titles[ i ] ] = state;
756 }
757 return true;
758 }
759 };
760
761 /**
762 * Normalize a file extension to the common form, making it lowercase and checking some synonyms,
763 * and ensure it's clean. Extensions with non-alphanumeric characters will be discarded.
764 * Keep in sync with File::normalizeExtension() in PHP.
765 *
766 * @param {string} extension File extension (without the leading dot)
767 * @return {string} File extension in canonical form
768 */
769 Title.normalizeExtension = function ( extension ) {
770 var
771 lower = extension.toLowerCase(),
772 squish = {
773 htm: 'html',
774 jpeg: 'jpg',
775 mpeg: 'mpg',
776 tiff: 'tif',
777 ogv: 'ogg'
778 };
779 if ( Object.prototype.hasOwnProperty.call( squish, lower ) ) {
780 return squish[ lower ];
781 } else if ( /^[0-9a-z]+$/.test( lower ) ) {
782 return lower;
783 } else {
784 return '';
785 }
786 };
787
788 /**
789 * PHP's strtoupper differs from String.toUpperCase in a number of cases (T147646).
790 *
791 * @param {string} chr Unicode character
792 * @return {string} Unicode character, in upper case, according to the same rules as in PHP
793 */
794 Title.phpCharToUpper = function ( chr ) {
795 var mapped = toUpperMapping[ chr ];
796 return mapped || chr.toUpperCase();
797 };
798
799 /* Public members */
800
801 Title.prototype = {
802 constructor: Title,
803
804 /**
805 * Get the namespace number
806 *
807 * Example: 6 for "File:Example_image.svg".
808 *
809 * @return {number}
810 */
811 getNamespaceId: function () {
812 return this.namespace;
813 },
814
815 /**
816 * Get the namespace prefix (in the content language)
817 *
818 * Example: "File:" for "File:Example_image.svg".
819 * In #NS_MAIN this is '', otherwise namespace name plus ':'
820 *
821 * @return {string}
822 */
823 getNamespacePrefix: function () {
824 return getNamespacePrefix( this.namespace );
825 },
826
827 /**
828 * Get the page name without extension or namespace prefix
829 *
830 * Example: "Example_image" for "File:Example_image.svg".
831 *
832 * For the page title (full page name without namespace prefix), see #getMain.
833 *
834 * @return {string}
835 */
836 getName: function () {
837 if (
838 mw.config.get( 'wgCaseSensitiveNamespaces' ).indexOf( this.namespace ) !== -1 ||
839 !this.title.length
840 ) {
841 return this.title;
842 }
843 return mw.Title.phpCharToUpper( this.title[ 0 ] ) + this.title.slice( 1 );
844 },
845
846 /**
847 * Get the page name (transformed by #text)
848 *
849 * Example: "Example image" for "File:Example_image.svg".
850 *
851 * For the page title (full page name without namespace prefix), see #getMainText.
852 *
853 * @return {string}
854 */
855 getNameText: function () {
856 return text( this.getName() );
857 },
858
859 /**
860 * Get the extension of the page name (if any)
861 *
862 * @return {string|null} Name extension or null if there is none
863 */
864 getExtension: function () {
865 return this.ext;
866 },
867
868 /**
869 * Shortcut for appendable string to form the main page name.
870 *
871 * Returns a string like ".json", or "" if no extension.
872 *
873 * @return {string}
874 */
875 getDotExtension: function () {
876 return this.ext === null ? '' : '.' + this.ext;
877 },
878
879 /**
880 * Get the main page name
881 *
882 * Example: "Example_image.svg" for "File:Example_image.svg".
883 *
884 * @return {string}
885 */
886 getMain: function () {
887 return this.getName() + this.getDotExtension();
888 },
889
890 /**
891 * Get the main page name (transformed by #text)
892 *
893 * Example: "Example image.svg" for "File:Example_image.svg".
894 *
895 * @return {string}
896 */
897 getMainText: function () {
898 return text( this.getMain() );
899 },
900
901 /**
902 * Get the full page name
903 *
904 * Example: "File:Example_image.svg".
905 * Most useful for API calls, anything that must identify the "title".
906 *
907 * @return {string}
908 */
909 getPrefixedDb: function () {
910 return this.getNamespacePrefix() + this.getMain();
911 },
912
913 /**
914 * Get the full page name (transformed by #text)
915 *
916 * Example: "File:Example image.svg" for "File:Example_image.svg".
917 *
918 * @return {string}
919 */
920 getPrefixedText: function () {
921 return text( this.getPrefixedDb() );
922 },
923
924 /**
925 * Get the page name relative to a namespace
926 *
927 * Example:
928 *
929 * - "Foo:Bar" relative to the Foo namespace becomes "Bar".
930 * - "Bar" relative to any non-main namespace becomes ":Bar".
931 * - "Foo:Bar" relative to any namespace other than Foo stays "Foo:Bar".
932 *
933 * @param {number} namespace The namespace to be relative to
934 * @return {string}
935 */
936 getRelativeText: function ( namespace ) {
937 if ( this.getNamespaceId() === namespace ) {
938 return this.getMainText();
939 } else if ( this.getNamespaceId() === NS_MAIN ) {
940 return ':' + this.getPrefixedText();
941 } else {
942 return this.getPrefixedText();
943 }
944 },
945
946 /**
947 * Get the fragment (if any).
948 *
949 * Note that this method (by design) does not include the hash character and
950 * the value is not url encoded.
951 *
952 * @return {string|null}
953 */
954 getFragment: function () {
955 return this.fragment;
956 },
957
958 /**
959 * Get the URL to this title
960 *
961 * @see mw.util#getUrl
962 * @param {Object} [params] A mapping of query parameter names to values,
963 * e.g. `{ action: 'edit' }`.
964 * @return {string}
965 */
966 getUrl: function ( params ) {
967 var fragment = this.getFragment();
968 if ( fragment ) {
969 return mw.util.getUrl( this.toString() + '#' + fragment, params );
970 } else {
971 return mw.util.getUrl( this.toString(), params );
972 }
973 },
974
975 /**
976 * Check if the title is in a talk namespace
977 *
978 * @return {boolean} The title is in a talk namespace
979 */
980 isTalkPage: function () {
981 return Title.isTalkNamespace( this.getNamespaceId() );
982 },
983
984 /**
985 * Get the title for the associated talk page
986 *
987 * @return {mw.Title|null} The title for the associated talk page, null if not available
988 */
989 getTalkPage: function () {
990 if ( !this.canHaveTalkPage() ) {
991 return null;
992 }
993 return this.isTalkPage() ?
994 this :
995 Title.makeTitle( this.getNamespaceId() + 1, this.getMainText() );
996 },
997
998 /**
999 * Get the title for the subject page of a talk page
1000 *
1001 * @return {mw.Title|null} The title for the subject page of a talk page, null if not available
1002 */
1003 getSubjectPage: function () {
1004 return this.isTalkPage() ?
1005 Title.makeTitle( this.getNamespaceId() - 1, this.getMainText() ) :
1006 this;
1007 },
1008
1009 /**
1010 * Check the the title can have an associated talk page
1011 *
1012 * @return {boolean} The title can have an associated talk page
1013 */
1014 canHaveTalkPage: function () {
1015 return this.getNamespaceId() >= NS_MAIN;
1016 },
1017
1018 /**
1019 * Whether this title exists on the wiki.
1020 *
1021 * @see #static-method-exists
1022 * @return {boolean|null} Boolean if the information is available, otherwise null
1023 */
1024 exists: function () {
1025 return Title.exists( this );
1026 }
1027 };
1028
1029 /**
1030 * @alias #getPrefixedDb
1031 * @method
1032 */
1033 Title.prototype.toString = Title.prototype.getPrefixedDb;
1034
1035 /**
1036 * @alias #getPrefixedText
1037 * @method
1038 */
1039 Title.prototype.toText = Title.prototype.getPrefixedText;
1040
1041 // Expose
1042 mw.Title = Title;
1043
1044 }() );