r56032 MediaWiki - Code Review archive

Repository:MediaWiki
Revision:r56031‎ | r56032 | r56033 >
Date:15:02, 8 September 2009
Author:werdna
Status:deferred (Comments)
Tags:todo 
Comment:
Add AJAX category management system. Includes suggestion system, dialogs for setting edit summaries and confirming, and methods for adding and removing all categories, but only directly in the page text (as categories in templates cannot be removed).
Modified paths:
  • /trunk/phase3/includes/AutoLoader.php (modified) (history)
  • /trunk/phase3/includes/DefaultSettings.php (modified) (history)
  • /trunk/phase3/includes/OutputPage.php (modified) (history)
  • /trunk/phase3/includes/Skin.php (modified) (history)
  • /trunk/phase3/includes/Xml.php (modified) (history)
  • /trunk/phase3/js2/ajaxcategories.js (added) (history)
  • /trunk/phase3/js2/mwEmbed/jquery/plugins/jquery.suggestions.js (added) (history)
  • /trunk/phase3/js2/mwEmbed/mv_embed.js (modified) (history)
  • /trunk/phase3/languages/messages/MessagesEn.php (modified) (history)
  • /trunk/phase3/skins/common/images/add.png (added) (history)
  • /trunk/phase3/skins/common/images/remove.png (added) (history)
  • /trunk/phase3/skins/common/shared.css (modified) (history)

Diff [purge]

Index: trunk/phase3/skins/common/images/remove.png
Cannot display: file marked as a binary type.
svn:mime-type = application/octet-stream
Property changes on: trunk/phase3/skins/common/images/remove.png
___________________________________________________________________
Added: svn:executable
11 + *
Added: svn:mime-type
22 + application/octet-stream
Index: trunk/phase3/skins/common/images/add.png
Cannot display: file marked as a binary type.
svn:mime-type = application/octet-stream
Property changes on: trunk/phase3/skins/common/images/add.png
___________________________________________________________________
Added: svn:executable
33 + *
Added: svn:mime-type
44 + application/octet-stream
Index: trunk/phase3/skins/common/shared.css
@@ -772,6 +772,30 @@
773773 font-family:monospace
774774 }
775775
 776+#mw-addcategory-prompt {
 777+ display: inline;
 778+ margin-left: 1em;
 779+}
 780+
 781+#mw-addcategory-prompt input {
 782+ margin-left: 0.5em;
 783+ margin-right: 0.5em;
 784+}
 785+
 786+.mw-remove-category {
 787+ padding: 8px;
 788+ background-image: url(images/remove.png);
 789+ background-position: center center;
 790+ background-repeat: no-repeat;
 791+}
 792+
 793+.mw-ajax-addcategory {
 794+ padding-left: 20px;
 795+ background-image: url(images/add.png);
 796+ background-position: left center;
 797+ background-repeat: no-repeat;
 798+}
 799+
