mw.Title: Rewrite from scratch (porting logic from Title.php)
authorTimo Tijhof <krinklemail@gmail.com>
Wed, 31 Jul 2013 22:12:16 +0000 (00:12 +0200)
committerTimo Tijhof <krinklemail@gmail.com>
Tue, 1 Oct 2013 21:38:53 +0000 (23:38 +0200)
Changes:

* Add support for fragments.

* Use wgLegalTitleChars instead of the old clean() method
  that stripped out characters instead of throwing an exception.

* Implemented various other parts of Title.php to make it more
  strict like Title.php. It is still slightly looser, but it
  now takes care of the following that Title.php did already:
  - Directory patterns ("../" etc.)
  - Extra initial colons
  - Titles in NS_TALK that don't round-trip to NS_MAIN
  - 3 or more consecutive tildes
  - Limited title size (255 bytes)

* Extracted parsing logic into a private static #parse method
  and introduced mw.Title.newFromText (a constructor that returns
  null|Title instead of throwing an exception).

* Extended test suite to cover the added features and fixed bugs.

* Since the PHP test suite was lacking these, added them there
  as well.

Bug fixes:

* Fragments are now excluded from the title instead of causing
  the input to be invalid or malformed (e.g. "Foo#bar" was being
  normalised to "Foo_bar").

* ".com" now parses and round-trips properly. The extension and
  rest of title are still separated, but only at the very end
  after all other processing, so though title cannot be empty,
  since we only do a lazy split afterwards, it will split into
  title="", ext="com" internally and join back together when
  needed (bug 38081).

* "Example.js " (trailing space after extension) was previously
  incorrectly parsed as title=Example.js,ext=null.

* "Foo    bar" (multiple consecutive spaces) was transformed
  into 1 space correctly, but "Foo___bar" was not. This has been
  fixed to match the PHP implementation (it merges underscores
  and whitespace of any kind).

Clean up:

* Removed various redundant private helper methods.

* Removed fixNsId as getNsIdByName uses wgNamespaceIds which
  always yields a valid value. The fixNsId was verifying
  something that was already valid.

* Yoda conditional in Title.php, got rid of.

* Use newFromText in jquery.byteLimit.test. It was previously
  using a very basic invalid test (=== '') and no try-catch.
  Since we're getting more strict, typing 'User:' results in
  an invalid title, which should result in the same behaviour
  as it previously did for the lazy === '' check.

Bug: 38081
Change-Id: Ief1c11dabadc2f822065c40be91e04d655933e4f

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 ) );