2 * @author Neil Kandalgaonkar, 2010
3 * @author Timo Tijhof, 2011-2013
7 /*jshint latedef:false */
12 * Parse titles into an object structure. Note that when using the constructor
13 * directly, passing invalid titles will result in an exception. Use #newFromText to use the
14 * logic directly and get null for invalid titles which is easier to work with.
17 * @param {string} title Title of the page. If no second argument given,
18 * this will be searched for a namespace
19 * @param {number} [namespace=NS_MAIN] If given, will used as default namespace for the given title
20 * @throws {Error} When the title is invalid
22 function Title( title, namespace ) {
23 var parsed = parse( title, namespace );
25 throw new Error( 'Unable to parse title' );
28 this.namespace = parsed.namespace;
29 this.title = parsed.title;
30 this.ext = parsed.ext;
31 this.fragment = parsed.fragment;
57 * @property NS_SPECIAL
78 * @property FILENAME_MAX_BYTES
80 FILENAME_MAX_BYTES = 240,
85 * @property TITLE_MAX_BYTES
87 TITLE_MAX_BYTES = 255,
90 * Get the namespace id from a namespace name (either from the localized, canonical or alias
93 * Example: On a German wiki this would return 6 for any of 'File', 'Datei', 'Image' or
98 * @method getNsIdByName
99 * @param {string} ns Namespace name (case insensitive, leading/trailing space ignored)
100 * @return {number|boolean} Namespace id or boolean false
102 getNsIdByName = function ( ns ) {
105 // Don't cast non-strings to strings, because null or undefined should not result in
106 // returning the id of a potential namespace called "Null:" (e.g. on null.example.org/wiki)
107 // Also, toLowerCase throws exception on null/undefined, because it is a String method.
108 if ( typeof ns !== 'string' ) {
111 ns = ns.toLowerCase();
112 id = mw.config.get( 'wgNamespaceIds' )[ ns ];
113 if ( id === undefined ) {
119 rUnderscoreTrim = /^_+|_+$/g,
121 rSplit = /^(.+?)_*:_*(.*)$/,
123 // See MediaWikiTitleCodec.php#getTitleInvalidRegex
124 rInvalid = new RegExp(
125 '[^' + mw.config.get( 'wgLegalTitleChars' ) + ']' +
126 // URL percent encoding sequences interfere with the ability
127 // to round-trip titles -- you can't link to them consistently.
129 // XML/HTML character references produce similar issues.
130 '|&[A-Za-z0-9\u0080-\uFFFF]+;' +
135 // From MediaWikiTitleCodec.php#L225 @26fcab1f18c568a41
136 // "Clean up whitespace" in function MediaWikiTitleCodec::splitTitleString()
137 rWhitespace = /[ _\u0009\u00A0\u1680\u180E\u2000-\u200A\u2028\u2029\u202F\u205F\u3000\s]+/g,
140 * Slightly modified from Flinfo. Credit goes to Lupo and Flominator.
143 * @property sanitationRules
152 // Space, underscore, tab, NBSP and other unusual spaces
154 pattern: rWhitespace,
158 // unicode bidi override characters: Implicit, Embeds, Overrides
160 pattern: /[\u200E\u200F\u202A-\u202E]/g,
164 // control characters
166 pattern: /[\x00-\x1f\x7f]/g,
170 // URL encoding (possibly)
172 pattern: /%([0-9A-Fa-f]{2})/g,
176 // HTML-character-entities
178 pattern: /&(([A-Za-z0-9\x80-\xff]+|#[0-9]+|#x[0-9A-Fa-f]+);)/g,
182 // slash, colon (not supported by file systems like NTFS/Windows, Mac OS 9 [:], ext4 [/])
188 // brackets, greater than
194 // brackets, lower than
200 // everything that wasn't covered yet
202 pattern: new RegExp( rInvalid.source, 'g' ),
206 // directory structures
208 pattern: /^(\.|\.\.|\.\/.*|\.\.\/.*|.*\/\.\/.*|.*\/\.\.\/.*|.*\/\.|.*\/\.\.)$/g,
215 * Internal helper for #constructor and #newFromtext.
217 * Based on Title.php#secureAndSplit
222 * @param {string} title
223 * @param {number} [defaultNamespace=NS_MAIN]
224 * @return {Object|boolean}
226 parse = function ( title, defaultNamespace ) {
227 var namespace, m, id, i, fragment, ext;
229 namespace = defaultNamespace === undefined ? NS_MAIN : defaultNamespace;
232 // Normalise whitespace to underscores and remove duplicates
233 .replace( /[ _\s]+/g, '_' )
235 .replace( rUnderscoreTrim, '' );
237 // Process initial colon
238 if ( title !== '' && title[ 0 ] === ':' ) {
239 // Initial colon means main namespace instead of specified default
245 .replace( rUnderscoreTrim, '' );
248 if ( title === '' ) {
252 // Process namespace prefix (if any)
253 m = title.match( rSplit );
255 id = getNsIdByName( m[ 1 ] );
256 if ( id !== false ) {
257 // Ordinary namespace
261 // For Talk:X pages, make sure X has no "namespace" prefix
262 if ( namespace === NS_TALK && ( m = title.match( rSplit ) ) ) {
263 // Disallow titles like Talk:File:x (subject should roundtrip: talk:file:x -> file:x -> file_talk:x)
264 if ( getNsIdByName( m[ 1 ] ) !== false ) {
272 i = title.indexOf( '#' );
277 // Get segment starting after the hash
280 // NB: Must not be trimmed ("Example#_foo" is not the same as "Example#foo")
281 .replace( /_/g, ' ' );
286 // Trim underscores, again (strips "_" from "bar" in "Foo_bar_#quux")
287 .replace( rUnderscoreTrim, '' );
290 // Reject illegal characters
291 if ( title.match( rInvalid ) ) {
295 // Disallow titles that browsers or servers might resolve as directory navigation
297 title.indexOf( '.' ) !== -1 && (
298 title === '.' || title === '..' ||
299 title.indexOf( './' ) === 0 ||
300 title.indexOf( '../' ) === 0 ||
301 title.indexOf( '/./' ) !== -1 ||
302 title.indexOf( '/../' ) !== -1 ||
303 title.slice( -2 ) === '/.' ||
304 title.slice( -3 ) === '/..'
310 // Disallow magic tilde sequence
311 if ( title.indexOf( '~~~' ) !== -1 ) {
315 // Disallow titles exceeding the TITLE_MAX_BYTES byte size limit (size of underlying database field)
316 // Except for special pages, e.g. [[Special:Block/Long name]]
317 // Note: The PHP implementation also asserts that even in NS_SPECIAL, the title should
318 // be less than 512 bytes.
319 if ( namespace !== NS_SPECIAL && $.byteLength( title ) > TITLE_MAX_BYTES ) {
323 // Can't make a link to a namespace alone.
324 if ( title === '' && namespace !== NS_MAIN ) {
328 // Any remaining initial :s are illegal.
329 if ( title[ 0 ] === ':' ) {
333 // For backwards-compatibility with old mw.Title, we separate the extension from the
334 // rest of the title.
335 i = title.lastIndexOf( '.' );
336 if ( i === -1 || title.length <= i + 1 ) {
337 // Extensions are the non-empty segment after the last dot
340 ext = title.slice( i + 1 );
341 title = title.slice( 0, i );
345 namespace: namespace,
353 * Convert db-key to readable text.
361 text = function ( s ) {
362 if ( s !== null && s !== undefined ) {
363 return s.replace( /_/g, ' ' );
370 * Sanitizes a string based on a rule set and a filter
376 * @param {Array} filter
379 sanitize = function ( s, filter ) {
380 var i, ruleLength, rule, m, filterLength,
381 rules = sanitationRules;
383 for ( i = 0, ruleLength = rules.length; i < ruleLength; ++i ) {
385 for ( m = 0, filterLength = filter.length; m < filterLength; ++m ) {
386 if ( rule[ filter[ m ] ] ) {
387 s = s.replace( rule.pattern, rule.replace );
395 * Cuts a string to a specific byte length, assuming UTF-8
396 * or less, if the last character is a multi-byte one
400 * @method trimToByteLength
402 * @param {number} length
405 trimToByteLength = function ( s, length ) {
406 var byteLength, chopOffChars, chopOffBytes;
408 // bytelength is always greater or equal to the length in characters
409 s = s.substr( 0, length );
410 while ( ( byteLength = $.byteLength( s ) ) > length ) {
411 // Calculate how many characters can be safely removed
412 // First, we need to know how many bytes the string exceeds the threshold
413 chopOffBytes = byteLength - length;
414 // A character in UTF-8 is at most 4 bytes
415 // One character must be removed in any case because the
416 // string is too long
417 chopOffChars = Math.max( 1, Math.floor( chopOffBytes / 4 ) );
418 s = s.substr( 0, s.length - chopOffChars );
424 * Cuts a file name to a specific byte length
428 * @method trimFileNameToByteLength
429 * @param {string} name without extension
430 * @param {string} extension file extension
431 * @return {string} The full name, including extension
433 trimFileNameToByteLength = function ( name, extension ) {
434 // There is a special byte limit for file names and ... remember the dot
435 return trimToByteLength( name, FILENAME_MAX_BYTES - extension.length - 1 ) + '.' + extension;
438 // Polyfill for ES5 Object.create
439 createObject = Object.create || ( function () {
440 return function ( o ) {
442 if ( o !== Object( o ) ) {
443 throw new Error( 'Cannot inherit from a non-object' );
453 * Constructor for Title objects with a null return instead of an exception for invalid titles.
456 * @param {string} title
457 * @param {number} [namespace=NS_MAIN] Default namespace
458 * @return {mw.Title|null} A valid Title object or null if the title is invalid
460 Title.newFromText = function ( title, namespace ) {
461 var t, parsed = parse( title, namespace );
466 t = createObject( Title.prototype );
467 t.namespace = parsed.namespace;
468 t.title = parsed.title;
470 t.fragment = parsed.fragment;
476 * Constructor for Title objects from user input altering that input to
477 * produce a title that MediaWiki will accept as legal
480 * @param {string} title
481 * @param {number} [defaultNamespace=NS_MAIN]
482 * If given, will used as default namespace for the given title.
483 * @param {Object} [options] additional options
484 * @param {boolean} [options.forUploading=true]
485 * Makes sure that a file is uploadable under the title returned.
486 * There are pages in the file namespace under which file upload is impossible.
487 * Automatically assumed if the title is created in the Media namespace.
488 * @return {mw.Title|null} A valid Title object or null if the input cannot be turned into a valid title
490 Title.newFromUserInput = function ( title, defaultNamespace, options ) {
491 var namespace, m, id, ext, parts;
493 // defaultNamespace is optional; check whether options moves up
494 if ( arguments.length < 3 && $.type( defaultNamespace ) === 'object' ) {
495 options = defaultNamespace;
496 defaultNamespace = undefined;
499 // merge options into defaults
500 options = $.extend( {
504 namespace = defaultNamespace === undefined ? NS_MAIN : defaultNamespace;
506 // Normalise whitespace and remove duplicates
507 title = $.trim( title.replace( rWhitespace, ' ' ) );
509 // Process initial colon
510 if ( title !== '' && title[ 0 ] === ':' ) {
511 // Initial colon means main namespace instead of specified default
517 .replace( rUnderscoreTrim, '' );
520 // Process namespace prefix (if any)
521 m = title.match( rSplit );
523 id = getNsIdByName( m[ 1 ] );
524 if ( id !== false ) {
525 // Ordinary namespace
531 if ( namespace === NS_MEDIA
532 || ( options.forUploading && ( namespace === NS_FILE ) )
535 title = sanitize( title, [ 'generalRule', 'fileRule' ] );
537 // Operate on the file extension
538 // Although it is possible having spaces between the name and the ".ext" this isn't nice for
539 // operating systems hiding file extensions -> strip them later on
540 parts = title.split( '.' );
542 if ( parts.length > 1 ) {
544 // Get the last part, which is supposed to be the file extension
547 // Remove whitespace of the name part (that W/O extension)
548 title = $.trim( parts.join( '.' ) );
550 // Cut, if too long and append file extension
551 title = trimFileNameToByteLength( title, ext );
555 // Missing file extension
556 title = $.trim( parts.join( '.' ) );
558 // Name has no file extension and a fallback wasn't provided either
563 title = sanitize( title, [ 'generalRule' ] );
565 // Cut titles exceeding the TITLE_MAX_BYTES byte size limit
566 // (size of underlying database field)
567 if ( namespace !== NS_SPECIAL ) {
568 title = trimToByteLength( title, TITLE_MAX_BYTES );
572 // Any remaining initial :s are illegal.
573 title = title.replace( /^\:+/, '' );
575 return Title.newFromText( title, namespace );
579 * Sanitizes a file name as supplied by the user, originating in the user's file system
580 * so it is most likely a valid MediaWiki title and file name after processing.
581 * Returns null on fatal errors.
584 * @param {string} uncleanName The unclean file name including file extension but
586 * @return {mw.Title|null} A valid Title object or null if the title is invalid
588 Title.newFromFileName = function ( uncleanName ) {
590 return Title.newFromUserInput( 'File:' + uncleanName, {
596 * Get the file title from an image element
598 * var title = mw.Title.newFromImg( $( 'img:first' ) );
601 * @param {HTMLElement|jQuery} img The image to use as a base
602 * @return {mw.Title|null} The file title or null if unsuccessful
604 Title.newFromImg = function ( img ) {
605 var matches, i, regex, src, decodedSrc,
607 // thumb.php-generated thumbnails
608 thumbPhpRegex = /thumb\.php/,
611 /\/[a-f0-9]\/[a-f0-9]{2}\/([^\s\/]+)\/[^\s\/]+-[^\s\/]*$/,
613 // Thumbnails in non-hashed upload directories
614 /\/([^\s\/]+)\/[^\s\/]+-(?:\1|thumbnail)[^\s\/]*$/,
617 /\/[a-f0-9]\/[a-f0-9]{2}\/([^\s\/]+)$/,
619 // Full-size images in non-hashed upload directories
623 recount = regexes.length;
625 src = img.jquery ? img[ 0 ].src : img.src;
627 matches = src.match( thumbPhpRegex );
630 return mw.Title.newFromText( 'File:' + mw.util.getParamValue( 'f', src ) );
633 decodedSrc = decodeURIComponent( src );
635 for ( i = 0; i < recount; i++ ) {
636 regex = regexes[ i ];
637 matches = decodedSrc.match( regex );
639 if ( matches && matches[ 1 ] ) {
640 return mw.Title.newFromText( 'File:' + matches[ 1 ] );
648 * Whether this title exists on the wiki.
651 * @param {string|mw.Title} title prefixed db-key name (string) or instance of Title
652 * @return {boolean|null} Boolean if the information is available, otherwise null
654 Title.exists = function ( title ) {
656 type = $.type( title ),
657 obj = Title.exist.pages;
659 if ( type === 'string' ) {
660 match = obj[ title ];
661 } else if ( type === 'object' && title instanceof Title ) {
662 match = obj[ title.toString() ];
664 throw new Error( 'mw.Title.exists: title must be a string or an instance of Title' );
667 if ( typeof match === 'boolean' ) {
675 * Store page existence
678 * @property {Object} exist
679 * @property {Object} exist.pages Keyed by title. Boolean true value indicates page does exist.
681 * @property {Function} exist.set The setter function.
683 * Example to declare existing titles:
685 * Title.exist.set( ['User:John_Doe', ...] );
687 * Example to declare titles nonexistent:
689 * Title.exist.set( ['File:Foo_bar.jpg', ...], false );
691 * @property {string|Array} exist.set.titles Title(s) in strict prefixedDb title form
692 * @property {boolean} [exist.set.state=true] State of the given titles
698 set: function ( titles, state ) {
699 titles = $.isArray( titles ) ? titles : [ titles ];
700 state = state === undefined ? true : !!state;
705 for ( i = 0; i < len; i++ ) {
706 pages[ titles[ i ] ] = state;
713 * Normalize a file extension to the common form, making it lowercase and checking some synonyms,
714 * and ensure it's clean. Extensions with non-alphanumeric characters will be discarded.
715 * Keep in sync with File::normalizeExtension() in PHP.
717 * @param {string} extension File extension (without the leading dot)
718 * @return {string} File extension in canonical form
720 Title.normalizeExtension = function ( extension ) {
722 lower = extension.toLowerCase(),
730 if ( squish.hasOwnProperty( lower ) ) {
731 return squish[ lower ];
732 } else if ( /^[0-9a-z]+$/.test( lower ) ) {
745 * Get the namespace number
747 * Example: 6 for "File:Example_image.svg".
751 getNamespaceId: function () {
752 return this.namespace;
756 * Get the namespace prefix (in the content language)
758 * Example: "File:" for "File:Example_image.svg".
759 * In #NS_MAIN this is '', otherwise namespace name plus ':'
763 getNamespacePrefix: function () {
764 return this.namespace === NS_MAIN ?
766 ( mw.config.get( 'wgFormattedNamespaces' )[ this.namespace ].replace( / /g, '_' ) + ':' );
770 * Get the page name without extension or namespace prefix
772 * Example: "Example_image" for "File:Example_image.svg".
774 * For the page title (full page name without namespace prefix), see #getMain.
778 getName: function () {
780 $.inArray( this.namespace, mw.config.get( 'wgCaseSensitiveNamespaces' ) ) !== -1 ||
785 return this.title[ 0 ].toUpperCase() + this.title.slice( 1 );
789 * Get the page name (transformed by #text)
791 * Example: "Example image" for "File:Example_image.svg".
793 * For the page title (full page name without namespace prefix), see #getMainText.
797 getNameText: function () {
798 return text( this.getName() );
802 * Get the extension of the page name (if any)
804 * @return {string|null} Name extension or null if there is none
806 getExtension: function () {
811 * Shortcut for appendable string to form the main page name.
813 * Returns a string like ".json", or "" if no extension.
817 getDotExtension: function () {
818 return this.ext === null ? '' : '.' + this.ext;
822 * Get the main page name
824 * Example: "Example_image.svg" for "File:Example_image.svg".
828 getMain: function () {
829 return this.getName() + this.getDotExtension();
833 * Get the main page name (transformed by #text)
835 * Example: "Example image.svg" for "File:Example_image.svg".
839 getMainText: function () {
840 return text( this.getMain() );
844 * Get the full page name
846 * Example: "File:Example_image.svg".
847 * Most useful for API calls, anything that must identify the "title".
851 getPrefixedDb: function () {
852 return this.getNamespacePrefix() + this.getMain();
856 * Get the full page name (transformed by #text)
858 * Example: "File:Example image.svg" for "File:Example_image.svg".
862 getPrefixedText: function () {
863 return text( this.getPrefixedDb() );
867 * Get the page name relative to a namespace
871 * - "Foo:Bar" relative to the Foo namespace becomes "Bar".
872 * - "Bar" relative to any non-main namespace becomes ":Bar".
873 * - "Foo:Bar" relative to any namespace other than Foo stays "Foo:Bar".
875 * @param {number} namespace The namespace to be relative to
878 getRelativeText: function ( namespace ) {
879 if ( this.getNamespaceId() === namespace ) {
880 return this.getMainText();
881 } else if ( this.getNamespaceId() === NS_MAIN ) {
882 return ':' + this.getPrefixedText();
884 return this.getPrefixedText();
889 * Get the fragment (if any).
891 * Note that this method (by design) does not include the hash character and
892 * the value is not url encoded.
894 * @return {string|null}
896 getFragment: function () {
897 return this.fragment;
901 * Get the URL to this title
903 * @see mw.util#getUrl
904 * @param {Object} [params] A mapping of query parameter names to values,
905 * e.g. `{ action: 'edit' }`.
908 getUrl: function ( params ) {
909 var fragment = this.getFragment();
911 return mw.util.getUrl( this.toString() + '#' + this.getFragment(), params );
913 return mw.util.getUrl( this.toString(), params );
918 * Whether this title exists on the wiki.
920 * @see #static-method-exists
921 * @return {boolean|null} Boolean if the information is available, otherwise null
923 exists: function () {
924 return Title.exists( this );
929 * @alias #getPrefixedDb
932 Title.prototype.toString = Title.prototype.getPrefixedDb;
935 * @alias #getPrefixedText
938 Title.prototype.toText = Title.prototype.getPrefixedText;
943 }( mediaWiki, jQuery ) );