776800 .mw-ajax-loader {
777801 background-image: url(images/ajax-loader.gif);
778802 background-position: center center;
Index: trunk/phase3/includes/Xml.php
@@ -567,7 +567,9 @@
568568 $s = 'null';
569569 } elseif ( is_int( $value ) ) {
570570 $s = $value;
571 - } elseif ( is_array( $value ) ) {
 571+ } elseif ( is_array( $value ) && // Make sure it's not associative.
 572+ array_keys($value) === range(0,count($value)-1)
 573+ ) {
572574 $s = '[';
573575 foreach ( $value as $elt ) {
574576 if ( $s != '[' ) {
@@ -576,7 +578,8 @@
577579 $s .= self::encodeJsVar( $elt );
578580 }
579581 $s .= ']';
580 - } elseif ( is_object( $value ) ) {
 582+ } elseif ( is_object( $value ) || is_array( $value ) ) {
 583+ // Objects and associative arrays
581584 $s = '{';
582585 foreach ( (array)$value as $name => $elt ) {
583586 if ( $s != '{' ) {
Index: trunk/phase3/includes/OutputPage.php
@@ -1115,6 +1115,11 @@
11161116 if( $wgUser->getBoolOption( 'editsectiononrightclick' ) ) {
11171117 $this->addScriptFile( 'rightclickedit.js' );
11181118 }
 1119+
 1120+ global $wgUseAJAXCategories;
 1121+ if ($wgUseAJAXCategories) {
 1122+ $this->addScriptClass( 'ajaxCategories' );
 1123+ }
11191124
11201125 if( $wgUniversalEditButton ) {
11211126 if( isset( $wgArticle ) && $this->getTitle() && $this->getTitle()->quickUserCan( 'edit' )
Index: trunk/phase3/includes/AutoLoader.php
@@ -619,6 +619,7 @@
620620 // phase 2 javascript:
621621 'uploadPage' => 'js2/uploadPage.js',
622622 'editPage' => 'js2/editPage.js',
 623+ 'ajaxCategories' => 'js2/ajaxcategories.js',
623624 );
624625
625626 //Include the js2 autoLoadClasses
Index: trunk/phase3/includes/DefaultSettings.php
@@ -4195,3 +4195,8 @@
41964196 * The minimum amount of memory that MediaWiki "needs"; MediaWiki will try to raise PHP's memory limit if it's below this amount.
41974197 */
41984198 $wgMemoryLimit = "50M";
 4199+
 4200+/**
 4201+ * Whether or not to use the AJAX categories system.
 4202+ */
 4203+$wgUseAJAXCategories = false;
Index: trunk/phase3/includes/Skin.php
@@ -409,6 +409,8 @@
410410 'wgSeparatorTransformTable' => $compactSeparatorTransTable,
411411 'wgDigitTransformTable' => $compactDigitTransTable,
412412 'wgMainPageTitle' => $mainPage ? $mainPage->getPrefixedText() : null,
 413+ 'wgFormattedNamespaces' => $wgContLang->getFormattedNamespaces(),
 414+ 'wgNamespaceIds' => $wgContLang->getNamespaceIds(),
413415 );
414416 if ( $wgContLang->hasVariants() ) {
415417 $vars['wgUserVariant'] = $wgContLang->getPreferredVariant();
Index: trunk/phase3/js2/mwEmbed/jquery/plugins/jquery.suggestions.js
@@ -0,0 +1,459 @@
 2+/**
 3+ * This plugin provides a generic way to add suggestions to a text box
 4+ * Usage:
 5+ *
 6+ * Set options
 7+ * $('#textbox').suggestions({ option1: value1, option2: value2 });
 8+ * $('#textbox').suggestions( option, value );
 9+ * Get option:
 10+ * value = $('#textbox').suggestions( option );
 11+ * Initialize:
 12+ * $('#textbox').suggestions();
 13+ *
 14+ * Available options:
 15+ * animationDuration: How long (in ms) the animated growing of the results box
 16+ * should take (default: 200)
 17+ * cancelPending(): Function called when any pending asynchronous suggestions
 18+ * fetches should be canceled (optional). Executed in the context of the
 19+ * textbox
 20+ * delay: Number of ms to wait for the user to stop typing (default: 120)
 21+ * fetch(query): Callback that should fetch suggestions and set the suggestions
 22+ * property (required). Executed in the context of the textbox
 23+ * maxGrowFactor: Maximum width of the suggestions box as a factor of the width
 24+ * of the textbox (default: 2)
 25+ * maxRows: Maximum number of suggestion rows to show
 26+ * submitOnClick: If true, submit the form when a suggestion is clicked
 27+ * (default: false)
 28+ * suggestions: Array of suggestions to display (default: [])
 29+ *
 30+ */
 31+(function($) {
 32+$.fn.suggestions = function( param, param2 ) {
 33+ /**
 34+ * Handle special keypresses (arrow keys and escape)
 35+ * @param key Key code
 36+ */
 37+ function processKey( key ) {
 38+ switch ( key ) {
 39+ case 40:
 40+ // Arrow down
 41+ if ( conf._data.div.is( ':visible' ) ) {
 42+ highlightResult( 'next', true );
 43+ } else {
 44+ // Load suggestions right now
 45+ updateSuggestions( false );
 46+ }
 47+ break;
 48+ case 38:
 49+ // Arrow up
 50+ if ( conf._data.div.is( ':visible' ) ) {
 51+ highlightResult( 'prev', true );
 52+ }
 53+ break;
 54+ case 27:
 55+ // Escape
 56+ conf._data.div.hide();
 57+ restoreText();
 58+ cancelPendingSuggestions();
 59+ break;
 60+ default:
 61+ updateSuggestions( true );
 62+ }
 63+ }
 64+
 65+ /**
 66+ * Restore the text the user originally typed in the textbox,
 67+ * before it was overwritten by highlightResult(). This restores the
 68+ * value the currently displayed suggestions are based on, rather than
 69+ * the value just before highlightResult() overwrote it; the former
 70+ * is arguably slightly more sensible.
 71+ */
 72+ function restoreText() {
 73+ conf._data.textbox.val( conf._data.prevText );
 74+ }
 75+
 76+ /**
 77+ * Ask the user-specified callback for new suggestions. Any previous
 78+ * delayed call to this function still pending will be canceled.
 79+ * If the value in the textbox hasn't changed since the last time
 80+ * suggestions were fetched, this function does nothing.
 81+ * @param delayed If true, delay this by the user-specified delay
 82+ */
 83+ function updateSuggestions( delayed ) {
 84+ // Cancel previous call
 85+ if ( conf._data.timerID != null )
 86+ clearTimeout( conf._data.timerID );
 87+ if ( delayed )
 88+ setTimeout( doUpdateSuggestions, conf.delay );
 89+ else
 90+ doUpdateSuggestions();
 91+ }
 92+
 93+ /**
 94+ * Delayed part of updateSuggestions()
 95+ * Don't call this, use updateSuggestions( false ) instead
 96+ */
 97+ function doUpdateSuggestions() {
 98+ if ( conf._data.textbox.val() == conf._data.prevText )
 99+ // Value in textbox didn't change
 100+ return;
 101+
 102+ conf._data.prevText = conf._data.textbox.val();
 103+ conf.fetch.call ( conf._data.textbox,
 104+ conf._data.textbox.val() );
 105+ }
 106+
 107+ /**
 108+ * Called when the user changes the suggestions post-init.
 109+ * Typically happens asynchronously from conf.fetch()
 110+ */
 111+ function suggestionsChanged() {
 112+ conf._data.div.show();
 113+ updateSuggestionsTable();
 114+ fitContainer();
 115+ trimResultText();
 116+ }
 117+
 118+ /**
 119+ * Cancel any delayed updateSuggestions() call and inform the user so
 120+ * they can cancel their result fetching if they use AJAX or something
 121+ */
 122+ function cancelPendingSuggestions() {
 123+ if ( conf._data.timerID != null )
 124+ clearTimeout( conf._data.timerID );
 125+ conf.cancelPending.call( this );
 126+ }
 127+
 128+ /**
 129+ * Rebuild the suggestions table
 130+ */
 131+ function updateSuggestionsTable() {
 132+ // If there are no suggestions, hide the div
 133+ if ( conf.suggestions.length == 0 ) {
 134+ conf._data.div.hide();
 135+ return;
 136+ }
 137+
 138+ var table = conf._data.div.children( 'table' );
 139+ table.empty();
 140+ for ( var i = 0; i < conf.suggestions.length; i++ ) {
 141+ var td = $( '<td />' ) // FIXME: why use a span?
 142+ .append( $( '<span />' ).text( conf.suggestions[i] ) );
 143+ //.addClass( 'os-suggest-result' ); //FIXME: use descendant selector
 144+ $( '<tr />' )
 145+ .addClass( 'os-suggest-result' ) // FIXME: use descendant selector
 146+ .attr( 'rel', i )
 147+ .data( 'text', conf.suggestions[i] )
 148+ .append( td )
 149+ .appendTo( table );
 150+ }
 151+ }
 152+
 153+ /**
 154+ * Make the container fit into the screen
 155+ */
 156+ function fitContainer() {
 157+ if ( conf._data.div.is( ':hidden' ) )
 158+ return;
 159+
 160+ // FIXME: Mysterious -20 from mwsuggest.js,
 161+ // presumably to make room for a scrollbar
 162+ var availableHeight = $( 'body' ).height() - (
 163+ Math.round( conf._data.div.offset().top ) -
 164+ $( document ).scrollTop() ) - 20;
 165+ var rowHeight = conf._data.div.find( 'tr' ).outerHeight();
 166+ var numRows = Math.floor( availableHeight / rowHeight );
 167+
 168+ // Show at least 2 rows if there are multiple results
 169+ if ( numRows < 2 && conf.suggestions.length >= 2 )
 170+ numRows = 2;
 171+ if ( numRows > conf.maxRows )
 172+ numRows = conf.maxRows;
 173+
 174+ var tableHeight = conf._data.div.find( 'table' ).outerHeight();
 175+ if ( numRows * rowHeight < tableHeight ) {
 176+ // The container is too small
 177+ conf._data.div.height( numRows * rowHeight );
 178+ conf._data.visibleResults = numRows;
 179+ } else {
 180+ // The container is possibly too large
 181+ conf._data.div.height( tableHeight );
 182+ conf._data.visibleResults = conf.suggestions.length;
 183+ }
 184+ }
 185+
 186+ /**
 187+ * If there are results wider than the container, try to grow the
 188+ * container or trim them to end with "..."
 189+ */
 190+ function trimResultText() {
 191+ if ( conf._data.div.is( ':hidden' ) )
 192+ return;
 193+
 194+ // Try to grow the container so all results fit
 195+ // Can't use each() here because the inner function can read
 196+ // but not write maxWidth for some crazy reason
 197+ var maxWidth = 0;
 198+ var spans = conf._data.div.find( 'span' ).get();
 199+ for ( var i = 0; i < spans.length; i++ )
 200+ if ( $(spans[i]).outerWidth() > maxWidth )
 201+ maxWidth = $(spans[i]).outerWidth();
 202+
 203+ // FIXME: Some mysterious fixing going on here
 204+ // FIXME: Left out Opera fix for now
 205+ // FIXME: This doesn't check that the container won't run off the screen
 206+ // FIXME: This should try growing to the left instead if no space on the right
 207+ var fix = 0;
 208+ if ( conf._data.visibleResults < conf.suggestions.length )
 209+ fix = 20;
 210+ //else
 211+ // fix = operaWidthFix();
 212+ if ( fix < 4 )
 213+ // FIXME: Make 4px configurable?
 214+ fix = 4; // Always pad at least 4px
 215+ maxWidth += fix;
 216+
 217+ var textBoxWidth = conf._data.textbox.outerWidth();
 218+ var factor = maxWidth / textBoxWidth;
 219+ if ( factor > conf.maxGrowFactor )
 220+ factor = conf.maxGrowFactor;
 221+ if ( factor < 1 )
 222+ // Don't shrink the container to be smaller
 223+ // than the textbox
 224+ factor = 1;
 225+ var newWidth = Math.round( textBoxWidth * factor );
 226+ if ( newWidth != conf._data.div.outerWidth() )
 227+ conf._data.div.animate( { width: newWidth },
 228+ conf.animationDuration );
 229+ // FIXME: mwsuggest.js has this inside the if != block
 230+ // but I don't think that's right
 231+ newWidth -= fix;
 232+
 233+ // If necessary, trim and add ...
 234+ conf._data.div.find( 'tr' ).each( function() {
 235+ var span = $(this).find( 'span' );
 236+ if ( span.outerWidth() > newWidth ) {
 237+ var span = $(this).find( 'span' );
 238+ span.text( span.text() + '...' );
 239+
 240+ // While it's still too wide and the last
 241+ // iteration shrunk it, remove the character
 242+ // before '...'
 243+ while ( span.outerWidth() > newWidth && span.text().length > 3 ) {
 244+ span.text( span.text().substring( 0,
 245+ span.text().length - 4 ) + '...' );
 246+ }
 247+ $(this).attr( 'title', $(this).data( 'text' ) );
 248+ }
 249+ });
 250+ }
 251+
 252+ /**
 253+ * Get a jQuery object for the currently highlighted row
 254+ */
 255+ function getHighlightedRow() {
 256+ return conf._data.div.find( '.os-suggest-result-hl' );
 257+ }
 258+
 259+ /**
 260+ * Highlight a result in the results table
 261+ * @param result <tr> to highlight: jQuery object, or 'prev' or 'next'
 262+ * @param updateTextbox If true, put the suggestion in the textbox
 263+ */
 264+ function highlightResult( result, updateTextbox ) {
 265+ // TODO: Use our own class here
 266+ var selected = getHighlightedRow();
 267+ if ( !result.get || selected.get( 0 ) != result.get( 0 ) ) {
 268+ if ( result == 'prev' ) {
 269+ result = selected.prev();
 270+ } else if ( result == 'next' ) {
 271+ if ( selected.size() == 0 )
 272+ // No item selected, go to the first one
 273+ result = conf._data.div.find( 'tr:first' );
 274+ else {
 275+ result = selected.next();
 276+ if ( result.size() == 0 )
 277+ // We were at the last item, stay there
 278+ result = selected;
 279+ }
 280+ }
 281+
 282+ selected.removeClass( 'os-suggest-result-hl' );
 283+ result.addClass( 'os-suggest-result-hl' );
 284+ }
 285+
 286+ if ( updateTextbox ) {
 287+ if ( result.size() == 0 )
 288+ restoreText();
 289+ else
 290+ conf._data.textbox.val( result.data( 'text' ) );
 291+ }
 292+
 293+ if ( result.size() > 0 && conf._data.visibleResults < conf.suggestions.length ) {
 294+ // Not all suggestions are visible
 295+ // Scroll if needed
 296+
 297+ // height of a result row
 298+ var rowHeight = result.outerHeight();
 299+ // index of first visible element
 300+ var first = conf._data.div.scrollTop() / rowHeight;
 301+ // index of last visible element
 302+ var last = first + conf._data.visibleResults - 1;
 303+ // index of element to scroll to
 304+ var to = result.attr( 'rel' );
 305+
 306+ if ( to < first )
 307+ // Need to scroll up
 308+ conf._data.div.scrollTop( to * rowHeight );
 309+ else if ( result.attr( 'rel' ) > last )
 310+ // Need to scroll down
 311+ conf._data.div.scrollTop( ( to - conf._data.visibleResults + 1 ) * rowHeight );
 312+ }
 313+ }
 314+
 315+ /**
 316+ * Initialize the widget
 317+ */
 318+ function init() {
 319+ if ( typeof conf != 'object' || typeof conf._data != 'undefined' )
 320+ // Configuration not set or init already done
 321+ return;
 322+
 323+ // Set defaults
 324+ if ( typeof conf.animationDuration == 'undefined' )
 325+ conf.animationDuration = 200;
 326+ if ( typeof conf.cancelPending != 'function' )
 327+ conf.cancelPending = function() {};
 328+ if ( typeof conf.delay == 'undefined' )
 329+ conf.delay = 250;
 330+ if ( typeof conf.maxGrowFactor == 'undefined' )
 331+ conf.maxGrowFactor = 2;
 332+ if ( typeof conf.maxRows == 'undefined' )
 333+ conf.maxRows = 7;
 334+ if ( typeof conf.submitOnClick == 'undefined' )
 335+ conf.submitOnClick = false;
 336+ if ( typeof conf.suggestions != 'object' )
 337+ conf.suggestions = [];
 338+
 339+ conf._data = {};
 340+ conf._data.textbox = $(this);
 341+ conf._data.timerID = null; // ID of running timer
 342+ conf._data.prevText = null; // Text in textbox when suggestions were last fetched
 343+ conf._data.visibleResults = 0; // Number of results visible without scrolling
 344+ conf._data.mouseDownOn = $( [] ); // Suggestion the last mousedown event occured on
 345+
 346+ // Create container div for suggestions
 347+ conf._data.div = $( '<div />' )
 348+ .addClass( 'os-suggest' ) //TODO: use own CSS
 349+ .css( {
 350+ top: Math.round( $(this).offset().top ) + this.offsetHeight,
 351+ left: Math.round( $(this).offset().left ),
 352+ width: $(this).outerWidth()
 353+ })
 354+ .hide()
 355+ .appendTo( $( 'body' ) );
 356+
 357+ // Create results table
 358+ $( '<table />' )
 359+ .addClass( 'os-suggest-results' ) // TODO: use descendant selector
 360+ .width( $(this).outerWidth() ) // TODO: see if we need Opera width fix
 361+ .appendTo( conf._data.div );
 362+
 363+ $(this)
 364+ // Stop browser autocomplete from interfering
 365+ .attr( 'autocomplete', 'off')
 366+ .keydown( function( e ) {
 367+ // Store key pressed to handle later
 368+ conf._data.keypressed = (e.keyCode == undefined) ? e.which : e.keyCode;
 369+ conf._data.keypressed_count = 0;
 370+ })
 371+ .keypress( function() {
 372+ conf._data.keypressed_count++;
 373+ processKey( conf._data.keypressed );
 374+ })
 375+ .keyup( function() {
 376+ // Some browsers won't throw keypress() for
 377+ // arrow keys. If we got a keydown and a keyup
 378+ // without a keypress in between, solve that
 379+ if (conf._data.keypressed_count == 0 )
 380+ processKey( conf._data.keypressed );
 381+ })
 382+ .blur( function() {
 383+ // When losing focus because of a mousedown
 384+ // on a suggestion, don't hide the suggestions
 385+ if ( conf._data.mouseDownOn.size() > 0 )
 386+ return;
 387+ conf._data.div.hide();
 388+ cancelPendingSuggestions();
 389+ });
 390+
 391+ conf._data.div
 392+ .mouseover( function( e ) {
 393+ var tr = $( e.target ).closest( '.os-suggest tr' );
 394+ highlightResult( tr, false );
 395+ })
 396+ // Can't use click() because the container div is hidden
 397+ // when the textbox loses focus. Instead, listen for a
 398+ // mousedown followed by a mouseup on the same <tr>
 399+ .mousedown( function( e ) {
 400+ var tr = $( e.target ).closest( '.os-suggest tr' );
 401+ conf._data.mouseDownOn = tr;
 402+ })
 403+ .mouseup( function( e ) {
 404+ var tr = $( e.target ).closest( '.os-suggest tr' );
 405+ var other = conf._data.mouseDownOn;
 406+ conf._data.mouseDownOn = $( [] );
 407+ if ( tr.get( 0 ) != other.get( 0 ) )
 408+ return;
 409+
 410+ highlightResult( tr, true );
 411+ conf._data.div.hide();
 412+ conf._data.textbox.focus();
 413+ if ( conf.submitOnClick )
 414+ conf._data.textbox.closest( 'form' )
 415+ .submit();
 416+ });
 417+ }
 418+
 419+ function getProperty( prop ) {
 420+ return ( param[0] == '_' ? undefined : conf[param] );
 421+ }
 422+
 423+ function setProperty( prop, value ) {
 424+ if ( typeof conf == 'undefined' ) {
 425+ $(this).data( 'suggestionsConfiguration', {} );
 426+ conf = $(this).data( 'suggestionsConfiguration' );
 427+ }
 428+ if ( prop[0] != '_' )
 429+ conf[prop] = value;
 430+ if ( prop == 'suggestions' && conf._data )
 431+ // Setting suggestions post-init
 432+ suggestionsChanged();
 433+ }
 434+
 435+
 436+ // Body of suggestions() starts here
 437+ var conf = $(this).data( 'suggestionsConfiguration' );
 438+ if ( typeof param == 'object' )
 439+ return this.each( function() {
 440+ // Bulk-set properties
 441+ for ( key in param ) {
 442+ // Make sure that this in setProperty()
 443+ // is set right
 444+ setProperty.call( this, key, param[key] );
 445+ }
 446+ });
 447+ else if ( typeof param == 'string' ) {
 448+ if ( typeof param2 != 'undefined' )
 449+ return this.each( function() {
 450+ setProperty( param, param2 );
 451+ });
 452+ else
 453+ return getProperty( param );
 454+ } else if ( typeof param != 'undefined' )
 455+ // Incorrect usage, ignore
 456+ return this;
 457+
 458+ // No parameters given, initialize
 459+ return this.each( init );
 460+};})(jQuery);
Index: trunk/phase3/js2/mwEmbed/mv_embed.js
@@ -191,6 +191,7 @@
192192 "$j.secureEvalJSON" : "jquery/plugins/jquery.secureEvalJSON.js",
193193 "$j.cookie" : "jquery/plugins/jquery.cookie.js",
194194 "$j.contextMenu" : "jquery/plugins/jquery.contextMenu.js",
 195+ "$j.fn.suggestions" : "jquery/plugins/jquery.suggestions.js",
195196
196197 "$j.effects.blind" : "jquery/jquery.ui/ui/effects.blind.js",
197198 "$j.effects.drop" : "jquery/jquery.ui/ui/effects.drop.js",
Index: trunk/phase3/js2/ajaxcategories.js
@@ -0,0 +1,315 @@
 2+loadGM( {
 3+ "ajax-add-category":"[Add Category]",
 4+ "ajax-add-category-submit":"[Add]",
 5+ "ajax-confirm-prompt":"[Confirmation Text]",
 6+ "ajax-confirm-title":"[Confirmation Title]",
 7+ "ajax-confirm-save":"[Save]",
 8+ "ajax-add-category-summary":"[Add category $1]",
 9+ "ajax-remove-category-summary":"[Remove category $2]",
 10+ "ajax-confirm-actionsummary":"[Summary]",
 11+ "ajax-error-title":"Error",
 12+ "ajax-error-dismiss":"OK",
 13+ "ajax-remove-category-error":"[RemoveErr]"
 14+ } );
 15+
 16+var ajaxCategories = {
 17+
 18+ handleAddLink : function(e) {
 19+ e.preventDefault();
 20+
 21+ // Make sure the suggestion plugin is loaded. Load everything else while we're at it
 22+ mvJsLoader.doLoad( ['$j.ui', '$j.ui.dialog', '$j.fn.suggestions'],
 23+ function() {
 24+ $j('#mw-addcategory-prompt').toggle();
 25+
 26+ $j('#mw-addcategory-input').suggestions( {
 27+ 'fetch':ajaxCategories.fetchSuggestions,
 28+ 'cancel': function() {
 29+ var req = ajaxCategories.request;
 30+ if (req.abort)
 31+ req.abort()
 32+ },
 33+ } );
 34+
 35+ $j('#mw-addcategory-input').suggestions();
 36+ } );
 37+ },
 38+
 39+ fetchSuggestions : function( query ) {
 40+ var that = this;
 41+ var request = $j.ajax( {
 42+ url: wgScriptPath + '/api.php',
 43+ data: {
 44+ 'action': 'query',
 45+ 'list': 'allpages',
 46+ 'apnamespace': 14,
 47+ 'apprefix': $j(this).val(),
 48+ 'format': 'json'
 49+ },
 50+ dataType: 'json',
 51+ success: function( data ) {
 52+ // Process data.query.allpages into an array of titles
 53+ var pages = data.query.allpages;
 54+ var titleArr = [];
 55+
 56+ $j.each(pages, function(i, page) {
 57+ var title = page.title.split( ':', 2 )[1];
 58+ titleArr.push(title);
 59+ } );
 60+
 61+ $j(that).suggestions( 'suggestions', titleArr );
 62+ }
 63+ });
 64+
 65+ ajaxCategories.request = request;
 66+ },
 67+
 68+ reloadCategoryList : function( response ) {
 69+ var holder = $j('<div/>');
 70+
 71+ holder.load( window.location.href+' .catlinks', function() {
 72+ $j('.catlinks').replaceWith( holder.find('.catlinks') );
 73+ ajaxCategories.setupAJAXCategories();
 74+ ajaxCategories.removeProgressIndicator( $j('.catlinks') );
 75+ });
 76+ },
 77+
 78+ confirmEdit : function( page, fn, actionSummary, doneFn ) {
 79+ // Load jQuery UI
 80+ mvJsLoader.doLoad( ['$j.ui', '$j.ui.dialog', '$j.suggestions'], function() {
 81+ // Produce a confirmation dialog
 82+
 83+ var dialog = $j('<div/>');
 84+
 85+ dialog.addClass('mw-ajax-confirm-dialog');
 86+ dialog.attr( 'title', gM('ajax-confirm-title') );
 87+
 88+ // Intro text.
 89+ var confirmIntro = $j('<p/>');
 90+ confirmIntro.text( gM('ajax-confirm-prompt') );
 91+ dialog.append(confirmIntro);
 92+
 93+ // Summary of the action to be taken
 94+ var summaryHolder = $j('<p/>');
 95+ var summaryLabel = $j('<strong/>');
 96+ summaryLabel.text(gM('ajax-confirm-actionsummary')+" " );
 97+ summaryHolder.text( actionSummary );
 98+ summaryHolder.prepend( summaryLabel );
 99+ dialog.append(summaryHolder);
 100+
 101+ // Reason textbox.
 102+ var reasonBox = $j('<input type="text" size="45" />');
 103+ reasonBox.addClass('mw-ajax-confirm-reason');
 104+ dialog.append(reasonBox);
 105+
 106+ // Submit button
 107+ var submitButton = $j('<input type="button"/>');
 108+ submitButton.val( gM( 'ajax-confirm-save' ) );
 109+
 110+ var submitFunction = function() {
 111+ ajaxCategories.addProgressIndicator( dialog );
 112+ ajaxCategories.doEdit( page, fn, reasonBox.val(),
 113+ function() {
 114+ doneFn();
 115+ dialog.dialog('close');
 116+ ajaxCategories.removeProgressIndicator( dialog );
 117+ }
 118+ );
 119+ };
 120+
 121+ var buttons = {};
 122+ buttons[gM('ajax-confirm-save')] = submitFunction;
 123+ var dialogOptions = {
 124+ 'AutoOpen' : true,
 125+ 'buttons' : buttons,
 126+ 'width' : 450,
 127+ };
 128+
 129+ $j('#catlinks').prepend(dialog);
 130+ dialog.dialog( dialogOptions );
 131+ } );
 132+ },
 133+
 134+ doEdit : function( page, fn, summary, doneFn ) {
 135+ // Get an edit token for the page.
 136+ var getTokenVars = {
 137+ 'action':'query',
 138+ 'prop':'info|revisions',
 139+ 'intoken':'edit',
 140+ 'titles':page,
 141+ 'rvprop':'content|timestamp',
 142+ 'format':'json',
 143+ };
 144+ $j.get(wgScriptPath+'/api.php', getTokenVars,
 145+ function( reply ) {
 146+ var infos = reply.query.pages;
 147+ $j.each(infos, function(pageid, data) {
 148+ var token = data.edittoken;
 149+ var timestamp = data.revisions[0].timestamp;
 150+ var oldText = data.revisions[0]['*'];
 151+
 152+ var newText = fn(oldText);
 153+
 154+ if (newText === false) return;
 155+
 156+ var postEditVars = {
 157+ 'action':'edit',
 158+ 'title':page,
 159+ 'text':newText,
 160+ 'summary':summary,
 161+ 'token':token,
 162+ 'basetimestamp':timestamp,
 163+ 'format':'json',
 164+ };
 165+
 166+ $j.post( wgScriptPath+'/api.php', postEditVars, doneFn, 'json' );
 167+ } );
 168+ }
 169+ , 'json' );
 170+ },
 171+
 172+ addProgressIndicator : function( elem ) {
 173+ var indicator = $j('<div/>');
 174+
 175+ indicator.addClass('mw-ajax-loader');
 176+
 177+ elem.append( indicator );
 178+ },
 179+
 180+ removeProgressIndicator : function( elem ) {
 181+ elem.find('.mw-ajax-loader').remove();
 182+ },
 183+
 184+ handleCategoryAdd : function(e) {
 185+ // Grab category text
 186+ var category = $j('#mw-addcategory-input').val();
 187+ var appendText = "\n[["+wgFormattedNamespaces[14]+":"+category+"]]\n";
 188+ var summary = gM('ajax-add-category-summary', category);
 189+
 190+ ajaxCategories.confirmEdit( wgPageName, function(oldText) { return oldText+appendText },
 191+ summary, ajaxCategories.reloadCategoryList );
 192+ },
 193+
 194+ handleDeleteLink : function(e) {
 195+ e.preventDefault();
 196+
 197+ var category = $j(this).parent().find('a').text();
 198+
 199+ // Build a regex that matches legal invocations of that category.
 200+
 201+ // In theory I should escape the aliases, but there's no JS function for it
 202+ // Shouldn't have any real impact, can't be exploited or anything, so we'll
 203+ // leave it for now.
 204+ var categoryNSFragment = '';
 205+ $j.each(wgNamespaceIds, function( name, id ) {
 206+ if (id == 14) {
 207+ // Allow the first character to be any case
 208+ var firstChar = name.charAt(0);
 209+ firstChar = '['+firstChar.toUpperCase()+firstChar.toLowerCase()+']'
 210+ categoryNSFragment += '|'+firstChar+name.substr(1);
 211+ }
 212+ } );
 213+ categoryNSFragment = categoryNSFragment.substr(1) // Remove leading |
 214+
 215+
 216+ // Build the regex
 217+ var titleFragment = category;
 218+
 219+ firstChar = category.charAt(0);
 220+ firstChar = '['+firstChar.toUpperCase()+firstChar.toLowerCase()+']';
 221+ titleFragment = firstChar+category.substr(1);
 222+ var categoryRegex = '\\[\\['+categoryNSFragment+':'+titleFragment+'(\\|[^\\]]*)?\\]\\]';
 223+ categoryRegex = new RegExp( categoryRegex, 'g' );
 224+
 225+ var summary = gM('ajax-remove-category-summary', category);
 226+
 227+ ajaxCategories.confirmEdit( wgPageName,
 228+ function(oldText) {
 229+ var newText = oldText.replace(categoryRegex, '');
 230+
 231+ if (newText == oldText) {
 232+ var error = gM('ajax-remove-category-error');
 233+ ajaxCategories.showError( error );
 234+ ajaxCategories.removeProgressIndicator( $j('.mw-ajax-confirm-dialog') );
 235+ $j('.mw-ajax-confirm-dialog').dialog('close');
 236+ return false;
 237+ }
 238+
 239+ return newText;
 240+ }, summary, ajaxCategories.reloadCategoryList );
 241+ },
 242+
 243+ showError : function( str ) {
 244+ var dialog = $j('<div/>');
 245+ dialog.text(str);
 246+
 247+ $j('#bodyContent').append(dialog);
 248+
 249+ var buttons = {};
 250+ buttons[gM('ajax-error-dismiss')] = function(e) { dialog.dialog('close'); };
 251+ var dialogOptions = {
 252+ 'buttons' : buttons,
 253+ 'AutoOpen' : true,
 254+ 'title' : gM('ajax-error-title'),
 255+ };
 256+
 257+ dialog.dialog(dialogOptions);
 258+ },
 259+
 260+ setupAJAXCategories : function() {
 261+ var clElement = $j('.catlinks');
 262+
 263+ // Unhide hidden category holders.
 264+ clElement.removeClass( 'catlinks-allhidden' );
 265+
 266+ var addLink = $j('<a/>');
 267+ addLink.addClass( 'mw-ajax-addcategory' );
 268+
 269+ // Create [Add Category] link
 270+ addLink.text( gM( 'ajax-add-category' ) );
 271+ addLink.attr('href', '#');
 272+ addLink.click( ajaxCategories.handleAddLink );
 273+ clElement.append(addLink);
 274+
 275+ // Create add category prompt
 276+ var promptContainer = $j('<div id="mw-addcategory-prompt"/>');
 277+ var promptTextbox = $j('<input type="text" size="45" id="mw-addcategory-input"/>');
 278+ var addButton = $j('<input type="button" id="mw-addcategory-button"/>' );
 279+ addButton.val( gM('ajax-add-category-submit') );
 280+
 281+ promptTextbox.keypress( ajaxCategories.handleCategoryInput );
 282+ addButton.click( ajaxCategories.handleCategoryAdd );
 283+
 284+ promptContainer.append(promptTextbox);
 285+ promptContainer.append(addButton);
 286+ promptContainer.hide();
 287+
 288+ // Create delete link for each category.
 289+ $j('.catlinks div span a').each( function(e) {
 290+ // Create a remove link
 291+ var deleteLink = $j('<a class="mw-remove-category" href="#"/>');
 292+
 293+ deleteLink.click(ajaxCategories.handleDeleteLink);
 294+
 295+ $j(this).after(deleteLink);
 296+ } );
 297+
 298+ clElement.append(promptContainer);
 299+ },
 300+
 301+};
 302+
 303+js2AddOnloadHook( ajaxCategories.setupAJAXCategories );
 304+loadGM( {
 305+ "ajax-add-category":"[Add Category]",
 306+ "ajax-add-category-submit":"[Add]",
 307+ "ajax-confirm-prompt":"[Confirmation Text]",
 308+ "ajax-confirm-title":"[Confirmation Title]",
 309+ "ajax-confirm-save":"[Save]",
 310+ "ajax-add-category-summary":"[Add category $1]",
 311+ "ajax-remove-category-summary":"[Remove category $2]",
 312+ "ajax-confirm-actionsummary":"[Summary]",
 313+ "ajax-error-title":"Error",
 314+ "ajax-error-dismiss":"OK",
 315+ "ajax-remove-category-error":"[RemoveErr]"
 316+ } );
Index: trunk/phase3/languages/messages/MessagesEn.php
@@ -4194,4 +4194,18 @@
41954195 'htmlform-reset' => 'Undo changes',
41964196 'htmlform-selectorother-other' => 'Other',
41974197
 4198+'ajax-add-category' => 'Add Category',
 4199+'ajax-add-category-submit' => 'Add',
 4200+'ajax-confirm-title' => 'Confirm Action',
 4201+'ajax-confirm-prompt' => 'Please confirm this action, and enter the reason for it in the
 4202+box below. Once you are happy to submit it, click "Save". Note that repeatedly making false
 4203+edits will result in your being blocked from Wikipedia.',
 4204+'ajax-confirm-save' => 'Save',
 4205+'ajax-add-category-summary' => 'Add category "$1"',
 4206+'ajax-remove-category-summary' => 'Remove category "$1"',
 4207+'ajax-confirm-actionsummary' => 'Action to take:',
 4208+'ajax-error-title' => 'Error',
 4209+'ajax-error-dismiss' => 'OK',
 4210+'ajax-remove-category-error' => 'It was not possible to remove this category. This usually
 4211+occurs when the category has been added to the page in a template.',
41984212 );

Follow-up revisions

RevisionCommit summaryAuthorDate
r56038Follow-up r56032: Tweak messages for more consistency and better i18n....raymond15:28, 8 September 2009
r56041Follow-up r56032/r56038: This is a message in a JS popup. Parsing of wikisynt...raymond15:47, 8 September 2009
r56046Revert r56041 "Follow-up r56032/r56038: This is a message in a JS popup. Pars...brion17:38, 8 September 2009
r56049Fix fix r56041 of fix r56038 of fix r56032 -- it turns out that {{int:X}} doe...werdna17:45, 8 September 2009
r56100Add doc comment noting that JS2 and script loader are required for ajax categ...werdna16:32, 9 September 2009
r56496In Xml::encodeJsVar, keep backwards compatibility by encoding empty arrays as...werdna09:31, 17 September 2009
r56501Add $wgUseAJAXCategories and $wgAJAXCategoriesNamespaces to RELEASE-NOTES. Re...siebrand10:05, 17 September 2009
r68291Follow-up r59446 (removal of JS2 work): remove messages that were added in r5...siebrand22:22, 19 June 2010
r68292* remove mention of $wgUseAJAXCategories feature from RELEASE-NOTES...siebrand22:28, 19 June 2010
r72376(Bug 20697) - Decrease size (area) of remove.png and add.png for aesthetic re...platonides13:55, 4 September 2010

Comments

#Comment by Bryan (talk | contribs)   15:17, 8 September 2009

Hardcodes the .php extension.

#Comment by Catrope (talk | contribs)   17:34, 8 September 2009

Aye, this should probably use wgScriptExtension (is that even exported to JS?) for compat with api.php5 environments.

#Comment by Werdna (talk | contribs)   17:35, 8 September 2009

No, it isn't exported to JS, which is why I didn't bother using it.

#Comment by Raymond (talk | contribs)   15:50, 8 September 2009

The edit summary should contain the added/removed category name too. Something like: "Category:Foo added. Reason"

#Comment by Brion VIBBER (talk | contribs)   18:44, 8 September 2009

Moving from fixme to todo as this won't go live immediately.

#Comment by Siebrand (talk | contribs)   21:25, 8 September 2009
  • Add category is also shown at special pages
  • Should be enablable per namespace (don't want this in MediaWiki namespace, for example, possibly also not on talk pages).

Pretty annoying as it is right now. I think this really is a fixme.

#Comment by Werdna (talk | contribs)   21:31, 8 September 2009

The first has been resolved as far as I can tell. The second is a feature request.

FIXME is for problems that block deployment on Wikimedia. This is clearly not in that category.

#Comment by Siebrand (talk | contribs)   08:46, 17 September 2009

Feature added in r56495. Default behaviour was left unchanged. Leaving 'todo' label, as there may be leftovers in other areas.

#Comment by Mormegil (talk | contribs)   16:06, 9 September 2009

Since this uses loadGM, which is defined only in mwEmbed (or UsabilityExtensions), the code needs to ensure mwEmbed is actually included… The current MW trunk throws JS exceptions all the time, since “loadGM is not defined”.

#Comment by Werdna (talk | contribs)   16:08, 9 September 2009

Yes, the AJAX category system requires JS2 and the script loader. The exceptions will only be thrown if the AJAX category system is turned on without these enabled. A documentation comment in DefaultSettings.php explains this.

#Comment by Mormegil (talk | contribs)   16:27, 9 September 2009

Where exactly…? I see only this:

/**
 * Whether or not to use the AJAX categories system.
 */
$wgUseAJAXCategories = false;
#Comment by Werdna (talk | contribs)   16:32, 9 September 2009

Whoops, I was thinking of live preview. Appropriate comment added in r56100.

#Comment by Umherirrender (talk | contribs)   17:16, 27 September 2009

Two Semicolon are missing (line 6 and 10):

		var categoryNSFragment = '';
		$j.each(wgNamespaceIds, function( name, id ) {
			if (id == 14) {
				// Allow the first character to be any case
				var firstChar = name.charAt(0);
				firstChar = '['+firstChar.toUpperCase()+firstChar.toLowerCase()+']'
				categoryNSFragment += '|'+firstChar+name.substr(1);
			}
		} );
		categoryNSFragment = categoryNSFragment.substr(1) // Remove leading |

(breaks IE7 in translatewiki.net)

#Comment by Siebrand (talk | contribs)   18:29, 27 September 2009
#Comment by Umherirrender (talk | contribs)   17:09, 28 September 2009

Thanks. But trailing comma after } without following definition breaks IE, too.

line 321

		} );
 		clElement.append(promptContainer);
 	},
 };
 
 js2AddOnloadHook( ajaxCategories.setupAJAXCategories );

and line 31

					'fetch':ajaxCategories.fetchSuggestions,
					'cancel': function() {
						var req = ajaxCategories.request;
						if (req.abort)
							req.abort()
					},
				} );

Thanks.

#Comment by Siebrand (talk | contribs)   17:30, 28 September 2009

Well, that can be fixed too :)

I made some more updates in the same area: rev:57018.

Status & tagging log