AjaxCategories rewrite:
authorKrinkle <krinkle@users.mediawiki.org>
Thu, 28 Jul 2011 00:43:21 +0000 (00:43 +0000)
committerKrinkle <krinkle@users.mediawiki.org>
Thu, 28 Jul 2011 00:43:21 +0000 (00:43 +0000)
Solving syntax problems, performance improvements and applying code conventions:

* Replaced sprite image with separate images and letting ResourceLoader embed them with @embed (@embed means 0 http requests, less maintenance, none of the known limitations with sprites, and more readable code (named files rather than pixel offsets)

* Many functions were floating in the global namespace (like window.makeCaseInsensitive). A statement ends after a semi-colon(;). All functions declared after "catUrl" were assigned to the window object. I've instead turned the semi-colons back into comma's, merged some other var statements and moved them to the top of the closure. Changed local function declarations into function expressions for clarity.

* fetchSuggestions is called by $.fn.suggestions like ".call( $textbox, $textbox.val() )". So the context (this) isn't the raw element but the jQuery object, no need to re-construct with "$(this)" or "$(that)" which is slow and shouldn't even work. jQuery methods can be called on it directly. I've also replaced "$(this).val()" with the value-argument passed to fetchSuggestions which has this exact value already.

* Adding more function documentation. And changing @since to 1.19 as this was merged from js2-branch into 1.19-trunk and new features aren't backported to 1.18.

* Optimizing options/default construction to just "options = $.extend( {}, options )". Caching defaultOptions is cool, but doesn't really work if it's in a context/instance local variable. Moved it up to the module closure var statements, now it's static across all instances.

* In makeSuggestionBox(): Fixing invalid html fragments passed to jQuery that fail in IE. Shortcuts (like '<foo>' and '<foo/>') are only allowed for createElement triggers, not when creating longer fragments with content and/or attributes which are created through innerHTML, in the latter case the HTML must be completely valid and is not auto-corrected by IE.

* Using more jQuery chaining where possible.

* In buildRegex(): Using $.map with join( '|' ), (rather than $.each with += '|'; and substr).

* Storing the init instance of mw.ajaxCategories in mw.page for reference (rather than local/anonymous).

* Applied some best practices and "write testable code"
** Moved some of the functions created on the fly and assigned to 'this' into prototype (reference is cheaper)
** Making sure at least all 'do', 'set' and/or 'prototype' functions have a return value. Even if it's just a simple boolean true or context/this for chain-ability.
** Rewrote confirmEdit( .., .., .., ) as a prototyped method named "doConfirmEdit" which takes a single props-object with named valuas as argument, instead of list with 8 arguments.

* Removed trailing whitespace and other minor fixes to comply with the code conventions.
** Removed space between function name and caller: "foo ()" => foo())
** Changing "someArray.indexOf() + 1" into "someArr.indexOf() !== -1". We want a Boolean here, not a Number.
** Renamed all underscore-variables to non-underscore variants.

== Bug fixes ==

* When adding a category that is not already on the page as-is but of which the clean() version is already on the page, the script would fail. Fixed it by moving the checks up in handleCategoryAdd() and making sure that createCatLink() actually returned something.

* confirmEdit() wasn't working properly and had unused code (such as submitButton), removed hidden prepending to #catlinks, no need to, it can be dialog'ed directly from the jQuery object without being somewhere in the document.

* in doConfirmEdit() in submitFunction() and multiEdit: Clearing the input field after adding a category, so that when another category is being added it doesn't start with the previous value which is not allowed to be added again...

15 files changed:
resources/mediawiki.page/images/AJAXCategorySprite.png [deleted file]
resources/mediawiki.page/images/ajaxcat-add-hover.png [new file with mode: 0644]
resources/mediawiki.page/images/ajaxcat-add.png [new file with mode: 0644]
resources/mediawiki.page/images/ajaxcat-close-hover.png [new file with mode: 0644]
resources/mediawiki.page/images/ajaxcat-close.png [new file with mode: 0644]
resources/mediawiki.page/images/ajaxcat-edit-hover.png [new file with mode: 0644]
resources/mediawiki.page/images/ajaxcat-edit.png [new file with mode: 0644]
resources/mediawiki.page/images/ajaxcat-error-hover.png [new file with mode: 0644]
resources/mediawiki.page/images/ajaxcat-error.png [new file with mode: 0644]
resources/mediawiki.page/images/ajaxcat-tick-hover.png [new file with mode: 0644]
resources/mediawiki.page/images/ajaxcat-tick.png [new file with mode: 0644]
resources/mediawiki.page/mediawiki.page.ajaxCategories.css
resources/mediawiki.page/mediawiki.page.ajaxCategories.init.js
resources/mediawiki.page/mediawiki.page.ajaxCategories.js
resources/mediawiki.page/mediawiki.page.startup.js

diff --git a/resources/mediawiki.page/images/AJAXCategorySprite.png b/resources/mediawiki.page/images/AJAXCategorySprite.png
deleted file mode 100644 (file)
index d5f9cf4..0000000
Binary files a/resources/mediawiki.page/images/AJAXCategorySprite.png and /dev/null differ
diff --git a/resources/mediawiki.page/images/ajaxcat-add-hover.png b/resources/mediawiki.page/images/ajaxcat-add-hover.png
new file mode 100644 (file)
index 0000000..9815f4f
Binary files /dev/null and b/resources/mediawiki.page/images/ajaxcat-add-hover.png differ
diff --git a/resources/mediawiki.page/images/ajaxcat-add.png b/resources/mediawiki.page/images/ajaxcat-add.png
new file mode 100644 (file)
index 0000000..2c39d3b
Binary files /dev/null and b/resources/mediawiki.page/images/ajaxcat-add.png differ
diff --git a/resources/mediawiki.page/images/ajaxcat-close-hover.png b/resources/mediawiki.page/images/ajaxcat-close-hover.png
new file mode 100644 (file)
index 0000000..679459e
Binary files /dev/null and b/resources/mediawiki.page/images/ajaxcat-close-hover.png differ
diff --git a/resources/mediawiki.page/images/ajaxcat-close.png b/resources/mediawiki.page/images/ajaxcat-close.png
new file mode 100644 (file)
index 0000000..2aaef6c
Binary files /dev/null and b/resources/mediawiki.page/images/ajaxcat-close.png differ
diff --git a/resources/mediawiki.page/images/ajaxcat-edit-hover.png b/resources/mediawiki.page/images/ajaxcat-edit-hover.png
new file mode 100644 (file)
index 0000000..afd03d9
Binary files /dev/null and b/resources/mediawiki.page/images/ajaxcat-edit-hover.png differ
diff --git a/resources/mediawiki.page/images/ajaxcat-edit.png b/resources/mediawiki.page/images/ajaxcat-edit.png
new file mode 100644 (file)
index 0000000..62592a2
Binary files /dev/null and b/resources/mediawiki.page/images/ajaxcat-edit.png differ
diff --git a/resources/mediawiki.page/images/ajaxcat-error-hover.png b/resources/mediawiki.page/images/ajaxcat-error-hover.png
new file mode 100644 (file)
index 0000000..30f9c7a
Binary files /dev/null and b/resources/mediawiki.page/images/ajaxcat-error-hover.png differ
diff --git a/resources/mediawiki.page/images/ajaxcat-error.png b/resources/mediawiki.page/images/ajaxcat-error.png
new file mode 100644 (file)
index 0000000..580450e
Binary files /dev/null and b/resources/mediawiki.page/images/ajaxcat-error.png differ
diff --git a/resources/mediawiki.page/images/ajaxcat-tick-hover.png b/resources/mediawiki.page/images/ajaxcat-tick-hover.png
new file mode 100644 (file)
index 0000000..a1e0220
Binary files /dev/null and b/resources/mediawiki.page/images/ajaxcat-tick-hover.png differ
diff --git a/resources/mediawiki.page/images/ajaxcat-tick.png b/resources/mediawiki.page/images/ajaxcat-tick.png
new file mode 100644 (file)
index 0000000..2e7bd36
Binary files /dev/null and b/resources/mediawiki.page/images/ajaxcat-tick.png differ
index 2e62f8c..11ded38 100644 (file)
@@ -9,30 +9,34 @@
 
 .mw-remove-category {
        padding: 2px 8px;
-       display:inline;
+       display: inline;
 }
 .mw-removed-category {
        text-decoration: line-through;
 }
+
 #catlinks:hover .icon {
        opacity: 1;
 }
 #catlinks ul {
        margin-right: 2em;
 }
+
 .mw-ajax-addcategory-holder {
        display: inline-block;
 }
 .mw-ajax-addcategory {
        margin-right: 1em;
        cursor: pointer;
-       display:inline-block;
+       display: inline-block;
 }
+
 #catlinks .icon {
        cursor: pointer;
        padding: 1px 8px;
        margin: 0;
-       background: url('images/AJAXCategorySprite.png') 0 0 no-repeat;
+       background-position: 0 0 ;
+       background-repeat: no-repeat;
        opacity: 0.5;
 
 }
        cursor: pointer;
        margin-right: 1em;
 }
-#catlinks .icon-parent:hover .icon {
-       background-position-y: -16px;
-}
-#catlinks .no-text {
-}
 #catlinks .icon-close {
-       background-position: 0 0;
+       /* @embed */ background-image: url(images/ajaxcat-close.png);
 }
 #catlinks .icon-edit {
-       background-position: -16px 0;
+       /* @embed */ background-image: url(images/ajaxcat-edit.png);
 }
 #catlinks .icon-tick {
