Merge "mw.Title: Rewrite from scratch (porting logic from Title.php)"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Fri, 4 Oct 2013 03:23:58 +0000 (03:23 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Fri, 4 Oct 2013 03:23:58 +0000 (03:23 +0000)
includes/Title.php
resources/mediawiki/mediawiki.Title.js
tests/phpunit/includes/TitleTest.php
tests/qunit/suites/resources/jquery/jquery.byteLimit.test.js
tests/qunit/suites/resources/mediawiki/mediawiki.Title.test.js

index cdbebf1..3b1aa24 100644 (file)
@@ -3165,7 +3165,7 @@ class Title {
                        return false;
                }
 
-               if ( false !== strpos( $dbkey, UTF8_REPLACEMENT ) ) {
+               if ( strpos( $dbkey, UTF8_REPLACEMENT ) !== false ) {
                        # Contained illegal UTF-8 sequences or forbidden Unicode chars.
                        return false;
                }
@@ -3302,6 +3302,7 @@ class Title {
 
                # Can't make a link to a namespace alone... "empty" local links can only be
                # self-links with a fragment identifier.
+               # TODO: Why do we exclude NS_MAIN (bug 54044)
                if ( $dbkey == '' && $this->mInterwiki == '' && $this->mNamespace != NS_MAIN ) {
                        return false;
                }
index b86a14b..98277fc 100644 (file)
 /*!
  * @author Neil Kandalgaonkar, 2010
- * @author Timo Tijhof, 2011
+ * @author Timo Tijhof, 2011-2013
  * @since 1.18
- *
- * Relies on: mw.config (wgFormattedNamespaces, wgNamespaceIds, wgCaseSensitiveNamespaces), mw.util.wikiGetlink
  */
 ( function ( mw, $ ) {
 
-       /* Local space */
-
        /**
         * @class mw.Title
         *
+        * Parse titles into an object struture. Note that when using the constructor
+        * directly, passing invalid titles will result in an exception. Use
+        * #newFromText to use the logic directly and get null for invalid titles
+        * which is easier to work with.
+        *
         * @constructor
         * @param {string} title Title of the page. If no second argument given,
-        * this will be searched for a namespace.
-        * @param {number} [namespace] Namespace id. If given, title will be taken as-is.
+        *  this will be searched for a namespace
+        * @param {number} [namespace=NS_MAIN] If given, will used as default namespace for the given title
+        * @throws {Error} When the title is invalid
         */
        function Title( title, namespace ) {
-               this.ns = 0; // integer namespace id
-               this.name = null; // name in canonical 'database' form
-               this.ext = null; // extension
-
-               if ( arguments.length === 2 ) {
-                       setNameAndExtension( this, title );
-                       this.ns = fixNsId( namespace );
-               } else if ( arguments.length === 1 ) {
-                       setAll( this, title );
+               var parsed = parse( title, namespace );
+               if ( !parsed ) {
+                       throw new Error( 'Unable to parse title' );
                }
+
+               this.namespace = parsed.namespace;
+               this.title = parsed.title;
+               this.ext = parsed.ext;
+               this.fragment = parsed.fragment;
+
                return this;
        }
 
-var
-       /* Public methods (defined later) */
-       fn,
+       /* Private members */
+
+       var
 
        /**
-        * Strip some illegal chars: control chars, colon, less than, greater than,
-        * brackets, braces, pipe, whitespace and normal spaces. This still leaves some insanity
-        * intact, like unicode bidi chars, but it's a good start..
-        * @ignore
-        * @param {string} s
-        * @return {string}
+        * @private
+        * @static
+        * @property NS_MAIN
         */
-       clean = function ( s ) {
-               if ( s !== undefined ) {
-                       return s.replace( /[\x00-\x1f\x23\x3c\x3e\x5b\x5d\x7b\x7c\x7d\x7f\s]+/g, '_' );
-               }
-       },
+       NS_MAIN = 0,
 
        /**
-        * Convert db-key to readable text.
-        * @ignore
-        * @param {string} s
-        * @return {string}
+        * @private
+        * @static
+        * @property NS_TALK
         */
-       text = function ( s ) {
-               if ( s !== null && s !== undefined ) {
-                       return s.replace( /_/g, ' ' );
-               } else {
-                       return '';
-               }
-       },
+       NS_TALK = 1,
 
        /**
-        * Sanitize name.
-        * @ignore
+        * @private
+        * @static
+        * @property NS_SPECIAL
         */
-       fixName = function ( s ) {
-               return clean( $.trim( s ) );
-       },
+       NS_SPECIAL = -1,
 
        /**
-        * Sanitize extension.
-        * @ignore
+        * Get the namespace id from a namespace name (either from the localized, canonical or alias
+        * name).
+        *
+        * Example: On a German wiki this would return 6 for any of 'File', 'Datei', 'Image' or
+        * even 'Bild'.
+        *
+        * @private
+        * @static
+        * @method getNsIdByName
+        * @param {string} ns Namespace name (case insensitive, leading/trailing space ignored)
+        * @return {number|boolean} Namespace id or boolean false
         */
-       fixExt = function ( s ) {
-               return clean( s );
+       getNsIdByName = function ( ns ) {
+               var id;
+
+               // Don't cast non-strings to strings, because null or undefined should not result in
+               // returning the id of a potential namespace called "Null:" (e.g. on null.example.org/wiki)
+               // Also, toLowerCase throws exception on null/undefined, because it is a String method.
+               if ( typeof ns !== 'string' ) {
+                       return false;
+               }
+               ns = ns.toLowerCase();
+               id = mw.config.get( 'wgNamespaceIds' )[ns];
+               if ( id === undefined ) {
+                       return false;
+               }
+               return id;
        },
 
+       rUnderscoreTrim = /^_+|_+$/g,
+
+       rSplit = /^(.+?)_*:_*(.*)$/,
+
+       // See Title.php#getTitleInvalidRegex
+       rInvalid = new RegExp(
+               '[^' + mw.config.get( 'wgLegalTitleChars' ) + ']' +
+               // URL percent encoding sequences interfere with the ability
+               // to round-trip titles -- you can't link to them consistently.
+               '|%[0-9A-Fa-f]{2}' +
+               // XML/HTML character references produce similar issues.
+               '|&[A-Za-z0-9\u0080-\uFFFF]+;' +
+               '|&#[0-9]+;' +
+               '|&#x[0-9A-Fa-f]+;'
+       ),
+
        /**
-        * Sanitize namespace id.
-        * @ignore
-        * @param id {Number} Namespace id.
-        * @return {Number|Boolean} The id as-is or boolean false if invalid.
+        * Internal helper for #constructor and #newFromtext.
+        *
+        * Based on Title.php#secureAndSplit
+        *
+        * @private
+        * @static
+        * @method parse
+        * @param {string} title
+        * @param {number} [defaultNamespace=NS_MAIN]
+        * @return {Object|boolean}
         */
-       fixNsId = function ( id ) {
-               // wgFormattedNamespaces is an object of *string* key-vals (ie. arr["0"] not arr[0] )
-               var ns = mw.config.get( 'wgFormattedNamespaces' )[id.toString()];
+       parse = function ( title, defaultNamespace ) {
+               var namespace, m, id, i, fragment, ext;
+
+               namespace = defaultNamespace === undefined ? NS_MAIN : defaultNamespace;
+
+               title = title
+                       // Normalise whitespace to underscores and remove duplicates
+                       .replace( /[ _\s]+/g, '_' )
+                       // Trim underscores
+                       .replace( rUnderscoreTrim, '' );
 
-               // Check only undefined (may be false-y, such as '' (main namespace) ).
-               if ( ns === undefined ) {
+               if ( title === '' ) {
                        return false;
+               }
+
+               // Process initial colon
+               if ( title.charAt( 0 ) === ':' ) {
+                       // Initial colon means main namespace instead of specified default
+                       namespace = NS_MAIN;
+                       title = title
+                               // Strip colon
+                               .substr( 1 )
+                               // Trim underscores
+                               .replace( rUnderscoreTrim, '' );
+               }
+
+               // Process namespace prefix (if any)
+               m = title.match( rSplit );
+               if ( m ) {
+                       id = getNsIdByName( m[1] );
+                       if ( id !== false ) {
+                               // Ordinary namespace
+                               namespace = id;
+                               title = m[2];
+
+                               // For Talk:X pages, make sure X has no "namespace" prefix
+                               if ( namespace === NS_TALK && ( m = title.match( rSplit ) ) ) {
+                                       // Disallow titles like Talk:File:x (subject should roundtrip: talk:file:x -> file:x -> file_talk:x)
+                                       if ( getNsIdByName( m[1] ) !== false ) {
+                                               return false;
+                                       }
+                               }
+                       }
+               }
+
+               // Process fragment
+               i = title.indexOf( '#' );
+               if ( i === -1 ) {
+                       fragment = null;
                } else {
-                       return Number( id );
+                       fragment = title
+                               // Get segment starting after the hash
+                               .substr( i + 1 )
+                               // Convert to text
+                               // NB: Must not be trimmed ("Example#_foo" is not the same as "Example#foo")
+                               .replace( /_/g, ' ' );
+
+                       title = title
+                               // Strip hash
+                               .substr( 0, i )
+                               // Trim underscores, again (strips "_" from "bar" in "Foo_bar_#quux")
+                               .replace( rUnderscoreTrim, '' );
                }
-       },
 
-       /**
-        * Get namespace id from namespace name by any known namespace/id pair (localized, canonical or alias).
-        * Example: On a German wiki this would return 6 for any of 'File', 'Datei', 'Image' or even 'Bild'.
-        * @ignore
-        * @param ns {String} Namespace name (case insensitive, leading/trailing space ignored).
-        * @return {Number|Boolean} Namespace id or boolean false if unrecognized.
-        */
-       getNsIdByName = function ( ns ) {
-               // Don't cast non-strings to strings, because null or undefined
-               // should not result in returning the id of a potential namespace
-               // called "Null:" (e.g. on nullwiki.example.org)
-               // Also, toLowerCase throws exception on null/undefined, because
-               // it is a String.prototype method.
-               if ( typeof ns !== 'string' ) {
+
+               // Reject illegal characters
+               if ( title.match( rInvalid ) ) {
                        return false;
                }
-               ns = clean( $.trim( ns.toLowerCase() ) ); // Normalize
-               var id = mw.config.get( 'wgNamespaceIds' )[ns];
-               if ( id === undefined ) {
-                       mw.log( 'mw.Title: Unrecognized namespace: ' + ns );
+
+               // Disallow titles that browsers or servers might resolve as directory navigation
+               if (
+                       title.indexOf( '.' ) !== -1 && (
+                               title === '.' || title === '..' ||
+                               title.indexOf( './' ) === 0 ||
+                               title.indexOf( '../' ) === 0 ||
+                               title.indexOf( '/./' ) !== -1 ||
+                               title.indexOf( '/../' ) !== -1 ||
+                               title.substr( -2 ) === '/.' ||
+                               title.substr( -3 ) === '/..'
+                       )
+               ) {
                        return false;
                }
-               return fixNsId( id );
+
+               // Disallow magic tilde sequence
+               if ( title.indexOf( '~~~' ) !== -1 ) {
+                       return false;
+               }
+
+               // Disallow titles exceeding the 255 byte size limit (size of underlying database field)
+               // Except for special pages, e.g. [[Special:Block/Long name]]
+               // Note: The PHP implementation also asserts that even in NS_SPECIAL, the title should
+               // be less than 512 bytes.
+               if ( namespace !== NS_SPECIAL && $.byteLength( title ) > 255 ) {
+                       return false;
+               }
+
+               // Can't make a link to a namespace alone.
+               if ( title === '' && namespace !== NS_MAIN ) {
+                       return false;
+               }
+
+               // Any remaining initial :s are illegal.
+               if ( title.charAt( 0 ) === ':' ) {
+                       return false;
+               }
+
+               // For backwards-compatibility with old mw.Title, we separate the extension from the
+               // rest of the title.
+               i = title.lastIndexOf( '.' );
+               if ( i === -1 || title.length <= i + 1 ) {
+                       // Extensions are the non-empty segment after the last dot
+                       ext = null;
+               } else {
+                       ext = title.substr( i + 1 );
+                       title = title.substr( 0, i );
+               }
+
+               return {
+                       namespace: namespace,
+                       title: title,
+                       ext: ext,
+                       fragment: fragment
+               };
        },
 
        /**
-        * Helper to extract namespace, name and extension from a string.
+        * Convert db-key to readable text.
         *
-        * @ignore
-        * @param {mw.Title} title
-        * @param {string} raw
-        * @return {mw.Title}
+        * @private
+        * @static
+        * @method text
+        * @param {string} s
+        * @return {string}
         */
-       setAll = function ( title, s ) {
-               // In normal browsers the match-array contains null/undefined if there's no match,
-               // IE returns an empty string.
-               var matches = s.match( /^(?:([^:]+):)?(.*?)(?:\.(\w+))?$/ ),
-                       nsMatch = getNsIdByName( matches[1] );
-
-               // Namespace must be valid, and title must be a non-empty string.
-               if ( nsMatch && typeof matches[2] === 'string' && matches[2] !== '' ) {
-                       title.ns = nsMatch;
-                       title.name = fixName( matches[2] );
-                       if ( typeof matches[3] === 'string' && matches[3] !== '' ) {
-                               title.ext = fixExt( matches[3] );
-                       }
+       text = function ( s ) {
+               if ( s !== null && s !== undefined ) {
+                       return s.replace( /_/g, ' ' );
                } else {
-                       // Consistency with MediaWiki PHP: Unknown namespace -> fallback to main namespace.
-                       title.ns = 0;
-                       setNameAndExtension( title, s );
+                       return '';
                }
-               return title;
        },
 
+       // Polyfill for ES5 Object.create
+       createObject = Object.create || ( function () {
+               return function ( o ) {
+                       function Title() {}
+                       if ( o !== Object( o ) ) {
+                               throw new Error( 'Cannot inherit from a non-object' );
+                       }
+                       Title.prototype = o;
+                       return new Title();
+               };
+       }() );
+
+
+       /* Static members */
+
        /**
-        * Helper to extract name and extension from a string.
+        * Constructor for Title objects with a null return instead of an exception for invalid titles.
         *
-        * @ignore
-        * @param {mw.Title} title
-        * @param {string} raw
-        * @return {mw.Title}
+        * @static
+        * @method
+        * @param {string} title
+        * @param {number} [namespace=NS_MAIN] Default namespace
+        * @return {mw.Title|null} A valid Title object or null if the title is invalid
         */
-       setNameAndExtension = function ( title, raw ) {
-               // In normal browsers the match-array contains null/undefined if there's no match,
-               // IE returns an empty string.
-               var matches = raw.match( /^(?:)?(.*?)(?:\.(\w+))?$/ );
-
-               // Title must be a non-empty string.
-               if ( typeof matches[1] === 'string' && matches[1] !== '' ) {
-                       title.name = fixName( matches[1] );
-                       if ( typeof matches[2] === 'string' && matches[2] !== '' ) {
-                               title.ext = fixExt( matches[2] );
-                       }
-               } else {
-                       throw new Error( 'mw.Title: Could not parse title "' + raw + '"' );
+       Title.newFromText = function ( title, namespace ) {
+               var t, parsed = parse( title, namespace );
+               if ( !parsed ) {
+                       return null;
                }
-               return title;
-       };
 
+               t = createObject( Title.prototype );
+               t.namespace = parsed.namespace;
+               t.title = parsed.title;
+               t.ext = parsed.ext;
+               t.fragment = parsed.fragment;
 
-       /* Static space */
+               return t;
+       };
 
        /**
         * Whether this title exists on the wiki.
+        *
         * @static
-        * @param {Mixed} title prefixed db-key name (string) or instance of Title
-        * @return {Mixed} Boolean true/false if the information is available. Otherwise null.
+        * @param {string|mw.Title} title prefixed db-key name (string) or instance of Title
+        * @return {boolean|null} Boolean if the information is available, otherwise null
         */
        Title.exists = function ( title ) {
-               var type = $.type( title ), obj = Title.exist.pages, match;
+               var match,
+                       type = $.type( title ),
+                       obj = Title.exist.pages;
+
                if ( type === 'string' ) {
                        match = obj[title];
                } else if ( type === 'object' && title instanceof Title ) {
@@ -194,23 +318,23 @@ var
                } else {
                        throw new Error( 'mw.Title.exists: title must be a string or an instance of Title' );
                }
+
                if ( typeof match === 'boolean' ) {
                        return match;
                }
+
                return null;
        };
 
-       /**
-        * @static
-        * @property
-        */
        Title.exist = {
                /**
+                * Boolean true value indicates page does exist.
+                *
                 * @static
                 * @property {Object} exist.pages Keyed by PrefixedDb title.
-                * Boolean true value indicates page does exist.
                 */
                pages: {},
+
                /**
                 * Example to declare existing titles:
                 *     Title.exist.set(['User:John_Doe', ...]);
@@ -219,8 +343,8 @@ var
                 *
                 * @static
                 * @property exist.set
-                * @param {string|Array} titles Title(s) in strict prefixedDb title form.
-                * @param {boolean} [state] State of the given titles. Defaults to true.
+                * @param {string|Array} titles Title(s) in strict prefixedDb title form
+                * @param {boolean} [state=true] State of the given titles
                 * @return {boolean}
                 */
                set: function ( titles, state ) {
@@ -234,42 +358,60 @@ var
                }
        };
 
-       /* Public methods */
+       /* Public members */
 
-       fn = {
+       Title.prototype = {
                constructor: Title,
 
                /**
-                * Get the namespace number.
+                * Get the namespace number
+                *
+                * Example: 6 for "File:Example_image.svg".
+                *
                 * @return {number}
                 */
-               getNamespaceId: function (){
-                       return this.ns;
+               getNamespaceId: function () {
+                       return this.namespace;
                },
 
                /**
-                * Get the namespace prefix (in the content-language).
-                * In NS_MAIN this is '', otherwise namespace name plus ':'
+                * Get the namespace prefix (in the content language)
+                *
+                * Example: "File:" for "File:Example_image.svg".
+                * In #NS_MAIN this is '', otherwise namespace name plus ':'
+                *
                 * @return {string}
                 */
-               getNamespacePrefix: function (){
-                       return mw.config.get( 'wgFormattedNamespaces' )[this.ns].replace( / /g, '_' ) + (this.ns === 0 ? '' : ':');
+               getNamespacePrefix: function () {
+                       return this.namespace === NS_MAIN ?
+                               '' :
+                               ( mw.config.get( 'wgFormattedNamespaces' )[ this.namespace ].replace( / /g, '_' ) + ':' );
                },
 
                /**
-                * The name, like "Foo_bar"
+                * Get the page name without extension or namespace prefix
+                *
+                * Example: "Example_image" for "File:Example_image.svg".
+                *
+                * For the page title (full page name without namespace prefix), see #getMain.
+                *
                 * @return {string}
                 */
                getName: function () {
-                       if ( $.inArray( this.ns, mw.config.get( 'wgCaseSensitiveNamespaces' ) ) !== -1 ) {
-                               return this.name;
+                       if ( $.inArray( this.namespace, mw.config.get( 'wgCaseSensitiveNamespaces' ) ) !== -1 ) {
+                               return this.title;
                        } else {
-                               return $.ucFirst( this.name );
+                               return $.ucFirst( this.title );
                        }
                },
 
                /**
-                * The name, like "Foo bar"
+                * Get the page name (transformed by #text)
+                *
+                * Example: "Example image" for "File:Example_image.svg".
+                *
+                * For the page title (full page name without namespace prefix), see #getMainText.
+                *
                 * @return {string}
                 */
                getNameText: function () {
@@ -277,24 +419,30 @@ var
                },
 
                /**
-                * Get full name in prefixed DB form, like File:Foo_bar.jpg,
-                * most useful for API calls, anything that must identify the "title".
-                * @return {string}
+                * Get the extension of the page name (if any)
+                *
+                * @return {string|null} Name extension or null if there is none
                 */
-               getPrefixedDb: function () {
-                       return this.getNamespacePrefix() + this.getMain();
+               getExtension: function () {
+                       return this.ext;
                },
 
                /**
-                * Get full name in text form, like "File:Foo bar.jpg".
+                * Shortcut for appendable string to form the main page name.
+                *
+                * Returns a string like ".json", or "" if no extension.
+                *
                 * @return {string}
                 */
-               getPrefixedText: function () {
-                       return text( this.getPrefixedDb() );
+               getDotExtension: function () {
+                       return this.ext === null ? '' : '.' + this.ext;
                },
 
                /**
-                * The main title (without namespace), like "Foo_bar.jpg"
+                * Get the main page name (transformed by #text)
+                *
+                * Example: "Example_image.svg" for "File:Example_image.svg".
+                *
                 * @return {string}
                 */
                getMain: function () {
@@ -302,7 +450,10 @@ var
                },
 
                /**
-                * The "text" form, like "Foo bar.jpg"
+                * Get the main page name (transformed by #text)
+                *
+                * Example: "Example image.svg" for "File:Example_image.svg".
+                *
                 * @return {string}
                 */
                getMainText: function () {
@@ -310,23 +461,43 @@ var
                },
 
                /**
-                * Get the extension (returns null if there was none)
-                * @return {string|null}
+                * Get the full page name
+                *
+                * Eaxample: "File:Example_image.svg".
+                * Most useful for API calls, anything that must identify the "title".
+                *
+                * @return {string}
                 */
-               getExtension: function () {
-                       return this.ext;
+               getPrefixedDb: function () {
+                       return this.getNamespacePrefix() + this.getMain();
                },
 
                /**
-                * Convenience method: return string like ".jpg", or "" if no extension
+                * Get the full page name (transformed by #text)
+                *
+                * Example: "File:Example image.svg" for "File:Example_image.svg".
+                *
                 * @return {string}
                 */
-               getDotExtension: function () {
-                       return this.ext === null ? '' : '.' + this.ext;
+               getPrefixedText: function () {
+                       return text( this.getPrefixedDb() );
                },
 
                /**
-                * Return the URL to this title
+                * Get the fragment (if any).
+                *
+                * Note that this method (by design) does not include the hash character and
+                * the value is not url encoded.
+                *
+                * @return {string|null}
+                */
+               getFragment: function () {
+                       return this.fragment;
+               },
+
+               /**
+                * Get the URL to this title
+                *
                 * @see mw.util#wikiGetlink
                 * @return {string}
                 */
@@ -336,20 +507,27 @@ var
 
                /**
                 * Whether this title exists on the wiki.
+                *
                 * @see #static-method-exists
-                * @return {boolean|null} If the information is available. Otherwise null.
+                * @return {boolean|null} Boolean if the information is available, otherwise null
                 */
                exists: function () {
                        return Title.exists( this );
                }
        };
 
-       // Alias
-       fn.toString = fn.getPrefixedDb;
-       fn.toText = fn.getPrefixedText;
+       /**
+        * @alias #getPrefixedDb
+        * @method
+        */
+       Title.prototype.toString = Title.prototype.getPrefixedDb;
+
 
-       // Assign
-       Title.prototype = fn;
+       /**
+        * @alias #getPrefixedText
+        * @method
+        */
+       Title.prototype.toText = Title.prototype.getPrefixedText;
 
        // Expose
        mw.Title = Title;
index 73786b9..da663c4 100644 (file)
@@ -32,6 +32,82 @@ class TitleTest extends MediaWikiTestCase {
                }
        }
 
+       /**
+        * See also mediawiki.Title.test.js
+        */
+       function testSecureAndSplit() {
+               // Valid
+               foreach ( array(
+                       'Sandbox',
+                       'A "B"',
+                       'A \'B\'',
+                       '.com',
+                       '~',
+                       '"',
+                       '\'',
+                       'Talk:Sandbox',
+                       'Talk:Foo:Sandbox',
+                       'File:Example.svg',
+                       'File_talk:Example.svg',
+                       'Foo/.../Sandbox',
+                       'Sandbox/...',
+                       'A~~',
+                       // Length is 256 total, but only title part matters
+                       'Category:' . str_repeat( 'x', 248 ),
+                       str_repeat( 'x', 252 )
+               ) as $text ) {
+                       $this->assertInstanceOf( 'Title', Title::newFromText( $text ), "Valid: $text" );
+               }
+
+               // Invalid
+               foreach ( array(
+                       '',
+                       '__  __',
+                       '  __  ',
+                       // Bad characters forbidden regardless of wgLegalTitleChars
+                       'A [ B',
+                       'A ] B',
+                       'A { B',
+                       'A } B',
+                       'A < B',
+                       'A > B',
+                       'A | B',
+                       // URL encoding
+                       'A%20B',
+                       'A%23B',
+                       'A%2523B',
+                       // XML/HTML character entity references
+                       // Note: Commented out because they are not marked invalid by the PHP test as
+                       // Title::newFromText runs Sanitizer::decodeCharReferencesAndNormalize first.
+                       //'A &eacute; B',
+                       //'A &#233; B',
+                       //'A &#x00E9; B',
+                       // Subject of NS_TALK does not roundtrip to NS_MAIN
+                       'Talk:File:Example.svg',
+                       // Directory navigation
+                       '.',
+                       '..',
+                       './Sandbox',
+                       '../Sandbox',
+                       'Foo/./Sandbox',
+                       'Foo/../Sandbox',
+                       'Sandbox/.',
+                       'Sandbox/..',
+                       // Tilde
+                       'A ~~~ Name',
+                       'A ~~~~ Signature',
+                       'A ~~~~~ Timestamp',
+                       str_repeat( 'x', 256 ),
+                       // Namespace prefix without actual title
+                       // ':', // bug 54044
+                       'Talk:',
+                       'Category: ',
+                       'Category: #bar'
+               ) as $text ) {
+                       $this->assertNull( Title::newFromText( $text ), "Invalid: $text" );
+               }
+       }
+
        public static function provideConvertByteClassToUnicodeClass() {
                return array(
                        array(
index 596c57c..22d2af1 100644 (file)
                description: 'Pass the limit and a callback as input filter',
                $input: $( '<input type="text"/>' )
                        .byteLimit( 6, function ( val ) {
-                               // Invalid title
-                               if ( val === '' ) {
-                                       return '';
-                               }
-
+                               var title = mw.Title.newFromText( String( val ) );
                                // Return without namespace prefix
-                               return new mw.Title( String( val ) ).getMain();
+                               return title ? title.getMain() : '';
                        } ),
                sample: 'User:Sample',
                expected: 'User:Sample'
                $input: $( '<input type="text"/>' )
                        .attr( 'maxlength', '6' )
                        .byteLimit( function ( val ) {
-                               // Invalid title
-                               if ( val === '' ) {
-                                       return '';
-                               }
-
+                               var title = mw.Title.newFromText( String( val ) );
                                // Return without namespace prefix
-                               return new mw.Title( String( val ) ).getMain();
+                               return title ? title.getMain() : '';
                        } ),
                sample: 'User:Sample',
                expected: 'User:Sample'
                description: 'Pass the limit and a callback as input filter',
                $input: $( '<input type="text"/>' )
                        .byteLimit( 6, function ( val ) {
-                               // Invalid title
-                               if ( val === '' ) {
-                                       return '';
-                               }
-
+                               var title = mw.Title.newFromText( String( val ) );
                                // Return without namespace prefix
-                               return new mw.Title( String( val ) ).getMain();
+                               return title ? title.getMain() : '';
                        } ),
                sample: 'User:Example',
                // The callback alters the value to be used to calculeate
index 30a31ef..e6bbe1e 100644 (file)
@@ -1,4 +1,4 @@
-( function ( mw ) {
+( function ( mw, $ ) {
        // mw.Title relies on these three config vars
        // Restore them after each test run
        var config = {
                        antarctic_waterfowl: 100
                },
                wgCaseSensitiveNamespaces: []
+       },
+       repeat = function ( input, multiplier ) {
+               return new Array( multiplier + 1 ).join( input );
+       },
+       cases = {
+               // See also TitleTest.php#testSecureAndSplit
+               valid: [
+                       'Sandbox',
+                       'A "B"',
+                       'A \'B\'',
+                       '.com',
+                       '~',
+                       '"',
+                       '\'',
+                       'Talk:Sandbox',
+                       'Talk:Foo:Sandbox',
+                       'File:Example.svg',
+                       'File_talk:Example.svg',
+                       'Foo/.../Sandbox',
+                       'Sandbox/...',
+                       'A~~',
+                       // Length is 256 total, but only title part matters
+                       'Category:' + repeat( 'x', 248 ),
+                       repeat( 'x', 252 )
+               ],
+               invalid: [
+                       '',
+                       '__  __',
+                       '  __  ',
+                       // Bad characters forbidden regardless of wgLegalTitleChars
+                       'A [ B',
+                       'A ] B',
+                       'A { B',
+                       'A } B',
+                       'A < B',
+                       'A > B',
+                       'A | B',
+                       // URL encoding
+                       'A%20B',
+                       'A%23B',
+                       'A%2523B',
+                       // XML/HTML character entity references
+                       // Note: The ones with # are commented out as those are interpreted as fragment and
+                       // as such end up being valid.
+                       'A &eacute; B',
+                       //'A &#233; B',
+                       //'A &#x00E9; B',
+                       // Subject of NS_TALK does not roundtrip to NS_MAIN
+                       'Talk:File:Example.svg',
+                       // Directory navigation
+                       '.',
+                       '..',
+                       './Sandbox',
+                       '../Sandbox',
+                       'Foo/./Sandbox',
+                       'Foo/../Sandbox',
+                       'Sandbox/.',
+                       'Sandbox/..',
+                       // Tilde
+                       'A ~~~ Name',
+                       'A ~~~~ Signature',
+                       'A ~~~~~ Timestamp',
+                       repeat( 'x', 256 ),
+                       // Extension separation is a js invention, for length
+                       // purposes it is part of the title
+                       repeat( 'x', 252 ) + '.json',
+                       // Namespace prefix without actual title
+                       // ':', // bug 54044
+                       'Talk:',
+                       'Category: ',
+                       'Category: #bar'
+               ]
        };
 
        QUnit.module( 'mediawiki.Title', QUnit.newMwEnvironment( { config: config } ) );
 
-       QUnit.test( 'Transformation', 8, function ( assert ) {
+       QUnit.test( 'constructor', cases.invalid.length, function ( assert ) {
+               var i, title;
+               for ( i = 0; i < cases.valid.length; i++ ) {
+                       title = new mw.Title( cases.valid[i] );
+               }
+               for ( i = 0; i < cases.invalid.length; i++ ) {
+                       /*jshint loopfunc:true */
+                       title = cases.invalid[i];
+                       assert.throws( function () {
+                               return new mw.Title( title );
+                       }, cases.invalid[i] );
+               }
+       } );
+
+       QUnit.test( 'newFromText', cases.valid.length + cases.invalid.length, function ( assert ) {
+               var i;
+               for ( i = 0; i < cases.valid.length; i++ ) {
+                       assert.equal(
+                               $.type( mw.Title.newFromText( cases.valid[i] ) ),
+                               'object',
+                               cases.valid[i]
+                       );
+               }
+               for ( i = 0; i < cases.invalid.length; i++ ) {
+                       assert.equal(
+                               $.type( mw.Title.newFromText( cases.invalid[i] ) ),
+                               'null',
+                               cases.invalid[i]
+                       );
+               }
+       } );
+
+       QUnit.test( 'Basic parsing', 12, function ( assert ) {
+               var title;
+               title = new mw.Title( 'File:Foo_bar.JPG' );
+
+               assert.equal( title.getNamespaceId(), 6 );
+               assert.equal( title.getNamespacePrefix(), 'File:' );
+               assert.equal( title.getName(), 'Foo_bar' );
+               assert.equal( title.getNameText(), 'Foo bar' );
+               assert.equal( title.getExtension(), 'JPG' );
+               assert.equal( title.getDotExtension(), '.JPG' );
+               assert.equal( title.getMain(), 'Foo_bar.JPG' );
+               assert.equal( title.getMainText(), 'Foo bar.JPG' );
+               assert.equal( title.getPrefixedDb(), 'File:Foo_bar.JPG' );
+               assert.equal( title.getPrefixedText(), 'File:Foo bar.JPG' );
+
+               title = new mw.Title( 'Foo#bar' );
+               assert.equal( title.getPrefixedText(), 'Foo' );
+               assert.equal( title.getFragment(), 'bar' );
+       } );
+
+       QUnit.test( 'Transformation', 11, function ( assert ) {
                var title;
 
                title = new mw.Title( 'File:quux pif.jpg' );
-               assert.equal( title.getName(), 'Quux_pif' );
+               assert.equal( title.getNameText(), 'Quux pif', 'First character of title' );
 
                title = new mw.Title( 'File:Glarg_foo_glang.jpg' );
-               assert.equal( title.getNameText(), 'Glarg foo glang' );
+               assert.equal( title.getNameText(), 'Glarg foo glang', 'Underscores' );
 
                title = new mw.Title( 'User:ABC.DEF' );
-               assert.equal( title.toText(), 'User:ABC.DEF' );
-               assert.equal( title.getNamespaceId(), 2 );
-               assert.equal( title.getNamespacePrefix(), 'User:' );
+               assert.equal( title.toText(), 'User:ABC.DEF', 'Round trip text' );
+               assert.equal( title.getNamespaceId(), 2, 'Parse canonical namespace prefix' );
+
+               title = new mw.Title( 'Image:quux pix.jpg' );
+               assert.equal( title.getNamespacePrefix(), 'File:', 'Transform alias to canonical namespace' );
 
                title = new mw.Title( 'uSEr:hAshAr' );
                assert.equal( title.toText(), 'User:HAshAr' );
-               assert.equal( title.getNamespaceId(), 2 );
+               assert.equal( title.getNamespaceId(), 2, 'Case-insensitive namespace prefix' );
 
-               title = new mw.Title( '   MediaWiki:  Foo   bar   .js   ' );
-               // Don't ask why, it's the way the backend works. One space is kept of each set
-               assert.equal( title.getName(), 'Foo_bar_.js', 'Merge multiple spaces to a single space.' );
-       } );
+               // Don't ask why, it's the way the backend works. One space is kept of each set.
+               title = new mw.Title( 'Foo  __  \t __ bar' );
+               assert.equal( title.getMain(), 'Foo_bar', 'Merge multiple types of whitespace/underscores into a single underscore' );
 
-       QUnit.test( 'Main text for filename', 8, function ( assert ) {
-               var title = new mw.Title( 'File:foo_bar.JPG' );
+               // Regression test: Previously it would only detect an extension if there is no space after it
+               title = new mw.Title( 'Example.js  ' );
+               assert.equal( title.getExtension(), 'js', 'Space after an extension is stripped' );
 
-               assert.equal( title.getNamespaceId(), 6 );
-               assert.equal( title.getNamespacePrefix(), 'File:' );
-               assert.equal( title.getName(), 'Foo_bar' );
-               assert.equal( title.getNameText(), 'Foo bar' );
-               assert.equal( title.getMain(), 'Foo_bar.JPG' );
-               assert.equal( title.getMainText(), 'Foo bar.JPG' );
-               assert.equal( title.getExtension(), 'JPG' );
-               assert.equal( title.getDotExtension(), '.JPG' );
+               title = new mw.Title( 'Example#foo' );
+               assert.equal( title.getFragment(), 'foo', 'Fragment' );
+
+               title = new mw.Title( 'Example#_foo_bar baz_' );
+               assert.equal( title.getFragment(), ' foo bar baz', 'Fragment' );
        } );
 
-       QUnit.test( 'Namespace detection and conversion', 6, function ( assert ) {
+       QUnit.test( 'Namespace detection and conversion', 10, function ( assert ) {
                var title;
 
+               title = new mw.Title( 'File:User:Example' );
+               assert.equal( title.getNamespaceId(), 6, 'Titles can contain namespace prefixes, which are otherwise ignored' );
+
+               title = new mw.Title( 'Example', 6 );
+               assert.equal( title.getNamespaceId(), 6, 'Default namespace passed is used' );
+
+               title = new mw.Title( 'User:Example', 6 );
+               assert.equal( title.getNamespaceId(), 2, 'Included namespace prefix overrides the given default' );
+
+               title = new mw.Title( ':Example', 6 );
+               assert.equal( title.getNamespaceId(), 0, 'Colon forces main namespace' );
+
                title = new mw.Title( 'something.PDF', 6 );
                assert.equal( title.toString(), 'File:Something.PDF' );
 
                assert.equal( title.getUrl(), '/wiki/User_talk:John_Doe', 'Escaping in title and namespace for urls' );
        } );
 
-}( mediaWiki ) );
+}( mediaWiki, jQuery ) );