AjaxCategories:
[lhc/web/wiklou.git] / resources / mediawiki.page / mediawiki.page.ajaxCategories.js
1 // TODO
2 //
3 // * The edit summary should contain the added/removed category name too.
4 // Something like: "Category:Foo added. Reason"
5 // Requirement: Be able to get msg with lang option.
6 // * Handle uneditable cats. Needs serverside changes!
7 // * Add Hooks for change, delete, add
8 // * Add Hooks for soft redirect
9 // * Handle normal redirects
10 // * Simple / MultiEditMode
11
12 ( function( $, mw ) {
13
14 var ajaxCategories = function ( options ) {
15 // TODO grab these out of option object.
16
17 var catLinkWrapper = '<li/>';
18 var $container = $( '.catlinks' );
19
20 var categoryLinkSelector = '#mw-normal-catlinks li a';
21 var _request;
22
23 var _catElements = {};
24
25 var namespaceIds = mw.config.get( 'wgNamespaceIds' )
26 var categoryNamespaceId = namespaceIds['category'];
27 var categoryNamespace = mw.config.get( 'wgFormattedNamespaces' )[categoryNamespaceId];
28
29
30 /**
31 * Helper function for $.fn.suggestion
32 *
33 * @param string Query string.
34 */
35 _fetchSuggestions = function ( query ) {
36 var _this = this;
37 // ignore bad characters, they will be stripped out
38 var catName = _stripIllegals( $( this ).val() );
39 var request = $.ajax( {
40 url: mw.util.wikiScript( 'api' ),
41 data: {
42 'action': 'query',
43 'list': 'allpages',
44 'apnamespace': categoryNamespaceId,
45 'apprefix': catName,
46 'format': 'json'
47 },
48 dataType: 'json',
49 success: function( data ) {
50 // Process data.query.allpages into an array of titles
51 var pages = data.query.allpages;
52 var titleArr = [];
53
54 $.each( pages, function( i, page ) {
55 var title = page.title.split( ':', 2 )[1];
56 titleArr.push( title );
57 } );
58
59 $( _this ).suggestions( 'suggestions', titleArr );
60 }
61 } );
62 //TODO
63 _request = request;
64 };
65
66 _stripIllegals = function ( cat ) {
67 return cat.replace( /[\x00-\x1f\x3c\x3e\x5b\x5d\x7b\x7c\x7d\x7f]+/g, '' );
68 };
69
70 /**
71 * Insert a newly added category into the DOM
72 *
73 * @param string category name.
74 * @param boolean isHidden (unused)
75 */
76 _insertCatDOM = function ( cat, isHidden ) {
77 // User can implicitely state a sort key.
78 // Remove before display
79 cat = cat.replace(/\|.*/, '');
80
81 // strip out bad characters
82 cat = _stripIllegals ( cat );
83
84 if ( $.isEmpty( cat ) || _containsCat( cat ) ) {
85 return;
86 }
87
88 var $catLinkWrapper = $( catLinkWrapper );
89 var $anchor = $( '<a/>' ).append( cat );
90 $catLinkWrapper.append( $anchor );
91 $anchor.attr( { target: "_blank", href: _catLink( cat ) } );
92 if ( isHidden ) {
93 $container.find( '#mw-hidden-catlinks ul' ).append( $catLinkWrapper );
94 } else {
95 $container.find( '#mw-normal-catlinks ul' ).append( $catLinkWrapper );
96 }
97 _createCatButtons( $anchor.get(0) );
98 };
99
100 _makeSuggestionBox = function ( prefill, callback, buttonVal ) {
101 // Create add category prompt
102 var promptContainer = $( '<div class="mw-addcategory-prompt"/>' );
103 var promptTextbox = $( '<input type="text" size="45" class="mw-addcategory-input"/>' );
104 if ( prefill !== '' ) {
105 promptTextbox.val( prefill );
106 }
107 var addButton = $( '<input type="button" class="mw-addcategory-button"/>' );
108 addButton.val( buttonVal );
109
110 addButton.click( callback );
111
112 promptTextbox.suggestions( {
113 'fetch':_fetchSuggestions,
114 'cancel': function() {
115 var req = _request;
116 // XMLHttpRequest.abort is unimplemented in IE6, also returns nonstandard value of "unknown" for typeof
117 if ( req && ( typeof req.abort !== 'unknown' ) && ( typeof req.abort !== 'undefined' ) && req.abort ) {
118 req.abort();
119 }
120 }
121 } );
122
123 promptTextbox.suggestions();
124
125 promptContainer.append( promptTextbox );
126 promptContainer.append( addButton );
127
128 return promptContainer;
129 };
130
131 /**
132 * Build URL for passed Category
133 *
134 * @param string category name.
135 * @return string Valid URL
136 */
137 _catLink = function ( cat ) {
138 return mw.util.wikiGetlink( categoryNamespace + ':' + $.ucFirst( cat ) );
139 };
140
141 /**
142 * Parse the DOM $container and build a list of
143 * present categories
144 *
145 * @return array Array of all categories
146 */
147 _getCats = function () {
148 return $container.find( categoryLinkSelector ).map( function() { return $.trim( $( this ).text() ); } );
149 };
150
151 /**
152 * Check whether a passed category is present in the DOM
153 *
154 * @return boolean True for exists
155 */
156 _containsCat = function ( cat ) {
157 return _getCats().filter( function() { return $.ucFirst(this) == $.ucFirst(cat); } ).length !== 0;
158 };
159
160 /**
161 * This get's called by all action buttons
162 * Displays a dialog to confirm the action
163 * Afterwords do the actual edit
164 *
165 * @param function fn text-modifying function
166 * @param string actionSummary Changes done
167 * @param function fn doneFn callback after everything is done
168 * @return boolean True for exists
169 */
170 _confirmEdit = function ( fn, actionSummary, doneFn, all ) {
171 // Check whether to use multiEdit mode
172 if ( mw.config.get('AJAXCategoriesMulti') && !all ) {
173 // Stash away
174 _stash.summaries.push( actionSummary );
175 _stash.fns.push( fn );
176 _stash.doneFns.push( doneFn );
177
178 // Make sure we have a save button
179 if ( !_saveAllButton ) {
180 //TODO Make more clickable
181 _saveAllButton = _createButton( 'icon-tick',
182 mw.msg( 'ajax-confirm-save-all' ),
183 '',
184 mw.msg( 'ajax-confirm-save-all' )
185 );
186 _saveAllButton.click( _handleStashedCategories );
187 }
188
189 return;
190 }
191 // Produce a confirmation dialog
192 var dialog = $( '<div/>' );
193
194 dialog.addClass( 'mw-ajax-confirm-dialog' );
195 dialog.attr( 'title', mw.msg( 'ajax-confirm-title' ) );
196
197 // Intro text.
198 var confirmIntro = $( '<p/>' );
199 confirmIntro.text( mw.msg( 'ajax-confirm-prompt' ) );
200 dialog.append( confirmIntro );
201
202 // Summary of the action to be taken
203 var summaryHolder = $( '<p/>' );
204 var summaryLabel = $( '<strong/>' );
205 summaryLabel.text( mw.msg( 'ajax-confirm-actionsummary' ) + " " );
206 summaryHolder.text( actionSummary );
207 summaryHolder.prepend( summaryLabel );
208 dialog.append( summaryHolder );
209
210 // Reason textbox.
211 var reasonBox = $( '<input type="text" size="45" />' );
212 reasonBox.addClass( 'mw-ajax-confirm-reason' );
213 dialog.append( reasonBox );
214
215 // Submit button
216 var submitButton = $( '<input type="button"/>' );
217 submitButton.val( mw.msg( 'ajax-confirm-save' ) );
218
219 var submitFunction = function() {
220 _addProgressIndicator( dialog );
221 _doEdit(
222 mw.config.get( 'wgPageName' ),
223 fn,
224 reasonBox.val(),
225 function() {
226 doneFn();
227 dialog.dialog( 'close' );
228 _removeProgressIndicator( dialog );
229 }
230 );
231 };
232
233 var buttons = {};
234 buttons[mw.msg( 'ajax-confirm-save' )] = submitFunction;
235 var dialogOptions = {
236 'AutoOpen' : true,
237 'buttons' : buttons,
238 'width' : 450
239 };
240
241 $( '#catlinks' ).prepend( dialog );
242 dialog.dialog( dialogOptions );
243 };
244
245 /**
246 * When multiEdit mode is enabled,
247 * this is called when the user clicks "save all"
248 * Combines the summaries and edit functions
249 */
250 _handleStashedCategories = function() {
251 // Save fns
252 fns = _stash.fns;
253
254 //TODO do I need a space?
255 var summary = _stash.summaries.join(' ');
256 var combinedFn = function( oldtext ) {
257 // Run the text through all action functions
258 newtext = oldtext;
259 for ( var i = 0; i < fns.length; i++ ) {
260 newtext = fns[i]( newtext );
261 };
262 return newtext;
263 }
264 var doneFn = function() {
265 //Remove saveAllButton
266 _saveAllButton.remove();
267 _saveAllButton = undefined;
268 };
269
270 };
271
272 _doEdit = function ( page, fn, summary, doneFn ) {
273 // Get an edit token for the page.
274 var getTokenVars = {
275 'action':'query',
276 'prop':'info|revisions',
277 'intoken':'edit',
278 'titles':page,
279 'rvprop':'content|timestamp',
280 'format':'json'
281 };
282
283 $.get( mw.util.wikiScript( 'api' ), getTokenVars,
284 function( reply ) {
285 var infos = reply.query.pages;
286 $.each(
287 infos,
288 function( pageid, data ) {
289 var token = data.edittoken;
290 var timestamp = data.revisions[0].timestamp;
291 var oldText = data.revisions[0]['*'];
292
293 var newText = fn( oldText );
294
295 if ( newText === false ) return;
296
297 var postEditVars = {
298 'action':'edit',
299 'title':page,
300 'text':newText,
301 'summary':summary,
302 'token':token,
303 'basetimestamp':timestamp,
304 'format':'json'
305 };
306
307 $.post( mw.util.wikiScript( 'api' ), postEditVars, doneFn, 'json' );
308 }
309 );
310 }
311 , 'json' );
312 };
313
314 /**
315 * Append spinner wheel to element
316 * @param DOMObject element.
317 */
318 _addProgressIndicator = function ( elem ) {
319 var indicator = $( '<div/>' );
320
321 indicator.addClass( 'mw-ajax-loader' );
322
323 elem.append( indicator );
324 };
325
326 /**
327 * Find and remove spinner wheel from inside element
328 * @param DOMObject parent element.
329 */
330 _removeProgressIndicator = function ( elem ) {
331 elem.find( '.mw-ajax-loader' ).remove();
332 };
333
334 /**
335 * Makes regex string caseinsensitive.
336 * Useful when 'i' flag can't be used.
337 * Return stuff like [Ff][Oo][Oo]
338 * @param string Regex string.
339 * @return string Processed regex string
340 */
341 _makeCaseInsensitive = function ( string ) {
342 var newString = '';
343 for (var i=0; i < string.length; i++) {
344 newString += '[' + string[i].toUpperCase() + string[i].toLowerCase() + ']';
345 }
346 return newString;
347 };
348 _buildRegex = function ( category ) {
349 // Build a regex that matches legal invocations of that category.
350 var categoryNSFragment = '';
351 $.each( namespaceIds, function( name, id ) {
352 if ( id == 14 ) {
353 // The parser accepts stuff like cATegORy,
354 // we need to do the same
355 categoryNSFragment += '|' + _makeCaseInsensitive ( $.escapeRE(name) );
356 }
357 } );
358 categoryNSFragment = categoryNSFragment.substr( 1 ); // Remove leading |
359
360 // Build the regex
361 var titleFragment = $.escapeRE(category);
362
363 firstChar = category.charAt( 0 );
364 firstChar = '[' + firstChar.toUpperCase() + firstChar.toLowerCase() + ']';
365 titleFragment = firstChar + category.substr( 1 );
366 var categoryRegex = '\\[\\[(' + categoryNSFragment + '):' + titleFragment + '(\\|[^\\]]*)?\\]\\]';
367
368 return new RegExp( categoryRegex, 'g' );
369 };
370
371 _handleEditLink = function ( e ) {
372 e.preventDefault();
373 var $this = $( this );
374 var $link = $this.parent().find( 'a:not(.icon)' );
375 var category = $link.text();
376
377 var $input = _makeSuggestionBox( category, _handleCategoryEdit, mw.msg( 'ajax-confirm-save' ) );
378 $link.after( $input ).hide();
379 _catElements[category].editButton.hide();
380 _catElements[category].deleteButton.unbind('click').click( function() {
381 $input.remove();
382 $link.show();
383 _catElements[category].editButton.show();
384 $( this ).unbind('click').click( _handleDeleteLink );
385 });
386 };
387
388 _handleAddLink = function ( e ) {
389 e.preventDefault();
390
391 $container.find( '#mw-normal-catlinks>.mw-addcategory-prompt' ).toggle();
392 };
393
394 _handleDeleteLink = function ( e ) {
395 e.preventDefault();
396
397 var $this = $( this );
398 var $link = $this.parent().find( 'a:not(.icon)' );
399 var category = $link.text();
400
401 categoryRegex = _buildRegex( category );
402
403 var summary = mw.msg( 'ajax-remove-category-summary', category );
404
405 _confirmEdit(
406 function( oldText ) {
407 //TODO Cleanup whitespace safely?
408 var newText = oldText.replace( categoryRegex, '' );
409
410 if ( newText == oldText ) {
411 var error = mw.msg( 'ajax-remove-category-error' );
412 _showError( error );
413 _removeProgressIndicator( $( '.mw-ajax-confirm-dialog' ) );
414 $( '.mw-ajax-confirm-dialog' ).dialog( 'close' );
415 return false;
416 }
417
418 return newText;
419 },
420 summary,
421 function() {
422 $this.parent().remove();
423 }
424 );
425 };
426
427 _handleCategoryAdd = function ( e ) {
428 // Grab category text
429 var category = $( this ).parent().find( '.mw-addcategory-input' ).val();
430 category = $.ucFirst( category );
431
432 if ( _containsCat(category) ) {
433 _showError( mw.msg( 'ajax-category-already-present' ) );
434 return;
435 }
436 var appendText = "\n[[" + categoryNamespace + ":" + category + "]]\n";
437 var summary = mw.msg( 'ajax-add-category-summary', category );
438
439 _confirmEdit(
440 function( oldText ) { return oldText + appendText },
441 summary,
442 function() {
443 _insertCatDOM( category, false );
444 }
445 );
446 };
447
448 _handleCategoryEdit = function ( e ) {
449 e.preventDefault();
450
451 // Grab category text
452 var categoryNew = $( this ).parent().find( '.mw-addcategory-input' ).val();
453 categoryNew = $.ucFirst( categoryNew );
454
455 var $this = $( this );
456 var $link = $this.parent().parent().find( 'a:not(.icon)' );
457 var category = $link.text();
458
459 // User didn't change anything. Just close the box
460 if ( category == categoryNew ) {
461 $this.parent().remove();
462 $link.show();
463 return;
464 }
465 categoryRegex = _buildRegex( category );
466
467 var summary = mw.msg( 'ajax-edit-category-summary', category, categoryNew );
468
469 _confirmEdit(
470 function( oldText ) {
471 var matches = oldText.match( categoryRegex );
472
473 //Old cat wasn't found, likely to be transcluded
474 if ( !$.isArray( matches ) ) {
475 var error = mw.msg( 'ajax-edit-category-error' );
476 _showError( error );
477 _removeProgressIndicator( $( '.mw-ajax-confirm-dialog' ) );
478 $( '.mw-ajax-confirm-dialog' ).dialog( 'close' );
479 return false;
480 }
481 var sortkey = matches[0].replace( categoryRegex, '$2' );
482 var newCategoryString = "[[" + categoryNamespace + ":" + categoryNew + sortkey + ']]';
483
484 if (matches.length > 1) {
485 // The category is duplicated.
486 // Remove all but one match
487 for (var i = 1; i < matches.length; i++) {
488 oldText = oldText.replace( matches[i], '');
489 }
490 }
491 var newText = oldText.replace( categoryRegex, newCategoryString );
492
493 return newText;
494 },
495 summary,
496 function() {
497 // Remove input box & button
498 $this.parent().remove();
499
500 // Update link text and href
501 $link.show().text( categoryNew ).attr( 'href', _catLink( categoryNew ) );
502 }
503 );
504 };
505
506 /**
507 * Open a dismissable error dialog
508 *
509 * @param string str The error description
510 */
511 _showError = function ( str ) {
512 var dialog = $( '<div/>' );
513 dialog.text( str );
514
515 $( '#bodyContent' ).append( dialog );
516
517 var buttons = { };
518 buttons[mw.msg( 'ajax-error-dismiss' )] = function( e ) {
519 dialog.dialog( 'close' );
520 };
521 var dialogOptions = {
522 'buttons' : buttons,
523 'AutoOpen' : true,
524 'title' : mw.msg( 'ajax-error-title' )
525 };
526
527 dialog.dialog( dialogOptions );
528 };
529
530 /**
531 * Manufacture iconed button, with or without text
532 *
533 * @param string icon The icon class.
534 * @param string title Title attribute.
535 * @param string className (optional) Additional classes to be added to the button.
536 * @param string text (optional) Text of button.
537 *
538 * @return jQueryObject The button
539 */
540 _createButton = function ( icon, title, className, text ){
541 var $button = $( '<a>' ).addClass( className || '' )
542 .attr('title', title);
543
544 if ( text ) {
545 var $icon = $( '<a>' ).addClass( 'icon ' + icon );
546 $button.addClass( 'icon-parent' ).append( $icon ).append( text );
547 } else {
548 $button.addClass( 'icon ' + icon );
549 }
550 return $button;
551 };
552
553 /**
554 * Append edit and remove buttons to a given category link
555 *
556 * @param DOMElement element Anchor element, to which the buttons should be appended.
557 */
558 _createCatButtons = function( element ) {
559 // Create remove & edit buttons
560 var deleteButton = _createButton('icon-close', mw.msg( 'ajax-remove-category' ) );
561 var editButton = _createButton('icon-edit', mw.msg( 'ajax-edit-category' ) );
562
563 //Not yet used
564 var saveButton = _createButton('icon-tick', mw.msg( 'ajax-confirm-save' ) ).hide();
565
566 deleteButton.click( _handleDeleteLink );
567 editButton.click( _handleEditLink );
568
569 $( element ).after( deleteButton ).after( editButton );
570
571 //Save references to all links and buttons
572 _catElements[$( element ).text()] = {
573 link : $( element ),
574 parent : $( element ).parent(),
575 saveButton : saveButton,
576 deleteButton: deleteButton,
577 editButton : editButton
578 };
579 };
580 this.setup = function () {
581 // Could be set by gadgets like HotCat etc.
582 if ( mw.config.get('disableAJAXCategories') ) {
583 return;
584 }
585 // Only do it for articles.
586 if ( !mw.config.get( 'wgIsArticle' ) ) return;
587
588 var clElement = $( '#mw-normal-catlinks' );
589
590 // Unhide hidden category holders.
591 $('#mw-hidden-catlinks').show();
592
593 // Create [Add Category] link
594 var addLink = _createButton('icon-add',
595 mw.msg( 'ajax-add-category' ),
596 'mw-ajax-addcategory',
597 mw.msg( 'ajax-add-category' )
598 );
599 addLink.click( _handleAddLink );
600 clElement.append( addLink );
601
602 // Create add category prompt
603 var promptContainer = _makeSuggestionBox( '', _handleCategoryAdd, mw.msg( 'ajax-add-category-submit' ) );
604 promptContainer.hide();
605
606 // Create edit & delete link for each category.
607 $( '#catlinks li a' ).each( function( e ) {
608 _createCatButtons( this );
609 });
610
611 clElement.append( promptContainer );
612 };
613
614 _stash = {
615 summaries : [],
616 fns : [],
617 doneFns : [],
618 };
619 };
620 // Now make a new version
621 mw.ajaxCategories = new ajaxCategories();
622
623 // Executing only on doc.ready, so that everyone
624 // gets a chance to set mw.config.set('disableAJAXCategories')
625 $( document ).ready( mw.ajaxCategories.setup() );
626
627 } )( jQuery, mediaWiki );