AjaxCategories: Add basic hook functionality. Can be used by sites for stuff like...
[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
11 ( function( $, mw ) {
12
13 var ajaxCategories = function ( options ) {
14 // TODO grab these out of option object.
15
16 var catLinkWrapper = '<li/>';
17 var $container = $( '.catlinks' );
18 var $containerNormal = $( '#mw-normal-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 var _saveAllButton;
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 ( wgUserGroups.indexOf("user") != -1 && !all ) {
173 // Stash away
174 _stash.summaries.push( actionSummary );
175 _stash.fns.push( fn );
176
177 _saveAllButton.show();
178
179 // This only does visual changes
180 doneFn( true );
181 return;
182 }
183 // Produce a confirmation dialog
184 var dialog = $( '<div/>' );
185
186 dialog.addClass( 'mw-ajax-confirm-dialog' );
187 dialog.attr( 'title', mw.msg( 'ajax-confirm-title' ) );
188
189 // Intro text.
190 var confirmIntro = $( '<p/>' );
191 confirmIntro.text( mw.msg( 'ajax-confirm-prompt' ) );
192 dialog.append( confirmIntro );
193
194 // Summary of the action to be taken
195 var summaryHolder = $( '<p/>' );
196 var summaryLabel = $( '<strong/>' );
197 summaryLabel.text( mw.msg( 'ajax-confirm-actionsummary' ) + " " );
198 summaryHolder.text( actionSummary );
199 summaryHolder.prepend( summaryLabel );
200 dialog.append( summaryHolder );
201
202 // Reason textbox.
203 var reasonBox = $( '<input type="text" size="45" />' );
204 reasonBox.addClass( 'mw-ajax-confirm-reason' );
205 dialog.append( reasonBox );
206
207 // Submit button
208 var submitButton = $( '<input type="button"/>' );
209 submitButton.val( mw.msg( 'ajax-confirm-save' ) );
210
211 var submitFunction = function() {
212 _addProgressIndicator( dialog );
213 _doEdit(
214 mw.config.get( 'wgPageName' ),
215 fn,
216 reasonBox.val(),
217 function() {
218 doneFn();
219 dialog.dialog( 'close' );
220 _removeProgressIndicator( dialog );
221 }
222 );
223 };
224
225 var buttons = {};
226 buttons[mw.msg( 'ajax-confirm-save' )] = submitFunction;
227 var dialogOptions = {
228 'AutoOpen' : true,
229 'buttons' : buttons,
230 'width' : 450
231 };
232
233 $( '#catlinks' ).prepend( dialog );
234 dialog.dialog( dialogOptions );
235 };
236
237 /**
238 * When multiEdit mode is enabled,
239 * this is called when the user clicks "save all"
240 * Combines the summaries and edit functions
241 */
242 _handleStashedCategories = function() {
243 // Save fns
244 fns = _stash.fns;
245
246 // RTL?
247 var summary = _stash.summaries.join('. ');
248 var combinedFn = function( oldtext ) {
249 // Run the text through all action functions
250 newtext = oldtext;
251 for ( var i = 0; i < fns.length; i++ ) {
252 newtext = fns[i]( newtext );
253 }
254 return newtext;
255 };
256 var doneFn = function() {
257 //Remove saveAllButton
258 _saveAllButton.hide();
259
260 // Clean stash
261 _stash.fns = [];
262 _stash.summaries = [];
263
264 // TODO
265 // Any link with $link.css('text-decoration', 'line-through');
266 // needs to be removed
267 };
268 _confirmEdit( combinedFn, summary, doneFn, true );
269 };
270
271 _doEdit = function ( page, fn, summary, doneFn ) {
272 // Get an edit token for the page.
273 var getTokenVars = {
274 'action':'query',
275 'prop':'info|revisions',
276 'intoken':'edit',
277 'titles':page,
278 'rvprop':'content|timestamp',
279 'format':'json'
280 };
281
282 $.get( mw.util.wikiScript( 'api' ), getTokenVars,
283 function( reply ) {
284 var infos = reply.query.pages;
285 $.each(
286 infos,
287 function( pageid, data ) {
288 var token = data.edittoken;
289 var timestamp = data.revisions[0].timestamp;
290 var oldText = data.revisions[0]['*'];
291
292 var newText = fn( oldText );
293
294 if ( newText === false ) return;
295
296 var postEditVars = {
297 'action':'edit',
298 'title':page,
299 'text':newText,
300 'summary':summary,
301 'token':token,
302 'basetimestamp':timestamp,
303 'format':'json'
304 };
305
306 $.post( mw.util.wikiScript( 'api' ), postEditVars, doneFn, 'json' );
307 }
308 );
309 }
310 , 'json' );
311 };
312
313 /**
314 * Append spinner wheel to element
315 * @param DOMObject element.
316 */
317 _addProgressIndicator = function ( elem ) {
318 var indicator = $( '<div/>' );
319
320 indicator.addClass( 'mw-ajax-loader' );
321
322 elem.append( indicator );
323 };
324
325 /**
326 * Find and remove spinner wheel from inside element
327 * @param DOMObject parent element.
328 */
329 _removeProgressIndicator = function ( elem ) {
330 elem.find( '.mw-ajax-loader' ).remove();
331 };
332
333 /**
334 * Makes regex string caseinsensitive.
335 * Useful when 'i' flag can't be used.
336 * Return stuff like [Ff][Oo][Oo]
337 * @param string Regex string.
338 * @return string Processed regex string
339 */
340 _makeCaseInsensitive = function ( string ) {
341 var newString = '';
342 for (var i=0; i < string.length; i++) {
343 newString += '[' + string[i].toUpperCase() + string[i].toLowerCase() + ']';
344 }
345 return newString;
346 };
347 _buildRegex = function ( category ) {
348 // Build a regex that matches legal invocations of that category.
349 var categoryNSFragment = '';
350 $.each( namespaceIds, function( name, id ) {
351 if ( id == 14 ) {
352 // The parser accepts stuff like cATegORy,
353 // we need to do the same
354 categoryNSFragment += '|' + _makeCaseInsensitive ( $.escapeRE(name) );
355 }
356 } );
357 categoryNSFragment = categoryNSFragment.substr( 1 ); // Remove leading |
358
359 // Build the regex
360 var titleFragment = $.escapeRE(category);
361
362 firstChar = category.charAt( 0 );
363 firstChar = '[' + firstChar.toUpperCase() + firstChar.toLowerCase() + ']';
364 titleFragment = firstChar + category.substr( 1 );
365 var categoryRegex = '\\[\\[(' + categoryNSFragment + '):' + titleFragment + '(\\|[^\\]]*)?\\]\\]';
366
367 return new RegExp( categoryRegex, 'g' );
368 };
369
370 _handleEditLink = function ( e ) {
371 e.preventDefault();
372 var $this = $( this );
373 var $link = $this.parent().find( 'a:not(.icon)' );
374 var category = $link.text();
375
376 var $input = _makeSuggestionBox( category, _handleCategoryEdit, mw.msg( 'ajax-confirm-save' ) );
377 $link.after( $input ).hide();
378 _catElements[category].editButton.hide();
379 _catElements[category].deleteButton.unbind('click').click( function() {
380 $input.remove();
381 $link.show();
382 _catElements[category].editButton.show();
383 $( this ).unbind('click').click( _handleDeleteLink );
384 });
385 };
386
387 _handleAddLink = function ( e ) {
388 e.preventDefault();
389
390 $container.find( '#mw-normal-catlinks>.mw-addcategory-prompt' ).toggle();
391 };
392
393 _handleDeleteLink = function ( e ) {
394 e.preventDefault();
395
396 var $this = $( this );
397 var $link = $this.parent().find( 'a:not(.icon)' );
398 var category = $link.text();
399
400 var categoryRegex = _buildRegex( category );
401
402 var summary = mw.msg( 'ajax-remove-category-summary', category );
403
404 _confirmEdit(
405 function( oldText ) {
406 newText = _runHooks ( oldText, 'beforeDelete' );
407 //TODO Cleanup whitespace safely?
408 var newText = newText.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( unsaved ) {
422 if ( unsaved ) {
423 //TODO flesh out: Make it a class, make revertable
424 $link.css('text-decoration', 'line-through');
425 } else {
426 $this.parent().remove();
427 }
428 }
429 );
430 };
431
432 _handleCategoryAdd = function ( e ) {
433 // Grab category text
434 var category = $( this ).parent().find( '.mw-addcategory-input' ).val();
435 category = $.ucFirst( category );
436
437 if ( _containsCat(category) ) {
438 _showError( mw.msg( 'ajax-category-already-present' ) );
439 return;
440 }
441 var appendText = "\n[[" + categoryNamespace + ":" + category + "]]\n";
442 var summary = mw.msg( 'ajax-add-category-summary', category );
443
444 _confirmEdit(
445 function( oldText ) {
446 newText = _runHooks ( oldText, 'beforeAdd' );
447 return newText + appendText;
448 },
449 summary,
450 function() {
451 _insertCatDOM( category, false );
452 }
453 );
454 };
455
456 _handleCategoryEdit = function ( e ) {
457 e.preventDefault();
458
459 // Grab category text
460 var categoryNew = $( this ).parent().find( '.mw-addcategory-input' ).val();
461 categoryNew = $.ucFirst( categoryNew );
462
463 var $this = $( this );
464 var $link = $this.parent().parent().find( 'a:not(.icon)' );
465 var category = $link.text();
466
467 // User didn't change anything. Just close the box
468 if ( category == categoryNew ) {
469 $this.parent().remove();
470 $link.show();
471 return;
472 }
473 categoryRegex = _buildRegex( category );
474
475 var summary = mw.msg( 'ajax-edit-category-summary', category, categoryNew );
476
477 _confirmEdit(
478 function( oldText ) {
479 newText = _runHooks ( oldText, 'beforeChange' );
480
481 var matches = newText.match( categoryRegex );
482
483 //Old cat wasn't found, likely to be transcluded
484 if ( !$.isArray( matches ) ) {
485 var error = mw.msg( 'ajax-edit-category-error' );
486 _showError( error );
487 _removeProgressIndicator( $( '.mw-ajax-confirm-dialog' ) );
488 $( '.mw-ajax-confirm-dialog' ).dialog( 'close' );
489 return false;
490 }
491 var sortkey = matches[0].replace( categoryRegex, '$2' );
492 var newCategoryString = "[[" + categoryNamespace + ":" + categoryNew + sortkey + ']]';
493
494 if (matches.length > 1) {
495 // The category is duplicated.
496 // Remove all but one match
497 for (var i = 1; i < matches.length; i++) {
498 oldText = oldText.replace( matches[i], '');
499 }
500 }
501 var newText = oldText.replace( categoryRegex, newCategoryString );
502
503 return newText;
504 },
505 summary,
506 function() {
507 // Remove input box & button
508 $this.parent().remove();
509
510 // Update link text and href
511 $link.show().text( categoryNew ).attr( 'href', _catLink( categoryNew ) );
512 }
513 );
514 };
515
516 /**
517 * Open a dismissable error dialog
518 *
519 * @param string str The error description
520 */
521 _showError = function ( str ) {
522 var dialog = $( '<div/>' );
523 dialog.text( str );
524
525 $( '#bodyContent' ).append( dialog );
526
527 var buttons = { };
528 buttons[mw.msg( 'ajax-error-dismiss' )] = function( e ) {
529 dialog.dialog( 'close' );
530 };
531 var dialogOptions = {
532 'buttons' : buttons,
533 'AutoOpen' : true,
534 'title' : mw.msg( 'ajax-error-title' )
535 };
536
537 dialog.dialog( dialogOptions );
538 };
539
540 /**
541 * Manufacture iconed button, with or without text
542 *
543 * @param string icon The icon class.
544 * @param string title Title attribute.
545 * @param string className (optional) Additional classes to be added to the button.
546 * @param string text (optional) Text of button.
547 *
548 * @return jQueryObject The button
549 */
550 _createButton = function ( icon, title, className, text ){
551 var $button = $( '<a>' ).addClass( className || '' )
552 .attr('title', title);
553
554 if ( text ) {
555 var $icon = $( '<a>' ).addClass( 'icon ' + icon );
556 $button.addClass( 'icon-parent' ).append( $icon ).append( text );
557 } else {
558 $button.addClass( 'icon ' + icon );
559 }
560 return $button;
561 };
562
563 /**
564 * Append edit and remove buttons to a given category link
565 *
566 * @param DOMElement element Anchor element, to which the buttons should be appended.
567 */
568 _createCatButtons = function( element ) {
569 // Create remove & edit buttons
570 var deleteButton = _createButton('icon-close', mw.msg( 'ajax-remove-category' ) );
571 var editButton = _createButton('icon-edit', mw.msg( 'ajax-edit-category' ) );
572
573 //Not yet used
574 var saveButton = _createButton('icon-tick', mw.msg( 'ajax-confirm-save' ) ).hide();
575
576 deleteButton.click( _handleDeleteLink );
577 editButton.click( _handleEditLink );
578
579 $( element ).after( deleteButton ).after( editButton );
580
581 //Save references to all links and buttons
582 _catElements[$( element ).text()] = {
583 link : $( element ),
584 parent : $( element ).parent(),
585 saveButton : saveButton,
586 deleteButton: deleteButton,
587 editButton : editButton
588 };
589 };
590 this.setup = function () {
591 // Could be set by gadgets like HotCat etc.
592 if ( mw.config.get('disableAJAXCategories') ) {
593 return;
594 }
595 // Only do it for articles.
596 if ( !mw.config.get( 'wgIsArticle' ) ) return;
597
598 // Unhide hidden category holders.
599 $('#mw-hidden-catlinks').show();
600
601 // Create [Add Category] link
602 var addLink = _createButton('icon-add',
603 mw.msg( 'ajax-add-category' ),
604 'mw-ajax-addcategory',
605 mw.msg( 'ajax-add-category' )
606 );
607 addLink.click( _handleAddLink );
608 $containerNormal.append( addLink );
609
610 // Create add category prompt
611 var promptContainer = _makeSuggestionBox( '', _handleCategoryAdd, mw.msg( 'ajax-add-category-submit' ) );
612 promptContainer.hide();
613
614 // Create edit & delete link for each category.
615 $( '#catlinks li a' ).each( function( e ) {
616 _createCatButtons( this );
617 });
618
619 $containerNormal.append( promptContainer );
620
621 //TODO Make more clickable
622 _saveAllButton = _createButton( 'icon-tick',
623 mw.msg( 'ajax-confirm-save-all' ),
624 '',
625 mw.msg( 'ajax-confirm-save-all' )
626 );
627 _saveAllButton.click( _handleStashedCategories ).hide();
628 $containerNormal.append( _saveAllButton );
629 };
630
631 _stash = {
632 summaries : [],
633 fns : []
634 };
635 _hooks = {
636 beforeAdd : [],
637 beforeChange : [],
638 beforeDelete : []
639 };
640 _runHooks = function( oldtext, type ) {
641 // No hooks registered
642 if ( !_hooks[type] ) {
643 return oldtext;
644 } else {
645 for (var i = 0; i < _hooks[type].length; i++) {
646 oldtext = _hooks[type][i]( oldtext );
647 }
648 return oldtext;
649 }
650 };
651 /**
652 * Add hooks
653 * Currently available: beforeAdd, beforeChange, beforeDelete
654 *
655 * @param string type Type of hook to add
656 * @param function fn Hook function. This function is the old text passed
657 * and it needs to return the modified text
658 */
659 this.addHook = function( type, fn ) {
660 if ( !_hooks[type] ) return;
661 else hooks[type].push( fn );
662 };
663 };
664 // Now make a new version
665 mw.ajaxCategories = new ajaxCategories();
666
667 // Executing only on doc.ready, so that everyone
668 // gets a chance to set mw.config.set('disableAJAXCategories')
669 $( document ).ready( mw.ajaxCategories.setup() );
670
671 } )( jQuery, mediaWiki );