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