-       background-position: -32px 0;
+       /* @embed */ background-image: url(images/ajaxcat-tick.png);
 }
 #catlinks .icon-add {
-       background-position: -64px 0;
+       /* @embed */ background-image: url(images/ajaxcat-add.png);
 }
 #catlinks .icon-close:hover {
-       background-position: 0 -16px;
+       /* @embed */ background-image: url(images/ajaxcat-close-hover.png);
 }
 #catlinks .icon-edit:hover {
-       background-position: -16px -16px;
+       /* @embed */ background-image: url(images/ajaxcat-edit-hover.png);
 }
 #catlinks .icon-tick:hover {
-       background-position: -32px -16px;
+       /* @embed */ background-image: url(images/ajaxcat-tick-hover.png);
 }
 #catlinks .icon-add:hover {
-       background-position: -64px -16px;
+       /* @embed */ background-image: url(images/ajaxcat-add-hover.png);
 }
index c1f5df1..a82cf3f 100644 (file)
@@ -1 +1,6 @@
-jQuery( document ).ready( new mw.ajaxCategories().setup );
+mw.page.ajaxCategories = new mw.ajaxCategories();
+jQuery( document ).ready( function(){
+       // Seperate function for call to prevent jQuery
+       // from executing it in the document context.
+       mw.page.ajaxCategories.setup();
+} );
index 2429115..ca34051 100644 (file)
  *
  * @author Michael Dale, 2009
  * @author Leo Koppelkamm, 2011
- * @since 1.18
+ * @author Timo Tijhof, 2011
+ * @since 1.19
  *
- * Relies on: mw.config (wgFormattedNamespaces, wgNamespaceIds, wgCaseSensitiveNamespaces, wgUserGroups), 
- *   mw.util.wikiGetlink, mw.user.getId
+ * Relies on: mw.config (wgFormattedNamespaces, wgNamespaceIds,
+ * wgCaseSensitiveNamespaces, wgUserGroups), mw.util.wikiGetlink, mw.user.getId
  */
 ( function( $ ) {
 
        /* Local scope */
 
        var     catNsId = mw.config.get( 'wgNamespaceIds' ).category,
+               defaultOptions = {
+                       catLinkWrapper: '<li>',
+                       $container: $( '.catlinks' ),
+                       $containerNormal: $( '#mw-normal-catlinks' ),
+                       categoryLinkSelector: 'li a:not(.icon)',
+                       multiEdit: $.inArray( 'user', mw.config.get( 'wgUserGroups' ) ) !== -1,
+                       resolveRedirects: true
+               };
 
-       clean = function( s ) {
+       function clean( s ) {
                if ( s !== undefined ) {
                        return s.replace( /[\x00-\x1f\x23\x3c\x3e\x5b\x5d\x7b\x7c\x7d\x7f\s]+/g, '' );
                }
-       },
+       }
 
        /**
         * Build URL for passed Category
-        * 
-        * @param string category name.
-        * @return string Valid URL
+        *
+        * @param cat {String} Category name.
+        * @return {String} Valid URL
         */
-       catUrl = function( cat ) {
+       function catUrl( cat ) {
                return mw.util.wikiGetlink( new mw.Title( cat, catNsId ) );
-       };
+       }
 
        /**
-        * Helper function for $.fn.suggestion
-        * 
-        * @param string Query string.
+        * Helper function for $.fn.suggestions
+        *
+        * @context {jQuery}
+        * @param value {String} Textbox value.
         */
-       fetchSuggestions = function( query ) {
-               var _this = this;
-               // ignore bad characters, they will be stripped out
-               var catName = clean( $( this ).val() );
-               var request = $.ajax( {
+       function fetchSuggestions( value ) {
+               var     request,
+                       $el = this,
+                       catName = clean( value );
+
+               request = $.ajax( {
                        url: mw.util.wikiScript( 'api' ),
                        data: {
-                               'action': 'query',
-                               'list': 'allpages',
-                               'apnamespace': catNsId,
-                               'apprefix': catName,
-                               'format': 'json'
+                               action: 'query',
+                               list: 'allpages',
+                               apnamespace: catNsId,
+                               apprefix: catName,
+                               format: 'json'
                        },
                        dataType: 'json',
                        success: function( data ) {
                                // Process data.query.allpages into an array of titles
-                               var pages = data.query.allpages;
-                               var titleArr = [];
+                               var     title,
+                                       pages = data.query.allpages,
+                                       titleArr = [];
 
                                $.each( pages, function( i, page ) {
-                                       var title = page.title.split( ':', 2 )[1];
+                                       title = page.title.split( ':', 2 )[1];
                                        titleArr.push( title );
                                } );
 
-                               $( _this ).suggestions( 'suggestions', titleArr );
+                               $el.suggestions( 'suggestions', titleArr );
                        }
                } );
-               _request = request;
-       };
-       
+               $el.data( 'suggestions-request', request );
+       }
+
        /**
-       * Replace <nowiki> and comments with unique keys
-       */
-       replaceNowikis = function( text, id, array ) {
+        * Replace <nowiki> and comments with unique keys
+        *
+        * @param text {String}
+        * @param id
+        * @param keys {Array}
+        * @return {String}
+        */
+       function replaceNowikis( text, id, keys ) {
                var matches = text.match( /(<nowiki\>[\s\S]*?<\/nowiki>|<\!--[\s\S]*?--\>)/g );
                for ( var i = 0; matches && i < matches.length; i++ ) {
-                       array[i] = matches[i];
+                       keys[i] = matches[i];
                        text = text.replace( matches[i], id + i + '-' );
                }
                return text;
-       };
-       
+       }
+
        /**
-       * Restore <nowiki> and comments  from unique keys
-       */
-       restoreNowikis = function( text, id, array ) {
-               for ( var i = 0; i < array.length; i++ ) {
-                       text = text.replace( id + i + '-', array[i] );
+        * Restore <nowiki> and comments from unique keys
+        * @param text {String}
+        * @param id
+        * @param keys {Array}
+        * @return {String}
+        */
+       function restoreNowikis( text, id, keys ) {
+               for ( var i = 0; i < keys.length; i++ ) {
+                       text = text.replace( id + i + '-', keys[i] );
                }
                return text;
-       };
-       
+       }
+
        /**
         * Makes regex string caseinsensitive.
         * Useful when 'i' flag can't be used.
         * Return stuff like [Ff][Oo][Oo]
-        * @param string Regex string.
-        * @return string Processed regex string
+        *
+        * @param string {String} Regex string
+        * @return {String} Processed regex string
         */
-       makeCaseInsensitive = function( string ) {
-               if ( $.inArray( 14, mw.config.get( 'wgCaseSensitiveNamespaces' ) ) + 1 ) {
+       function makeCaseInsensitive( string ) {
+               var newString = '';
+               if ( $.inArray( 14, mw.config.get( 'wgCaseSensitiveNamespaces' ) ) !== -1 ) {
                        return string;
                }
-               var newString = '';
-               for ( var i=0; i < string.length; i++ ) {
+               for ( var i = 0; i < string.length; i++ ) {
                        newString += '[' + string.charAt( i ).toUpperCase() + string.charAt( i ).toLowerCase() + ']';
                }
                return newString;
-       };
-       
+       }
+
        /**
-        * Build a regex that matches legal invocations 
-        * of the passed category.
-        * @param string category.
-        * @param boolean Match one following linebreak as well?
-        * @return Regex
-        */
-       buildRegex = function( category, matchLineBreak ) {
-               var categoryNSFragment = '';
-               $.each( mw.config.get( 'wgNamespaceIds' ), function( name, id ) {
-                       if ( id == 14 ) {
-                               // The parser accepts stuff like cATegORy, 
-                               // we need to do the same
-                               // ( Well unless we have wgCaseSensitiveNamespaces, but that's being checked for )
-                               categoryNSFragment += '|' + makeCaseInsensitive ( $.escapeRE( name ) );
+        * Build a regex that matches legal invocations of the passed category.
+        * @param category {String}
+        * @param matchLineBreak {Boolean} Match one following linebreak as well?
+        * @return {RegExp}
+        */
+       function buildRegex( category, matchLineBreak ) {
+               var     categoryRegex, categoryNSFragment,
+                       titleFragment = $.escapeRE( category ).replace( /( |_)/g, '[ _]' ),
+                       firstChar = titleFragment.charAt( 0 );
+
+               // Filter out all names for category namespace
+               categoryNSFragment = $.map( mw.config.get( 'wgNamespaceIds' ), function( id, name ) {
+                       if ( id === catNsId ) {
+                               return makeCaseInsensitive( $.escapeRE( name ) );
                        }
-               } );
-               categoryNSFragment = categoryNSFragment.substr( 1 ); // Remove leading pipe
+               } ).join( '|' );
 
-               // Build the regex
-               var titleFragment = $.escapeRE( category ).replace( /( |_)/g, '[ _]' );
-               
-               firstChar = titleFragment.charAt( 0 );
                firstChar = '[' + firstChar.toUpperCase() + firstChar.toLowerCase() + ']';
                titleFragment = firstChar + titleFragment.substr( 1 );
-               var categoryRegex = '\\[\\[(' + categoryNSFragment + '):' + '[ _]*' +titleFragment + '(\\|[^\\]]*)?\\]\\]';
+               categoryRegex = '\\[\\[(' + categoryNSFragment + '):' + '[ _]*' + titleFragment + '(\\|[^\\]]*)?\\]\\]';
                if ( matchLineBreak ) {
                        categoryRegex += '[ \\t\\r]*\\n?';
                }
                return new RegExp( categoryRegex, 'g' );
-       };
-       
-
-mw.ajaxCategories = function( options ) {
-       //Save scope in shortcut
-       var that = this, _request, _saveAllButton, _cancelAllButton, _addContainer, defaults;
-       
-       defaults = { 
-               catLinkWrapper   : '<li/>',
-               $container       : $( '.catlinks' ),
-               $containerNormal : $( '#mw-normal-catlinks' ),
-               categoryLinkSelector : 'li a:not(.icon)',
-               multiEdit        : $.inArray( 'user', mw.config.get( 'wgUserGroups' ) ) + 1,
-               resolveRedirects : true
-       };
-       // merge defaults and options, without modifying defaults */
-       options = $.extend( {}, defaults, options );
+       }
 
        /**
-        * Insert a newly added category into the DOM
-        * 
-        * @param string category name.
-        * @return jQuery object
+        * Manufacture iconed button, with or without text.
+        *
+        * @param icon {String} The icon class.
+        * @param title {String} Title attribute.
+        * @param className {String} (optional) Additional classes to be added to the button.
+        * @param text {String} (optional) Text of button.
+        * @return {jQuery} The button.
         */
-       this.createCatLink = function( cat ) {
-               // User can implicitely state a sort key.
-               // Remove before display
-               cat = cat.replace(/\|.*/, '' );
-
-               // strip out bad characters
-               cat = clean ( cat );
+       function createButton( icon, title, className, text ){
+               // We're adding a zero width space for IE7, it's got problems with empty nodes apparently
+               var $button = $( '<a>' )
+                       .addClass( className || '' )
+                       .attr( 'title', title )
+                       .html( '&#8203;' );
 
-               if ( $.isEmpty( cat ) || that.containsCat( cat ) ) { 
-                       return; 
+               if ( text ) {
+                       var $icon = $( '<span>' ).addClass( 'icon ' + icon ).html( '&#8203;' );
+                       $button.addClass( 'icon-parent' ).append( $icon ).append( text );
+               } else {
+                       $button.addClass( 'icon ' + icon );
                }
+               return $button;
+       }
 
-               var $catLinkWrapper = $( options.catLinkWrapper );
-               var $anchor = $( '<a/>' ).append( cat );
-               $catLinkWrapper.append( $anchor );
-               $anchor.attr( { target: "_blank", href: catUrl( cat ) } );
-
-               _createCatButtons( $anchor );
+/**
+ * @constructor
+ * @param
+ */
+mw.ajaxCategories = function( options ) {
 
-               return $anchor;
-       };
+       this.options = options = $.extend( defaultOptions, options );
 
-       /**
-        * Takes a category link element
-        * and strips all data from it.
-        * 
-        * @param jQuery object
-        */
-       this.resetCatLink = function( $link, del, dontRestoreText ) {
-               $link.removeClass( 'mw-removed-category mw-added-category mw-changed-category' );
-               var data = $link.data();
+       // Save scope in shortcut
+       var     that = this;
 
-               if ( typeof data.stashIndex == "number" ) {
-                       _removeStashItem( data.stashIndex );                    
-               }
-               if ( del ) {
-                       $link.parent.remove();
-                       return;
-               }
-               if ( data.origCat && !dontRestoreText ) {
-                       $link.text( data.origCat );
-                       $link.attr( 'href', catUrl( data.origCat ) );
-               }
+       // Elements tied to this instance
+       this.saveAllButton = null;
+       this.cancelAllButton = null;
+       this.addContainer = null;
 
-               $link.removeData();
+       this.request = null;
 
-               //Readd static.
-               $link.data({
-                       saveButton      : data.saveButton,
-                       deleteButton: data.deleteButton,
-                       editButton      : data.editButton
-               });
+       // Stash and hooks
+       this.stash = {
+               summaries: [],
+               shortSum: [],
+               fns: []
+       };
+       this.hooks = {
+               beforeAdd: [],
+               beforeChange: [],
+               beforeDelete: [],
+               afterAdd: [],
+               afterChange: [],
+               afterDelete: []
        };
 
+       /* Event handlers */
+
        /**
-        * Reset all data from the category links and the stash.
-        * @param Boolean del Delete any category links with .mw-removed-category
+        * Handle add category submit. Not to be called directly.
+        *
+        * @context Element
+        * @param e {jQuery Event}
         */
-       this.resetAll = function( del ) {
-               var $links = options.$container.find( options.categoryLinkSelector ), $del = $();
-               if ( del ) {
-                       $del = $links.filter( '.mw-removed-category' ).parent();
-               }
-
-               $links.each( function() {
-                       that.resetCatLink( $( this ), false, del );
-               });
-               
-               $del.remove();
+       this.handleAddLink = function( e ) {
+               var     $el = $( this ),
+                       $link = $([]),
+                       categoryText = $.ucFirst( $el.parent().find( '.mw-addcategory-input' ).val() || '' );
 
-               if ( !options.$container.find( '#mw-hidden-catlinks li' ).length ) {
-                       options.$container.find( '#mw-hidden-catlinks' ).remove();
-               }
+               // Resolve redirects
+               that.resolveRedirects( categoryText, function( resolvedCat, exists ) {
+                       that.handleCategoryAdd( $link, resolvedCat, false, exists );
+               } );
        };
-       
+
        /**
-        * Create a suggestion box for use in edit/add dialogs
-        * @param str prefill Prefill input
-        * @param function callback on submit
-        * @param str buttonVal Button text
+        * @context Element
+        * @param e {jQuery Event}
         */
-       this._makeSuggestionBox = function( prefill, callback, buttonVal ) {
-               // Create add category prompt
-               var promptContainer = $( '<div class="mw-addcategory-prompt"/>' );
-               var promptTextbox = $( '<input type="text" size="30" class="mw-addcategory-input"/>' );
-               if ( prefill !== '' ) {
-                       promptTextbox.val( prefill );
-               }
-               var addButton = $( '<input type="button" class="mw-addcategory-button"/>' );
-               addButton.val( buttonVal );
+       this.createEditInterface = function( e ) {
+               var $el = $( this ),
+                       $link = $el.data( 'link' ),
+                       category = $link.text(),
+                       $input = that.makeSuggestionBox( category,
+                               that.handleEditLink,
+                               that.options.multiEdit ? mw.msg( 'ajax-confirm-ok' ) : mw.msg( 'ajax-confirm-save' )
+                       );
 
-               addButton.click( callback );
-               promptTextbox.keyup( function( e ) {
-               if ( e.keyCode == 13 ) addButton.click();
-               });
-               promptTextbox.suggestions( {
-                       'fetch': fetchSuggestions,
-                       'cancel': function() {
-                               var req = _request;
-                               // XMLHttpRequest.abort is unimplemented in IE6, also returns nonstandard value of "unknown" for typeof
-                               if ( req && ( typeof req.abort !== 'unknown' ) && ( typeof req.abort !== 'undefined' ) && req.abort ) {
-                                       req.abort();
-                               }
-                       }
-               } );
+               $link.after( $input ).hide();
 
-               promptTextbox.suggestions();
+               $input.find( '.mw-addcategory-input' ).focus();
 
-               promptContainer.append( promptTextbox );
-               promptContainer.append( addButton );
+               $link.data( 'editButton' ).hide();
 
-               return promptContainer;
+               $link.data( 'deleteButton' )
+                       .unbind( 'click' )
+                       .click( function() {
+                               $input.remove();
+                               $link.show().data( 'editButton' ).show();
+                               $( this )
+                                       .unbind( 'click' )
+                                       .click( that.handleDeleteLink )
+                                       .attr( 'title', mw.msg( 'ajax-remove-category' ) );
+                       })
+                       .attr( 'title', mw.msg( 'ajax-cancel' ) );
        };
 
        /**
-        * Parse the DOM $container and build a list of
-        * present categories
-        * 
-        * @return array Array of all categories
+        * Handle edit category submit. Not to be called directly.
+        *
+        * @context Element
+        * @param e {jQuery Event}
         */
-       this.getCats = function() {
-               return options.$container.find( options.categoryLinkSelector ).map( function() { return $.trim( $( this ).text() ); } );
-       };
+       this.handleEditLink = function( e ) {
+               var categoryNew,
+                       $el = $( this ),
+                       $link = $el.parent().parent().find( 'a:not(.icon)' ),
+                       sortkey = '';
 
-       /**
-        * Check whether a passed category is present in the DOM
-        * 
-        * @return boolean True for exists
-        */
-       this.containsCat = function( cat ) {
-               return that.getCats().filter( function() { return $.ucFirst( this ) == $.ucFirst( cat ); } ).length !== 0;
-       };
+               // Grab category text
+               categoryNew = $el.parent().find( '.mw-addcategory-input' ).val();
+               categoryNew = $.ucFirst( categoryNew.replace( /_/g, ' ' ) );
 
-       /**
-        * This gets called by all action buttons
-        * Displays a dialog to confirm the action
-        * Afterwards do the actual edit
-        *
-        * @param function fn text-modifying function 
-        * @param string actionSummary Changes done
-        * @param string shortSummary Changes, short version
-        * @param function fn doneFn callback after everything is done
-        * @return boolean True for exists
-        */
-       this._confirmEdit = function( fn, actionSummary, shortSummary, doneFn, $link, action ) {
-               // Check whether to use multiEdit mode
-               if ( options.multiEdit && action != 'all' ) {
-                       // Stash away
-                       $link.data( 'stashIndex', _stash.fns.length );
-                       $link.data( 'summary', actionSummary );
-                       _stash.summaries.push( actionSummary );
-                       _stash.shortSum.push( shortSummary );
-                       _stash.fns.push( fn );
+               // Strip sortkey
+               var arr = categoryNew.split( '|' );
+               if ( arr.length > 1 ) {
+                       categoryNew = arr.shift();
+                       sortkey = '|' + arr.join( '|' );
+               }
 
-                       _saveAllButton.show();
-                       _cancelAllButton.show();
+               // Grab text
+               var added = $link.hasClass( 'mw-added-category' );
+               that.resetCatLink( $link );
+               var category = $link.text();
 
-                       // This only does visual changes
-                       doneFn( true );
+               // Check for dupes ( exluding itself )
+               if ( category !== categoryNew && that.containsCat( categoryNew ) ) {
+                       $link.data( 'deleteButton' ).click();
                        return;
                }
-               // Produce a confirmation dialog
-               var dialog = $( '<div/>' );
-
-               dialog.addClass( 'mw-ajax-confirm-dialog' );
-               dialog.attr( 'title', mw.msg( 'ajax-confirm-title' ) );
-
-               // Summary of the action to be taken
-               var summaryHolder = $( '<p/>' );
-               summaryHolder.html( '<strong>' + mw.msg( 'ajax-category-question' ) + '</strong><br>' + actionSummary );
-               dialog.append( summaryHolder );
-
-               // Reason textbox.
-               var reasonBox = $( '<input type="text" size="45" />' );
-               reasonBox.addClass( 'mw-ajax-confirm-reason' );
-               dialog.append( reasonBox );
-
-               // Submit button
-               var submitButton = $( '<input type="button"/>' );
-               submitButton.val( mw.msg( 'ajax-confirm-save' ) );
-
-               var submitFunction = function() {
-                       that._addProgressIndicator( dialog );
-                       that._doEdit(
-                               mw.config.get( 'wgPageName' ),
-                               fn,
-                               shortSummary + ': ' + reasonBox.val(),
-                               function() {
-                                       doneFn();
-                                       dialog.dialog( 'close' );
-                                       that._removeProgressIndicator( dialog );
-                               }
-                       );
-               };
 
-               var buttons = {};
-               buttons[mw.msg( 'ajax-confirm-save' )] = submitFunction;
-               var dialogOptions = {
-                       'AutoOpen' : true,
-                       'buttons' : buttons,
-                       'width' : 450
-               };
+               // Resolve redirects
+               that.resolveRedirects( categoryNew, function( resolvedCat, exists ) {
+                       that.handleCategoryEdit( $link, category, resolvedCat, sortkey, exists, added );
+               });
+       };
 
-               $( '#catlinks' ).prepend( dialog );
-               dialog.dialog( dialogOptions );
+       /**
+        * Handle delete category submit. Not to be called directly.
+        *
+        * @context Element
+        * @param e {jQuery Event}
+        */
+       this.handleDeleteLink = function( e ) {
+               var     $el = $( this ),
+                       $link = $el.parent().find( 'a:not(.icon)' ),
+                       category = $link.text();
 
-               // Close on enter
-               dialog.keyup( function( e ) {
-               if ( e.keyCode == 13 ) submitFunction();
-               });
+               if ( $link.is( '.mw-added-category, .mw-changed-category' ) ) {
+                       // We're just cancelling the addition or edit
+                       that.resetCatLink( $link, $link.hasClass( 'mw-added-category' ) );
+                       return;
+               } else if ( $link.is( '.mw-removed-category' ) ) {
+                       // It's already removed...
+                       return;
+               }
+               that.handleCategoryDelete( $link, category );
        };
 
        /**
         * When multiEdit mode is enabled,
         * this is called when the user clicks "save all"
-        * Combines the summaries and edit functions
+        * Combines the summaries and edit functions.
+        *
+        * @context Element
+        * @return ?
         */
-       this._handleStashedCategories = function() {
-               var summary = '', fns = _stash.fns;
+       this.handleStashedCategories = function() {
 
                // Remove "holes" in array
-               summary = $.grep( _stash.summaries, function( n, i ) {
-                       return ( n );
-               });
+               var summary = $.grep( that.stash.summaries, function( n, i ) {
+                       return n;
+               } );
+
                if ( summary.length < 1 ) {
                        // Nothing to do here.
-                       _saveAllButton.hide();
-                       _cancelAllButton.hide();
+                       that.saveAllButton.hide();
+                       that.cancelAllButton.hide();
                        return;
                } else {
-                       summary = summary.join( '<br>' );
+                       summary = summary.join( '<br/>' );
                }
+
                // Remove "holes" in array
-               summaryShort = $.grep( _stash.shortSum, function( n,i ) {
-                       return ( n );
-               });
+               var summaryShort = $.grep( that.stash.shortSum, function( n,i ) {
+                       return n;
+               } );
                summaryShort = summaryShort.join( ', ' );
 
-               var combinedFn = function( oldtext ) {
-                       // Run the text through all action functions
-                       newtext = oldtext;
-                       for ( var i = 0; i < fns.length; i++ ) {
-                               if ( $.isFunction( fns[i] ) ) {
-                                       newtext = fns[i]( newtext );
-                                       if ( newtext === false ) {
-                                               return false;
+               var     fns = that.stash.fns;
+
+               that.doConfirmEdit( {
+                       modFn: function( oldtext ) {
+                               // Run the text through all action functions
+                               var newtext = oldtext;
+                               for ( var i = 0; i < fns.length; i++ ) {
+                                       if ( $.isFunction( fns[i] ) ) {
+                                               newtext = fns[i]( newtext );
+                                               if ( newtext === false ) {
+                                                       return false;
+                                               }
                                        }
                                }
-                       }
-                       return newtext;
-               };
-               var doneFn = function() { that.resetAll( true ); };
-
-               that._confirmEdit( combinedFn, summary, summaryShort, doneFn, '', 'all' );
+                               return newtext;
+                       },
+                       actionSummary: summary,
+                       shortSummary: summaryShort,
+                       doneFn: function() {
+                               that.resetAll( true );
+                       },
+                       $link: null,
+                       action: 'all'
+               } );
        };
+};
+
+/* Public methods */
 
+mw.ajaxCategories.prototype = {
        /**
-        * Do the actual edit.
-        * Gets token & text from api, runs it through fn
-        * and saves it with summary.
-        * @param str page Pagename
-        * @param function fn edit function
-        * @param str summary
-        * @param str doneFn Callback after all is done
+        * Create the UI
         */
-       this._doEdit = function( page, fn, summary, doneFn ) {
-               // Get an edit token for the page.
-               var getTokenVars = {
-                       'action':'query',
-                       'prop':'info|revisions',
-                       'intoken':'edit',
-                       'titles':page,
-                       'rvprop':'content|timestamp',
-                       'format':'json'
-               };
+       setup: function() {
+               // Could be set by gadgets like HotCat etc.
+               if ( mw.config.get( 'disableAJAXCategories' ) ) {
+                       return false;
+               }
+               // Only do it for articles.
+               if ( !mw.config.get( 'wgIsArticle' ) ) {
+                       return;
+               }
+               var options = this.options,
+                       that = this,
+                       // Create [Add Category] link
+                       $addLink = createButton( 'icon-add',
+                               mw.msg( 'ajax-add-category' ),
+                               'mw-ajax-addcategory',
+                               mw.msg( 'ajax-add-category' )
+                       ).click( function() {
+                               $( this ).nextAll().toggle().filter( '.mw-addcategory-input' ).focus();
+                       });
 
-               $.post( mw.util.wikiScript( 'api' ), getTokenVars,
-                       function( reply ) {
-                               var infos = reply.query.pages;
-                               $.each(
-                                       infos,
-                                       function( pageid, data ) {
-                                               var token = data.edittoken;
-                                               var timestamp = data.revisions[0].timestamp;
-                                               var oldText = data.revisions[0]['*'];
-
-                                               // Replace all nowiki and comments with unique keys
-                                               var key = mw.user.generateId();
-                                               var nowiki = [];
-                                               oldText = replaceNowikis( oldText, key, nowiki );
-                                               
-                                               // Then do the changes
-                                               var newText = fn( oldText );
-                                               if ( newText === false ) return;
-                                               
-                                               // And restore them back
-                                               newText = restoreNowikis( newText, key, nowiki );
-
-                                               var postEditVars = {
-                                                       'action':'edit',
-                                                       'title':page,
-                                                       'text':newText,
-                                                       'summary':summary,
-                                                       'token':token,
-                                                       'basetimestamp':timestamp,
-                                                       'format':'json'
-                                               };
-
-                                               $.post( mw.util.wikiScript( 'api' ), postEditVars, doneFn, 'json' )
-                                                .error( function( xhr, text, error ) {
-                                                       _showError( mw.msg( 'ajax-api-error', text, error ) );
-                                               });
-                                       }
-                               );
-                       }
-               , 'json' ).error( function( xhr, text, error ) {
-                       _showError( mw.msg( 'ajax-api-error', text, error ) );
+               // Create add category prompt
+               this.addContainer = this.makeSuggestionBox( '', this.handleAddLink, mw.msg( 'ajax-add-category-submit' ) );
+               this.addContainer.children().hide();
+               this.addContainer.prepend( $addLink );
+
+               // Create edit & delete link for each category.
+               $( '#catlinks li a' ).each( function() {
+                       that.createCatButtons( $( this ) );
                });
-       };
-       /**
-        * Append spinner wheel to element
-        * @param DOMObject element.
-        */
-       this._addProgressIndicator = function( elem ) {
-               elem.append( $( '<div/>' ).addClass( 'mw-ajax-loader' ) );
-       };
+
+               options.$containerNormal.append( this.addContainer );
+
+               // @todo Make more clickable
+               this.saveAllButton = createButton( 'icon-tick',
+                       mw.msg( 'ajax-confirm-save-all' ),
+                       '',
+                       mw.msg( 'ajax-confirm-save-all' )
+               );
+               this.cancelAllButton = createButton( 'icon-close',
+                       mw.msg( 'ajax-cancel-all' ),
+                       '',
+                       mw.msg( 'ajax-cancel-all' )
+               );
+               this.saveAllButton.click( this.handleStashedCategories ).hide();
+               this.cancelAllButton.click( function() {
+                       that.resetAll( false );
+               } ).hide();
+               options.$containerNormal.append( this.saveAllButton ).append( this.cancelAllButton );
+               options.$container.append( this.addContainer );
+       },
 
        /**
-        * Find and remove spinner wheel from inside element
-        * @param DOMObject parent element.
-        */
-       this._removeProgressIndicator = function( elem ) {
-               elem.find( '.mw-ajax-loader' ).remove();
-       };
-       
-       /**
-        * Checks the API whether the category in question is a redirect.
-        * Also returns existance info ( to color link red/blue )
-        * @param string category.
-        * @param function callback
+        * Insert a newly added category into the DOM.
+        *
+        * @param cat {String} Category name.
+        * @return {jQuery}
         */
-       this._resolveRedirects = function( category, callback ) {
-               if ( !options.resolveRedirects ) {
-                       callback( category );
+       createCatLink: function( cat ) {
+               // User can implicitely state a sort key.
+               // Remove before display.
+               // strip out bad characters
+               cat = clean( cat.replace( /\|.*/, '' ) );
+
+               if ( $.isEmpty( cat ) || this.containsCat( cat ) ) {
                        return;
                }
-               var queryVars = {
-                       'action':'query',
-                       'titles': new mw.Title( category,  catNsId ).toString(),
-                       'redirects':'',
-                       'format' : 'json'
-               };
 
-               $.get( mw.util.wikiScript( 'api' ), queryVars,
-                       function( reply ) {
-                               var redirect = reply.query.redirects;
-                               if ( redirect ) {
-                                       category = new mw.Title( redirect[0].to )._name;
-                               }
-                               callback( category, !reply.query.pages[-1] );
-                       }
-               , 'json' );
-       };
-       
+               var     $catLinkWrapper = $( this.options.catLinkWrapper ),
+                       $anchor = $( '<a>' )
+                               .append( cat )
+                               .attr( {
+                                       target: '_blank',
+                                       href: catUrl( cat )
+                               } );
+
+               $catLinkWrapper.append( $anchor );
+
+               this.createCatButtons( $anchor );
+
+               return $anchor;
+       },
+
        /**
-        * Handle add category submit. Not to be called directly
+        * Create a suggestion box for use in edit/add dialogs
+        * @param prefill {String} Prefill input
+        * @param callback {Function} Called on submit
+        * @param buttonVal {String} Button text
         */
-       this._handleAddLink = function( e ) {
-               var $this = $( this ), $link = $();
+       makeSuggestionBox: function( prefill, callback, buttonVal ) {
+               // Create add category prompt
+               var     $promptContainer = $( '<div class="mw-addcategory-prompt"></div>' ),
+                       $promptTextbox = $( '<input type="text" size="30" class="mw-addcategory-input"></input>' ),
+                       $addButton = $( '<input type="button" class="mw-addcategory-button"></input>' ),
+                       that = this;
 
-               // Grab category text
-               var category = $this.parent().find( '.mw-addcategory-input' ).val();
-               category = $.ucFirst( category );
+               if ( prefill !== '' ) {
+                       $promptTextbox.val( prefill );
+               }
+
+               $addButton
+                       .val( buttonVal )
+                       .click( callback );
+
+               $promptTextbox
+                       .keyup( function( e ) {
+                               if ( e.keyCode === 13 ) {
+                                       $addButton.click();
+                               }
+                       } )
+                       .suggestions( {
+                               fetch: fetchSuggestions,
+                               cancel: function() {
+                                       var req = this.data( 'suggestions-request' );
+                                       // XMLHttpRequest.abort is unimplemented in IE6, also returns nonstandard value of 'unknown' for typeof
+                                       if ( req && typeof req.abort !== 'unknown' && typeof req.abort !== 'undefined' && req.abort ) {
+                                               req.abort();
+                                       }
+                               }
+                       } )
+                       .suggestions();
+
+               $promptContainer
+                       .append( $promptTextbox )
+                       .append( $addButton );
+
+               return $promptContainer;
+       },
 
-               // Resolve redirects
-               that._resolveRedirects( category, function( resolvedCat, exists ) {
-                       that.handleCategoryAdd( $link, resolvedCat, false, exists );
-               } );
-       };
        /**
-        * Execute or queue an category add
+        * Execute or queue an category add.
+        * @param $link {jQuery}
+        * @param category
+        * @param noAppend
+        * @param exists
+        * @return {mw.ajaxCategories}
         */
-       this.handleCategoryAdd = function( $link, category, noAppend, exists ) {
-               if ( !$link.length ) {
-                       $link = that.createCatLink( category );
-               }
-               // Mark red if missing
-               $link.toggleClass( 'new', exists === false );
-               
+       handleCategoryAdd: function( $link, category, noAppend, exists ) {
                // Handle sortkey
-               var arr = category.split( '|' ), sortkey = '';
-               
+               var     arr = category.split( '|' ),
+                       sortkey = '',
+                       that = this;
+
                if ( arr.length > 1 ) {
                        category = arr.shift();
                        sortkey = '|' + arr.join( '|' );
-                       if ( sortkey == '|' ) sortkey = '';
-               }
-               
-               //Replace underscores 
-               category = category.replace(/_/g, ' ' );
-               
-               if ( that.containsCat( category ) ) {
-                       _showError( mw.msg( 'ajax-category-already-present', category ) );
-                       return;
+                       if ( sortkey === '|' ) {
+                               sortkey = '';
+                       }
                }
-               var catFull = new mw.Title( category,  catNsId ).toString().replace(/_/g, ' ' );
-               var appendText = "\n[[" + catFull + sortkey + "]]\n";
-               var summary = mw.msg( 'ajax-add-category-summary', category );
-               var shortSummary = '+[[' + catFull + ']]';
-               that._confirmEdit(
-                       function( oldText ) {
-                               newText = _runHooks ( oldText, 'beforeAdd', category );
-                               newText = newText + appendText;
-                               return _runHooks ( newText, 'afterAdd', category );
-                       },
-                       summary,
-                       shortSummary,
-                       function( unsaved ) {
-                               if ( !noAppend ) {
-                                       options.$container.find( '#mw-normal-catlinks>.mw-addcategory-prompt' ).children( 'input' ).hide();
-                                       options.$container.find( '#mw-normal-catlinks ul' ).append( $link.parent() );
-                               } else {
-                                       // Remove input box & button
-                                       $link.data( 'deleteButton' ).click();
 
-                                       // Update link text and href
-                                       $link.show().text( category ).attr( 'href', catUrl( category ) );
-                               }
-                               if ( unsaved ) {
-                                       $link.addClass( 'mw-added-category' );
-                               }
-                               $( '.mw-ajax-addcategory' ).click();
-                       },
-                       $link,
-                       'add'
-               );
-       };
-       this._createEditInterface = function( e ) {
-               var $this = $( this ),
-                       $link = $this.data( 'link' ),
-                       category = $link.text();
-               var $input = that._makeSuggestionBox( category, 
-                                               that._handleEditLink, 
-                                               options.multiEdit ? mw.msg( 'ajax-confirm-ok' ) : mw.msg( 'ajax-confirm-save' ) 
-                       );
-               $link.after( $input ).hide();
-               $input.find( '.mw-addcategory-input' ).focus();
-               $link.data( 'editButton' ).hide();
-               $link.data( 'deleteButton' ).unbind( 'click' ).click( function() {
-                       $input.remove();
-                       $link.show();
-                       $link.data( 'editButton' ).show();
-                       $( this ).unbind( 'click' ).click( that._handleDeleteLink )
-                               .attr( 'title', mw.msg( 'ajax-remove-category' ));
-               }).attr( 'title', mw.msg( 'ajax-cancel' ));     
-       };
-       
-       /**
-        * Handle edit category submit. Not to be called directly
-        */
-       this._handleEditLink = function( e ) {
-               var $this = $( this ),
-                       $link = $this.parent().parent().find( 'a:not(.icon)' ),
-                       categoryNew, sortkey = '';
-               
-               // Grab category text
-               categoryNew = $this.parent().find( '.mw-addcategory-input' ).val();
-               categoryNew = $.ucFirst( categoryNew.replace(/_/g, ' ' ) );
-               
-               // Strip sortkey
-               var arr = categoryNew.split( '|' );
-               if ( arr.length > 1 ) {
-                       categoryNew = arr.shift();
-                       sortkey = '|' + arr.join( '|' );
+               if ( !$link.length ) {
+                       $link = this.createCatLink( category );
                }
 
-               // Grab text
-               var added = $link.hasClass( 'mw-added-category' );
-               that.resetCatLink ( $link );
-               var category = $link.text();
+               if ( this.containsCat( category ) ) {
+                       this.showError( mw.msg( 'ajax-category-already-present', category ) );
+                       return this;
+               }
 
-               // Check for dupes ( exluding itself )
-               if ( category != categoryNew && that.containsCat( categoryNew ) ) {
-                       $link.data( 'deleteButton' ).click();
-                       return;
+               // Sometimes createCatLink returns undefined/null, previously caused an exception
+               // in the following lines, catching now.. @todo
+               if ( !$link ) {
+                       this.showError( 'Unexpected error occurred. $link undefined.' );
+                       return this;
                }
 
-               // Resolve redirects
-               that._resolveRedirects( categoryNew, function( resolvedCat, exists ) {
-                       that.handleCategoryEdit( $link, category, resolvedCat, sortkey, exists, added );
-               });
-       };
+               // Mark red if missing
+               $link.toggleClass( 'new', exists !== true );
+
+               // Replace underscores
+               category = category.replace( /_/g, ' ' );
+               var catFull = new mw.Title( category, catNsId ).toString().replace( /_/g, ' ' );
+
+               this.doConfirmEdit( {
+                       modFn: function( oldText ) {
+                               var newText = that.runHooks( oldText, 'beforeAdd', category );
+                               newText = newText + "\n[[" + catFull + sortkey + "]]\n";
+                               return that.runHooks( newText, 'afterAdd', category );
+                       },
+                       actionSummary: mw.msg( 'ajax-add-category-summary', category ),
+                       shortSummary: '+[[' + catFull + ']]',
+                       doneFn: function( unsaved ) {
+                               if ( !noAppend ) {
+                                       that.options.$container
+                                               .find( '#mw-normal-catlinks > .mw-addcategory-prompt' ).children( 'input' ).hide();
+                                       that.options.$container
+                                               .find( '#mw-normal-catlinks ul' ).append( $link.parent() );
+                               } else {
+                                       // Remove input box & button
+                                       $link.data( 'deleteButton' ).click();
+
+                                       // Update link text and href
+                                       $link.show().text( category ).attr( 'href', catUrl( category ) );
+                               }
+                               if ( unsaved ) {
+                                       $link.addClass( 'mw-added-category' );
+                               }
+                               $( '.mw-ajax-addcategory' ).click();
+                       },
+                       $link: $link,
+                       action: 'add'
+               } );
+               return this;
+       },
+
        /**
-        * Execute or queue an category edit
+        * Execute or queue an category edit.
+        * @param $link {jQuery}
+        * @param category
+        * @param categoryNew
+        * @param sortkeyNew
+        * @param exists {Boolean}
+        * @param added {Boolean}
         */
-       this.handleCategoryEdit = function( $link, category, categoryNew, sortkeyNew, exists, added ) {
+       handleCategoryEdit: function( $link, category, categoryNew, sortkeyNew, exists, added ) {
+               var that = this;
+
                // Category add needs to be handled differently
                if ( added ) {
                        // Pass sortkey back
                        that.handleCategoryAdd( $link, categoryNew + sortkeyNew, true );
                        return;
                }
+
                // User didn't change anything.
-               if ( category == categoryNew + sortkeyNew ) {
+               if ( category === categoryNew + sortkeyNew ) {
                        $link.data( 'deleteButton' ).click();
                        return;
                }
+
                // Mark red if missing
-               $link.toggleClass( 'new', exists === false );
-       
-               categoryRegex = buildRegex( category );
-               
-               var summary = mw.msg( 'ajax-edit-category-summary', category, categoryNew );
-               var shortSummary = '[[' + new mw.Title( category,  catNsId ) + ']] -> [[' + new mw.Title( categoryNew,  catNsId ) + ']]';
-               that._confirmEdit(
-                       function( oldText ) {
-                               newText = _runHooks ( oldText, 'beforeChange', category, categoryNew );
-
-                               var matches = newText.match( categoryRegex );
+               $link[(exists === false ? 'addClass' : 'removeClass')]( 'new' );
+
+               var     categoryRegex = buildRegex( category ),
+                       shortSummary = '[[' + new mw.Title( category, catNsId ) + ']] -> [[' + new mw.Title( categoryNew, catNsId ) + ']]';
+               that.doConfirmEdit({
+                       modFn: function( oldText ) {
+                               var     sortkey, newCategoryString,
+                                       newText = that.runHooks( oldText, 'beforeChange', category, categoryNew ),
+                                       matches = newText.match( categoryRegex );
 
                                //Old cat wasn't found, likely to be transcluded
                                if ( !$.isArray( matches ) ) {
-                                       _showError( mw.msg( 'ajax-edit-category-error' ) );
+                                       that.showError( mw.msg( 'ajax-edit-category-error' ) );
                                        return false;
                                }
-                               var sortkey = sortkeyNew || matches[0].replace( categoryRegex, '$2' );
-                               var newCategoryString = "[[" + new mw.Title( categoryNew, catNsId ) + sortkey + ']]';
+                               sortkey = sortkeyNew || matches[0].replace( categoryRegex, '$2' );
+                               newCategoryString = '[[' + new mw.Title( categoryNew, catNsId ) + sortkey + ']]';
 
                                if ( matches.length > 1 ) {
                                        // The category is duplicated.
                                        // Remove all but one match
                                        for ( var i = 1; i < matches.length; i++ ) {
-                                               oldText = oldText.replace( matches[i], '' ); 
+                                               oldText = oldText.replace( matches[i], '' );
                                        }
                                }
-                               var newText = oldText.replace( categoryRegex, newCategoryString );
+                               newText = oldText.replace( categoryRegex, newCategoryString );
 
-                               return _runHooks ( newText, 'afterChange', category, categoryNew );
+                               return that.runHooks( newText, 'afterChange', category, categoryNew );
                        },
-                       summary, 
-                       shortSummary,
-                       function( unsaved ) {
+                       actionSummary: mw.msg( 'ajax-edit-category-summary', category, categoryNew ),
+                       shortSummary: shortSummary,
+                       doneFn: function( unsaved ) {
                                // Remove input box & button
                                $link.data( 'deleteButton' ).click();
 
@@ -710,246 +660,397 @@ mw.ajaxCategories = function( options ) {
                                        $link.data( 'origCat', category ).addClass( 'mw-changed-category' );
                                }
                        },
-                       $link,
-                       'edit'
-               );
-       };
-       
+                       $link: $link,
+                       action: 'edit'
+               });
+       },
+
        /**
-        * Handle delete category submit. Not to be called directly
+        * Checks the API whether the category in question is a redirect.
+        * Also returns existance info (to color link red/blue)
+        * @param string category.
+        * @param function callback
         */
-       this._handleDeleteLink = function() {
-               var $this = $( this ),
-                       $link = $this.parent().find( 'a:not(.icon)' ),
-                       category = $link.text();
-
-               if ( $link.is( '.mw-added-category, .mw-changed-category' ) ) {
-                       // We're just cancelling the addition or edit
-                       that.resetCatLink ( $link, $link.hasClass( 'mw-added-category' ) );
-                       return;
-               } else if ( $link.is( '.mw-removed-category' ) ) {
-                       // It's already removed...
+       resolveRedirects: function( category, callback ) {
+               if ( !this.options.resolveRedirects ) {
+                       callback( category, true );
                        return;
                }
-               that.handleCategoryDelete( $link, category );
-       };
-       
+               var queryVars = {
+                       action:'query',
+                       titles: new mw.Title( category, catNsId ).toString(),
+                       redirects: '',
+                       format: 'json'
+               };
+
+               $.get( mw.util.wikiScript( 'api' ), queryVars,
+                       function( reply ) {
+                               var redirect = reply.query.redirects;
+                               if ( redirect ) {
+                                       category = new mw.Title( redirect[0].to )._name;
+                               }
+                               callback( category, !reply.query.pages[-1] );
+                       }, 'json'
+               );
+       },
+
+       /**
+        * Append edit and remove buttons to a given category link
+        *
+        * @param DOMElement element Anchor element, to which the buttons should be appended.
+        * @return {mw.ajaxCategories}
+        */
+       createCatButtons: function( $element ) {
+               var     deleteButton = createButton( 'icon-close', mw.msg( 'ajax-remove-category' ) ),
+                       editButton = createButton( 'icon-edit', mw.msg( 'ajax-edit-category' ) ),
+                       saveButton = createButton( 'icon-tick', mw.msg( 'ajax-confirm-save' ) ).hide(),
+                       that = this;
+
+               deleteButton.click( this.handleDeleteLink );
+               editButton.click( that.createEditInterface );
+
+               $element.after( deleteButton ).after( editButton );
+
+               // Save references to all links and buttons
+               $element.data( {
+                       deleteButton: deleteButton,
+                       editButton: editButton,
+                       saveButton: saveButton
+               } );
+               editButton.data( {
+                       link: $element
+               } );
+               return this;
+       },
+
+       /**
+        * Append spinner wheel to element.
+        * @param $el {jQuery}
+        * @return {mw.ajaxCategories}
+        */
+       addProgressIndicator: function( $el ) {
+               $el.append( $( '<div>' ).addClass( 'mw-ajax-loader' ) );
+               return this;
+       },
+
+       /**
+        * Find and remove spinner wheel from inside element.
+        * @param $el {jQuery}
+        * @return {mw.ajaxCategories}
+        */
+       removeProgressIndicator: function( $el ) {
+               $el.find( '.mw-ajax-loader' ).remove();
+               return this;
+       },
+
        /**
-        * Execute or queue an category delete
+        * Parse the DOM $container and build a list of
+        * present categories.
+        *
+        * @return {Array} All categories.
+        */
+       getCats: function() {
+               var cats = this.options.$container
+                               .find( this.options.categoryLinkSelector )
+                               .map( function() {
+                                       return $.trim( $( this ).text() );
+                               } );
+               return cats;
+       },
+
+       /**
+        * Check whether a passed category is present in the DOM.
+        *
+        * @return {Boolean}
         */
-       this.handleCategoryDelete = function( $link, category ) {
-               var categoryRegex = buildRegex( category, true );
+       containsCat: function( cat ) {
+               cat = $.ucFirst( cat );
+               return this.getCats().filter( function() {
+                       return $.ucFirst( this ) === cat;
+               } ).length !== 0;
+       },
 
-               var summary = mw.msg( 'ajax-remove-category-summary', category );
-               var shortSummary = '-[[' + new mw.Title( category,  catNsId ) + ']]';
+       /**
+        * Execute or queue an category delete.
+        *
+        * @param $link {jQuery}
+        * @param category
+        * @return ?
+        */
+       handleCategoryDelete: function( $link, category ) {
+               var     categoryRegex = buildRegex( category, true ),
+                       that = this;
 
-               that._confirmEdit(
-                       function( oldText ) {
-                               newText = _runHooks ( oldText, 'beforeDelete', category );
-                               var newText = newText.replace( categoryRegex, '' );
+               that.doConfirmEdit({
+                       modFn: function( oldText ) {
+                               var newText = that.runHooks( oldText, 'beforeDelete', category );
+                               newText = newText.replace( categoryRegex, '' );
 
-                               if ( newText == oldText ) {
-                                       _showError( mw.msg( 'ajax-remove-category-error' ) );
+                               if ( newText === oldText ) {
+                                       that.showError( mw.msg( 'ajax-remove-category-error' ) );
                                        return false;
                                }
 
-                               return _runHooks ( newText, 'afterDelete', category );
+                               return that.runHooks( newText, 'afterDelete', category );
                        },
-                       summary,
-                       shortSummary,
-                       function( unsaved ) {
+                       actionSummary: mw.msg( 'ajax-remove-category-summary', category ),
+                       shortSummary: '-[[' + new mw.Title( category, catNsId ) + ']]',
+                       doneFn: function( unsaved ) {
                                if ( unsaved ) {
                                        $link.addClass( 'mw-removed-category' );
                                } else {
                                        $link.parent().remove();
                                }
                        },
-                       $link,
-                       'delete'
-               );
-       };
-       
+                       $link: $link,
+                       action: 'delete'
+               });
+       },
 
        /**
-        * Open a dismissable error dialog 
+        * Takes a category link element
+        * and strips all data from it.
         *
-        * @param string str The error description
+        * @param $link {jQuery}
+        * @param del {Boolean}
+        * @param dontRestoreText {Boolean}
+        * @return ?
         */
-       _showError = function( str ) {
-               var oldDialog = $( '.mw-ajax-confirm-dialog' );
-               that._removeProgressIndicator( oldDialog );
-               oldDialog.dialog( 'close' );
-               
-               var dialog = $( '<div/>' );
-               dialog.text( str );
-
-               mw.util.$content.append( dialog );
+       resetCatLink: function( $link, del, dontRestoreText ) {
+               $link.removeClass( 'mw-removed-category mw-added-category mw-changed-category' );
+               var data = $link.data();
 
-               var buttons = { };
-               buttons[mw.msg( 'ajax-confirm-ok' )] = function( e ) {
-                       dialog.dialog( 'close' );
-               };
-               var dialogOptions = {
-                       'buttons' : buttons,
-                       'AutoOpen' : true,
-                       'title' : mw.msg( 'ajax-error-title' )
-               };
+               if ( typeof data.stashIndex === 'number' ) {
+                       this.removeStashItem( data.stashIndex );
+               }
+               if ( del ) {
+                       $link.parent.remove();
+                       return;
+               }
+               if ( data.origCat && !dontRestoreText ) {
+                       $link.text( data.origCat );
+                       $link.attr( 'href', catUrl( data.origCat ) );
+               }
 
-               dialog.dialog( dialogOptions );
+               $link.removeData();
 
-               // Close on enter
-               dialog.keyup( function( e ) {
-               if ( e.keyCode == 13 ) dialog.dialog( 'close' );
-               });
-       };
+               // Readd static.
+               $link.data( {
+                       saveButton: data.saveButton,
+                       deleteButton: data.deleteButton,
+                       editButton: data.editButton
+               } );
+       },
 
        /**
-        * Manufacture iconed button, with or without text 
-        *
-        * @param string icon The icon class.
-        * @param string title Title attribute.
-        * @param string className (optional) Additional classes to be added to the button.
-        * @param string text (optional) Text of button.
-        *
-        * @return jQueryObject The button
+        * Do the actual edit.
+        * Gets token & text from api, runs it through fn
+        * and saves it with summary.
+        * @param page {String} Pagename
+        * @param fn {Function} edit function
+        * @param summary {String}
+        * @param doneFn {String} Callback after all is done
         */
-       _createButton = function( icon, title, className, text ){
-               // We're adding a zero width space for IE7, it's got problems with empty nodes apparently
-               var $button = $( '<a>' ).addClass( className || '' )
-                       .attr( 'title', title ).html( '&#8203;' );
+       doEdit: function( page, fn, summary, doneFn ) {
+               // Get an edit token for the page.
+               var getTokenVars = {
+                       action: 'query',
+                       prop: 'info|revisions',
+                       intoken: 'edit',
+                       titles: page,
+                       rvprop: 'content|timestamp',
+                       format: 'json'
+               }, that = this;
+
+               $.post(
+                       mw.util.wikiScript( 'api' ),
+                       getTokenVars,
+                       function( reply ) {
+                               var infos = reply.query.pages;
+                               $.each( infos, function( pageid, data ) {
+                                       var token = data.edittoken;
+                                       var timestamp = data.revisions[0].timestamp;
+                                       var oldText = data.revisions[0]['*'];
+
+                                       // Replace all nowiki and comments with unique keys
+                                       var key = mw.user.generateId();
+                                       var nowiki = [];
+                                       oldText = replaceNowikis( oldText, key, nowiki );
+
+                                       // Then do the changes
+                                       var newText = fn( oldText );
+                                       if ( newText === false ) {
+                                               return;
+                                       }
 
-               if ( text ) {
-                       var $icon = $( '<span>' ).addClass( 'icon ' + icon ).html( '&#8203;' );
-                       $button.addClass( 'icon-parent' ).append( $icon ).append( text );
-               } else {
-                       $button.addClass( 'icon ' + icon );
-               }
-               return $button;
-       };
+                                       // And restore them back
+                                       newText = restoreNowikis( newText, key, nowiki );
+
+                                       var postEditVars = {
+                                               action: 'edit',
+                                               title: page,
+                                               text: newText,
+                                               summary: summary,
+                                               token: token,
+                                               basetimestamp: timestamp,
+                                               format: 'json'
+                                       };
+
+                                       $.post( mw.util.wikiScript( 'api' ), postEditVars, doneFn, 'json' )
+                                        .error( function( xhr, text, error ) {
+                                               that.showError( mw.msg( 'ajax-api-error', text, error ) );
+                                       });
+                               } );
+                       },
+                       'json'
+               ).error( function( xhr, text, error ) {
+                       that.showError( mw.msg( 'ajax-api-error', text, error ) );
+               } );
+       },
 
        /**
-        * Append edit and remove buttons to a given category link 
+        * This gets called by all action buttons
+        * Displays a dialog to confirm the action
+        * Afterwards do the actual edit.
         *
-        * @param DOMElement element Anchor element, to which the buttons should be appended.
+        * @param props {Object}:
+        * - modFn {Function} text-modifying function
+        * - actionSummary {String} Changes done
+        * - shortSummary {String} Changes, short version
+        * - doneFn {Function} callback after everything is done
+        * - $link {jQuery}
+        * - action
+        * @return {mw.ajaxCategories}
         */
-       _createCatButtons = function( $element ) {
-               // Create remove & edit buttons
-               var deleteButton = _createButton( 'icon-close', mw.msg( 'ajax-remove-category' ) );
-               var editButton = _createButton( 'icon-edit', mw.msg( 'ajax-edit-category' ) );
+       doConfirmEdit: function( props ) {
+               var summaryHolder, reasonBox, dialog, submitFunction,
+                       buttons = {},
+                       dialogOptions = {
+                               AutoOpen: true,
+                               buttons: buttons,
+                               width: 450
+                       },
+                       that = this;
 
-               //Not yet used
-               var saveButton = _createButton( 'icon-tick', mw.msg( 'ajax-confirm-save' ) ).hide();
+               // Check whether to use multiEdit mode:
+               if ( this.options.multiEdit && props.action !== 'all' ) {
 
-               deleteButton.click( that._handleDeleteLink );
-               editButton.click( that._createEditInterface );
+                       // Stash away
+                       props.$link
+                               .data( 'stashIndex', this.stash.fns.length )
+                               .data( 'summary', props.actionSummary );
 
-               $element.after( deleteButton ).after( editButton );
+                       this.stash.summaries.push( props.actionSummary );
+                       this.stash.shortSum.push( props.shortSummary );
+                       this.stash.fns.push( props.modFn );
 
-               //Save references to all links and buttons
-               $element.data({
-                       saveButton      : saveButton,
-                       deleteButton: deleteButton,
-                       editButton      : editButton
-               });
-               editButton.data({
-                       link    : $element
-               });
-       };
+                       this.saveAllButton.show();
+                       this.cancelAllButton.show();
 
-       /**
-        * Create the UI 
-        */
-       this.setup = function() {
-               // Could be set by gadgets like HotCat etc.
-               if ( mw.config.get( 'disableAJAXCategories' ) ) {
-                       return false;
+                       // Clear input field after action
+                       that.addContainer.find( '.mw-addcategory-input' ).val( '' );
+
+                       // This only does visual changes, fire done and return.
+                       props.doneFn( true );
+                       return this;
                }
-               // Only do it for articles.
-               if ( !mw.config.get( 'wgIsArticle' ) ) return;
-
-               // Create [Add Category] link
-               var addLink = _createButton( 'icon-add', 
-                                                                       mw.msg( 'ajax-add-category' ), 
-                                                                       'mw-ajax-addcategory', 
-                                                                       mw.msg( 'ajax-add-category' )
-                                                                  );
-               addLink.click( function() {
-                       $( this ).nextAll().toggle().filter( '.mw-addcategory-input' ).focus();
-               });
-               
 
-               // Create add category prompt
-               _addContainer = that._makeSuggestionBox( '', that._handleAddLink, mw.msg( 'ajax-add-category-submit' ) );
-               _addContainer.children().hide();
+               // Summary of the action to be taken
+               summaryHolder = $( '<p>' )
+                       .html( '<strong>' + mw.msg( 'ajax-category-question' ) + '</strong><br/>' + props.actionSummary );
 
-               _addContainer.prepend( addLink );
+               // Reason textbox.
+               reasonBox = $( '<input type="text" size="45"></input>' )
+                       .addClass( 'mw-ajax-confirm-reason' );
 
-               // Create edit & delete link for each category.
-               $( '#catlinks li a' ).each( function() {
-                       _createCatButtons( $( this ) );
-               });
+               // Produce a confirmation dialog
+               dialog = $( '<div>' )
+                       .addClass( 'mw-ajax-confirm-dialog' )
+                       .attr( 'title', mw.msg( 'ajax-confirm-title' ) )
+                       .append( summaryHolder )
+                       .append( reasonBox );
 
-               options.$containerNormal.append( _addContainer );
-
-               //TODO Make more clickable
-               _saveAllButton = _createButton( 'icon-tick', 
-                                                                               mw.msg( 'ajax-confirm-save-all' ), 
-                                                                               '', 
-                                                                               mw.msg( 'ajax-confirm-save-all' ) 
-                                                                               );
-               _cancelAllButton = _createButton( 'icon-close', 
-                                                                               mw.msg( 'ajax-cancel-all' ), 
-                                                                               '', 
-                                                                               mw.msg( 'ajax-cancel-all' ) 
-                                                                               );
-               _saveAllButton.click( that._handleStashedCategories ).hide();
-               _cancelAllButton.click( function() { that.resetAll( false ); } ).hide();
-               options.$containerNormal.append( _saveAllButton ).append( _cancelAllButton );
-               options.$container.append( _addContainer );
-       };
+               // Submit button
+               submitFunction = function() {
+                       that.addProgressIndicator( dialog );
+                       that.doEdit(
+                               mw.config.get( 'wgPageName' ),
+                               props.modFn,
+                               props.shortSummary + ': ' + reasonBox.val(),
+                               function() {
+                                       props.doneFn();
 
-       _stash = {
-               summaries : [],
-               shortSum : [],
-               fns : []
-       };
-       _removeStashItem = function( i ) {
-               if ( typeof i != "number" ) {
+                                       // Clear input field after successful edit
+                                       that.addContainer.find( '.mw-addcategory-input' ).val( '' );
+
+                                       dialog.dialog( 'close' );
+                                       that.removeProgressIndicator( dialog );
+                               }
+                       );
+               };
+
+               buttons[mw.msg( 'ajax-confirm-save' )] = submitFunction;
+
+               dialog.dialog( dialogOptions ).keyup( function( e ) {
+                       // Close on enter
+                       if ( e.keyCode === 13 ) {
+                               submitFunction();
+                       }
+               } );
+
+               return this;
+       },
+
+       /**
+        * @param index {Number|jQuery} Stash index or jQuery object of stash item.
+        * @return {mw.ajaxCategories}
+        */
+       removeStashItem: function( i ) {
+               if ( typeof i !== 'number' ) {
                        i = i.data( 'stashIndex' );
                }
-               delete _stash.fns[i];
-               delete _stash.summaries[i];
-               if ( $.isEmpty( _stash.fns ) ) {
-                       _stash.fns = [];
-                       _stash.summaries = [];
-                       _stash.shortSum = [];
-                       _saveAllButton.hide();
-                       _cancelAllButton.hide();
+
+               try {
+                       delete this.stash.fns[i];
+                       delete this.stash.summaries[i];
+               } catch(e) {}
+
+               if ( $.isEmpty( this.stash.fns ) ) {
+                       this.stash.fns = [];
+                       this.stash.summaries = [];
+                       this.stash.shortSum = [];
+                       this.saveAllButton.hide();
+                       this.cancelAllButton.hide();
                }
-       };
-       _hooks = {
-               beforeAdd : [],
-               beforeChange : [],
-               beforeDelete : [],
-               afterAdd : [],
-               afterChange : [],
-               afterDelete : []
-       };
-       _runHooks = function( oldtext, type, category, categoryNew ) {
-               // No hooks registered
-               if ( !_hooks[type] ) {  
-                       return oldtext;
-               } else {
-                       for ( var i = 0; i < _hooks[type].length; i++ ) {
-                               oldtext = _hooks[type][i]( oldtext, category, categoryNew );
-                               if ( oldtext === false ) {
-                                       _showError( mw.msg( 'ajax-category-hook-error', category ) );
-                                       return;
-                               }
-                       }
-                       return oldtext;
+               return this;
+       },
+
+       /**
+        * Reset all data from the category links and the stash.
+        *
+        * @param del {Boolean} Delete any category links with .mw-removed-category
+        * @return {mw.ajaxCategories}
+        */
+       resetAll: function( del ) {
+               var     $links = this.options.$container.find( this.options.categoryLinkSelector ),
+                       $del = $([]),
+                       that = this;
+
+               if ( del ) {
+                       $del = $links.filter( '.mw-removed-category' ).parent();
                }
-       };
+
+               $links.each( function() {
+                       that.resetCatLink( $( this ), false, del );
+               } );
+
+               $del.remove();
+
+               this.options.$container.find( '#mw-hidden-catlinks' ).remove();
+
+               return this;
+       },
+
        /**
         * Add hooks
         * Currently available: beforeAdd, beforeChange, beforeDelete,
@@ -958,18 +1059,74 @@ mw.ajaxCategories = function( options ) {
         *
         * @param string type Type of hook to add
         * @param function fn Hook function. The following vars are passed to it:
-        *                                                                      1. oldtext: The wikitext before the hook
-        *                                                                      2. category: The deleted, added, or changed category
-        *                                                                      3. (only for beforeChange/afterChange): newcategory
+        *      1. oldtext: The wikitext before the hook
+        *      2. category: The deleted, added, or changed category
+        *      3. (only for beforeChange/afterChange): newcategory
         */
-       this.addHook = function( type, fn ) {
-               if ( !_hooks[type] || !$.isFunction( fn ) ) {
+       addHook: function( type, fn ) {
+               if ( !this.hooks[type] || !$.isFunction( fn ) ) {
                        return;
                }
                else {
-                       hooks[type].push( fn );
+                       this.hooks[type].push( fn );
                }
-       };
+       },
+
+
+       /**
+        * Open a dismissable error dialog
+        *
+        * @param string str The error description
+        */
+       showError: function( str ) {
+               var     oldDialog = $( '.mw-ajax-confirm-dialog' ),
+                       buttons = {},
+                       dialogOptions = {
+                               buttons: buttons,
+                               AutoOpen: true,
+                               title: mw.msg( 'ajax-error-title' )
+                       };
+
+               this.removeProgressIndicator( oldDialog );
+               oldDialog.dialog( 'close' );
+
+               var dialog = $( '<div>' ).text( str );
+
+               mw.util.$content.append( dialog );
+
+               buttons[mw.msg( 'ajax-confirm-ok' )] = function( e ) {
+                       dialog.dialog( 'close' );
+               };
+
+               dialog.dialog( dialogOptions ).keyup( function( e ) {
+                       if ( e.keyCode === 13 ) {
+                               dialog.dialog( 'close' );
+                       }
+               } );
+       },
+
+       /**
+        * @param oldtext
+        * @param type
+        * @param category
+        * @param categoryNew
+        * @return oldtext
+        */
+       runHooks: function( oldtext, type, category, categoryNew ) {
+               // No hooks registered
+               if ( !this.hooks[type] ) {
+                       return oldtext;
+               } else {
+                       for ( var i = 0; i < this.hooks[type].length; i++ ) {
+                               oldtext = this.hooks[type][i]( oldtext, category, categoryNew );
+                               if ( oldtext === false ) {
+                                       this.showError( mw.msg( 'ajax-category-hook-error', category ) );
+                                       return;
+                               }
+                       }
+                       return oldtext;
+               }
+       }
 };
 
-} )( jQuery );
\ No newline at end of file
+} )( jQuery );
index 0a8cabf..163dbf3 100644 (file)
@@ -1,5 +1,7 @@
 ( function( $ ) {
 
+       mw.page = {};
+
        /* Client profile classes for <html> */
 
        var prof = $.client.profile();