2b3b021a06c2ae2caac97a0182b3f9172935e568
[lhc/web/wiklou.git] / resources / mediawiki.page / mediawiki.page.ajaxCategories.js
1 /**
2 * mediaWiki.page.ajaxCategories
3 *
4 * @author Michael Dale, 2009
5 * @author Leo Koppelkamm, 2011
6 * @author Timo Tijhof, 2011
7 * @since 1.19
8 *
9 * Relies on: mw.config (wgFormattedNamespaces, wgNamespaceIds,
10 * wgCaseSensitiveNamespaces, wgUserGroups), mw.util.wikiGetlink, mw.user.getId
11 */
12 ( function( $ ) {
13
14 /* Local scope */
15
16 var catNsId = mw.config.get( 'wgNamespaceIds' ).category,
17 defaultOptions = {
18 catLinkWrapper: '<li>',
19 $container: $( '.catlinks' ),
20 $containerNormal: $( '#mw-normal-catlinks' ),
21 categoryLinkSelector: 'li a:not(.icon)',
22 multiEdit: $.inArray( 'user', mw.config.get( 'wgUserGroups' ) ) !== -1,
23 resolveRedirects: true
24 },
25 isCatNsSensitive = $.inArray( 14, mw.config.get( 'wgCaseSensitiveNamespaces' ) ) !== -1;
26
27 /**
28 * @return {String}
29 */
30 function clean( s ) {
31 if ( typeof s === 'string' ) {
32 return s.replace( /[\x00-\x1f\x23\x3c\x3e\x5b\x5d\x7b\x7c\x7d\x7f\s]+/g, '' );
33 }
34 return '';
35 }
36
37 /**
38 * Helper function for $.fn.suggestions
39 *
40 * @context {jQuery}
41 * @param value {String} Textbox value.
42 */
43 function fetchSuggestions( value ) {
44 var request,
45 $el = this,
46 catName = clean( value );
47
48 request = $.ajax( {
49 url: mw.util.wikiScript( 'api' ),
50 data: {
51 action: 'query',
52 list: 'allpages',
53 apnamespace: catNsId,
54 apprefix: catName,
55 format: 'json'
56 },
57 dataType: 'json',
58 success: function( data ) {
59 // Process data.query.allpages into an array of titles
60 var pages = data.query.allpages,
61 titleArr = $.map( pages, function( page ) {
62 return new mw.Title( page.title ).getMainText();
63 } );
64
65 $el.suggestions( 'suggestions', titleArr );
66 }
67 } );
68 $el.data( 'suggestions-request', request );
69 }
70
71 /**
72 * Replace <nowiki> and comments with unique keys in the page text.
73 *
74 * @param text {String}
75 * @param id {String} Unique key for this nowiki replacement layer call.
76 * @param keys {Array} Array where fragments will be stored in.
77 * @return {String}
78 */
79 function replaceNowikis( text, id, keys ) {
80 var matches = text.match( /(<nowiki\>[\s\S]*?<\/nowiki>|<\!--[\s\S]*?--\>)/g );
81 for ( var i = 0; matches && i < matches.length; i++ ) {
82 keys[i] = matches[i];
83 text = text.replace( matches[i], '' + id + '-' + i );
84 }
85 return text;
86 }
87
88 /**
89 * Restore <nowiki> and comments from unique keys in the page text.
90 *
91 * @param text {String}
92 * @param id {String} Unique key of the layer to be restored, as passed to replaceNowikis().
93 * @param keys {Array} Array where fragements should be fetched from.
94 * @return {String}
95 */
96 function restoreNowikis( text, id, keys ) {
97 for ( var i = 0; i < keys.length; i++ ) {
98 text = text.replace( '' + id + '-' + i, keys[i] );
99 }
100 return text;
101 }
102
103 /**
104 * Makes regex string caseinsensitive.
105 * Useful when 'i' flag can't be used.
106 * Return stuff like [Ff][Oo][Oo]
107 *
108 * @param string {String} Regex string
109 * @return {String} Processed regex string
110 */
111 function makeCaseInsensitive( string ) {
112 var newString = '';
113 for ( var i = 0; i < string.length; i++ ) {
114 newString += '[' + string.charAt( i ).toUpperCase() + string.charAt( i ).toLowerCase() + ']';
115 }
116 return newString;
117 }
118
119 /**
120 * Build a regex that matches legal invocations of the passed category.
121 * @param category {String}
122 * @param matchLineBreak {Boolean} Match one following linebreak as well?
123 * @return {RegExp}
124 */
125 function buildRegex( category, matchLineBreak ) {
126 var categoryRegex, categoryNSFragment,
127 titleFragment = $.escapeRE( category ).replace( /( |_)/g, '[ _]' ),
128 firstChar = titleFragment.charAt( 0 );
129
130 // Filter out all names for category namespace
131 categoryNSFragment = $.map( mw.config.get( 'wgNamespaceIds' ), function( id, name ) {
132 if ( id === catNsId ) {
133 name = $.escapeRE( name );
134 return !isCatNsSensitive ? makeCaseInsensitive( name ) : name;
135 }
136 // Otherwise don't include in categoryNSFragment
137 return null;
138 } ).join( '|' );
139
140 firstChar = '[' + firstChar.toUpperCase() + firstChar.toLowerCase() + ']';
141 titleFragment = firstChar + titleFragment.substr( 1 );
142 categoryRegex = '\\[\\[(' + categoryNSFragment + ')' + '[ _]*' + ':' + '[ _]*' + titleFragment + '[ _]*' + '(\\|[^\\]]*)?\\]\\]';
143 if ( matchLineBreak ) {
144 categoryRegex += '[ \\t\\r]*\\n?';
145 }
146 return new RegExp( categoryRegex, 'g' );
147 }
148
149 /**
150 * Manufacture iconed button, with or without text.
151 *
152 * @param icon {String} The icon class.
153 * @param title {String} Title attribute.
154 * @param className {String} (optional) Additional classes to be added to the button.
155 * @param text {String} (optional) Text label of button.
156 * @return {jQuery} The button.
157 */
158 function createButton( icon, title, className, text ){
159 // We're adding a zero width space for IE7, it's got problems with empty nodes apparently
160 var $button = $( '<a>' )
161 .addClass( className || '' )
162 .attr( 'title', title )
163 .html( '&#8203;' );
164
165 if ( text ) {
166 var $icon = $( '<span>' ).addClass( 'icon ' + icon ).html( '&#8203;' );
167 $button.addClass( 'icon-parent' ).append( $icon ).append( mw.html.escape( text ) );
168 } else {
169 $button.addClass( 'icon ' + icon );
170 }
171 return $button;
172 }
173
174 /**
175 * @constructor
176 * @param
177 */
178 mw.ajaxCategories = function( options ) {
179
180 this.options = options = $.extend( defaultOptions, options );
181
182 // Save scope in shortcut
183 var ajaxcat = this;
184
185 // Elements tied to this instance
186 this.saveAllButton = null;
187 this.cancelAllButton = null;
188 this.addContainer = null;
189
190 this.request = null;
191
192 // Stash and hooks
193 this.stash = {
194 dialogDescriptions: [],
195 editSummaries: [],
196 fns: []
197 };
198 this.hooks = {
199 beforeAdd: [],
200 beforeChange: [],
201 beforeDelete: [],
202 afterAdd: [],
203 afterChange: [],
204 afterDelete: []
205 };
206
207 /* Event handlers */
208
209 /**
210 * Handle add category submit. Not to be called directly.
211 *
212 * @context Element
213 * @param e {jQuery Event}
214 */
215 this.handleAddLink = function( e ) {
216 var $el = $( this ),
217 $link = $([]),
218 categoryText = $.ucFirst( $el.parent().find( '.mw-addcategory-input' ).val() || '' );
219
220 // Resolve redirects
221 ajaxcat.resolveRedirects( categoryText, function( resolvedCatTitle ) {
222 ajaxcat.handleCategoryAdd( $link, resolvedCatTitle, '', false );
223 } );
224 };
225
226 /**
227 * @context Element
228 * @param e {jQuery Event}
229 */
230 this.createEditInterface = function( e ) {
231 var $el = $( this ),
232 $link = $el.data( 'link' ),
233 category = $link.text(),
234 $input = ajaxcat.makeSuggestionBox( category,
235 ajaxcat.handleEditLink,
236 ajaxcat.options.multiEdit ? mw.msg( 'ajax-confirm-ok' ) : mw.msg( 'ajax-confirm-save' )
237 );
238
239 $link.after( $input ).hide();
240
241 $input.find( '.mw-addcategory-input' ).focus();
242
243 // Get the editButton associated with this category link,
244 // and hide it.
245 $link.data( 'editButton' ).hide();
246
247 // Get the deleteButton associated with this category link,
248 $link.data( 'deleteButton' )
249 // (re)set click handler
250 .unbind( 'click' )
251 .click( function() {
252 // When the delete button is clicked:
253 // - Remove the suggestion box
254 // - Show the link and it's edit button
255 // - (re)set the click handler again
256 $input.remove();
257 $link.show().data( 'editButton' ).show();
258 $( this )
259 .unbind( 'click' )
260 .click( ajaxcat.handleDeleteLink )
261 .attr( 'title', mw.msg( 'ajax-remove-category' ) );
262 })
263 .attr( 'title', mw.msg( 'ajax-cancel' ) );
264 };
265
266 /**
267 * Handle edit category submit. Not to be called directly.
268 *
269 * @context Element
270 * @param e {jQuery Event}
271 */
272 this.handleEditLink = function( e ) {
273 var input, category, categoryOld,
274 sortkey = '', // Wikitext for between '[[Category:Foo' and ']]'.
275 $el = $( this ),
276 $link = $el.parent().parent().find( 'a:not(.icon)' );
277
278 // Grab category text
279 input = $el.parent().find( '.mw-addcategory-input' ).val();
280
281 // Strip sortkey
282 var arr = input.split( '|', 2 );
283 if ( arr.length > 1 ) {
284 category = arr[0];
285 sortkey = arr[1];
286 }
287
288 // Grab text
289 var isAdded = $link.hasClass( 'mw-added-category' );
290 ajaxcat.resetCatLink( $link );
291 categoryOld = $link.text();
292
293 // If something changed and the new cat is already on the page, delete it.
294 if ( categoryOld !== category && ajaxcat.containsCat( category ) ) {
295 $link.data( 'deleteButton' ).click();
296 return;
297 }
298
299 // Resolve redirects
300 ajaxcat.resolveRedirects( category, function( resolvedCatTitle ) {
301 ajaxcat.handleCategoryEdit( $link, categoryOld, resolvedCatTitle, sortkey, isAdded );
302 });
303 };
304
305 /**
306 * Handle delete category submit. Not to be called directly.
307 *
308 * @context Element
309 * @param e {jQuery Event}
310 */
311 this.handleDeleteLink = function( e ) {
312 var $el = $( this ),
313 $link = $el.parent().find( 'a:not(.icon)' ),
314 category = $link.text();
315
316 if ( $link.is( '.mw-added-category, .mw-changed-category' ) ) {
317 // We're just cancelling the addition or edit
318 ajaxcat.resetCatLink( $link, $link.hasClass( 'mw-added-category' ) );
319 return;
320 } else if ( $link.is( '.mw-removed-category' ) ) {
321 // It's already removed...
322 return;
323 }
324 ajaxcat.handleCategoryDelete( $link, category );
325 };
326
327 /**
328 * When multiEdit mode is enabled,
329 * this is called when the user clicks "save all"
330 * Combines the dialogDescriptions and edit functions.
331 *
332 * @context Element
333 * @return ?
334 */
335 this.handleStashedCategories = function() {
336
337 // Remove "holes" in array
338 var dialogDescriptions = $.grep( ajaxcat.stash.dialogDescriptions, function( n, i ) {
339 return n;
340 } );
341
342 if ( dialogDescriptions.length < 1 ) {
343 // Nothing to do here.
344 ajaxcat.saveAllButton.hide();
345 ajaxcat.cancelAllButton.hide();
346 return;
347 } else {
348 dialogDescriptions = dialogDescriptions.join( '<br/>' );
349 }
350
351 // Remove "holes" in array
352 var summaryShort = $.grep( ajaxcat.stash.editSummaries, function( n,i ) {
353 return n;
354 } );
355 summaryShort = summaryShort.join( ', ' );
356
357 var fns = ajaxcat.stash.fns;
358
359 ajaxcat.doConfirmEdit( {
360 modFn: function( oldtext ) {
361 // Run the text through all action functions
362 var newtext = oldtext;
363 for ( var i = 0; i < fns.length; i++ ) {
364 if ( $.isFunction( fns[i] ) ) {
365 newtext = fns[i]( newtext );
366 if ( newtext === false ) {
367 return false;
368 }
369 }
370 }
371 return newtext;
372 },
373 dialogDescription: dialogDescriptions,
374 editSummary: summaryShort,
375 doneFn: function() {
376 ajaxcat.resetAll( true );
377 },
378 $link: null,
379 action: 'all'
380 } );
381 };
382 };
383
384 /* Public methods */
385
386 mw.ajaxCategories.prototype = {
387 /**
388 * Create the UI
389 */
390 setup: function() {
391 // Could be set by gadgets like HotCat etc.
392 if ( mw.config.get( 'disableAJAXCategories' ) ) {
393 return false;
394 }
395 // Only do it for articles.
396 if ( !mw.config.get( 'wgIsArticle' ) ) {
397 return;
398 }
399 var options = this.options,
400 ajaxcat = this,
401 // Create [Add Category] link
402 $addLink = createButton( 'icon-add',
403 mw.msg( 'ajax-add-category' ),
404 'mw-ajax-addcategory',
405 mw.msg( 'ajax-add-category' )
406 ).click( function() {
407 $( this ).nextAll().toggle().filter( '.mw-addcategory-input' ).focus();
408 });
409
410 // Create add category prompt
411 this.addContainer = this.makeSuggestionBox( '', this.handleAddLink, mw.msg( 'ajax-add-category-submit' ) );
412 this.addContainer.children().hide();
413 this.addContainer.prepend( $addLink );
414
415 // Create edit & delete link for each category.
416 $( '#catlinks' ).find( 'li a' ).each( function() {
417 ajaxcat.createCatButtons( $( this ) );
418 });
419
420 options.$containerNormal.append( this.addContainer );
421
422 // @todo Make more clickable
423 this.saveAllButton = createButton( 'icon-tick',
424 mw.msg( 'ajax-confirm-save-all' ),
425 '',
426 mw.msg( 'ajax-confirm-save-all' )
427 );
428 this.cancelAllButton = createButton( 'icon-close',
429 mw.msg( 'ajax-cancel-all' ),
430 '',
431 mw.msg( 'ajax-cancel-all' )
432 );
433 this.saveAllButton.click( this.handleStashedCategories ).hide();
434 this.cancelAllButton.click( function() {
435 ajaxcat.resetAll( false );
436 } ).hide();
437 options.$containerNormal.append( this.saveAllButton ).append( this.cancelAllButton );
438 options.$container.append( this.addContainer );
439 },
440
441 /**
442 * Insert a newly added category into the DOM.
443 *
444 * @param catTitle {mw.Title} Category title for which a link should be created.
445 * @return {jQuery}
446 */
447 createCatLink: function( catTitle ) {
448 var catName = catTitle.getMainText();
449
450 if ( this.containsCat( catName ) ) {
451 return;
452 }
453
454 var $catLinkWrapper = $( this.options.catLinkWrapper ),
455 $anchor = $( '<a>' )
456 .text( catName )
457 .attr( {
458 target: '_blank',
459 href: catTitle.getUrl()
460 } );
461
462 $catLinkWrapper.append( $anchor );
463
464 this.createCatButtons( $anchor );
465
466 return $anchor;
467 },
468
469 /**
470 * Create a suggestion box for use in edit/add dialogs
471 * @param prefill {String} Prefill input
472 * @param callback {Function} Called on submit
473 * @param buttonVal {String} Button text
474 */
475 makeSuggestionBox: function( prefill, callback, buttonVal ) {
476 // Create add category prompt
477 var $promptContainer = $( '<div class="mw-addcategory-prompt"></div>' ),
478 $promptTextbox = $( '<input type="text" size="30" class="mw-addcategory-input"></input>' ),
479 $addButton = $( '<input type="button" class="mw-addcategory-button"></input>' ),
480 ajaxcat = this;
481
482 if ( prefill !== '' ) {
483 $promptTextbox.val( prefill );
484 }
485
486 $addButton
487 .val( buttonVal )
488 .click( callback );
489
490 $promptTextbox
491 .keyup( function( e ) {
492 if ( e.keyCode === 13 ) {
493 $addButton.click();
494 }
495 } )
496 .suggestions( {
497 fetch: fetchSuggestions,
498 cancel: function() {
499 var req = this.data( 'suggestions-request' );
500 // XMLHttpRequest.abort is unimplemented in IE6, also returns nonstandard value of 'unknown' for typeof
501 if ( req && typeof req.abort !== 'unknown' && typeof req.abort !== 'undefined' && req.abort ) {
502 req.abort();
503 }
504 }
505 } )
506 .suggestions();
507
508 $promptContainer
509 .append( $promptTextbox )
510 .append( $addButton );
511
512 return $promptContainer;
513 },
514
515 /**
516 * Execute or queue a category addition.
517 *
518 * @param $link {jQuery} Anchor tag of category link inside #catlinks.
519 * @param catTitle {mw.Title} Instance of mw.Title of the category to be added.
520 * @param catSortkey {String} sort key (optional)
521 * @param noAppend
522 * @return {mw.ajaxCategories}
523 */
524 handleCategoryAdd: function( $link, catTitle, catSortkey, noAppend ) {
525 var ajaxcat = this,
526 // Suffix is wikitext between '[[Category:Foo' and ']]'.
527 suffix = catSortkey ? '|' + catSortkey : '',
528 catName = catTitle.getMainText(),
529 catFull = catTitle.toText();
530
531 if ( !$link.length ) {
532 $link = this.createCatLink( catTitle );
533 }
534
535 if ( this.containsCat( catName ) ) {
536 this.showError( mw.msg( 'ajax-category-already-present', catName ) );
537 return this;
538 }
539
540 // Sometimes createCatLink returns undefined/null, previously caused an exception
541 // in the following lines, catching now.. @todo
542 if ( !$link ) {
543 this.showError( 'Unexpected error occurred. $link undefined.' );
544 return this;
545 }
546
547 // Mark red if missing
548 $link[(catTitle.exists() === false ? 'addClass' : 'removeClass')]( 'new' );
549
550 this.doConfirmEdit( {
551 modFn: function( oldText ) {
552 var newText = ajaxcat.runHooks( oldText, 'beforeAdd', catName );
553 newText = newText + "\n[[" + catFull + suffix + "]]\n";
554 return ajaxcat.runHooks( newText, 'afterAdd', catName );
555 },
556 dialogDescription: mw.message( 'ajax-add-category-summary', catName ).escaped(),
557 editSummary: '+[[' + catFull + ']]',
558 doneFn: function( unsaved ) {
559 if ( !noAppend ) {
560 ajaxcat.options.$container
561 .find( '#mw-normal-catlinks > .mw-addcategory-prompt' ).children( 'input' ).hide();
562 ajaxcat.options.$container
563 .find( '#mw-normal-catlinks ul' ).append( $link.parent() );
564 } else {
565 // Remove input box & button
566 $link.data( 'deleteButton' ).click();
567
568 // Update link text and href
569 $link.show().text( catName ).attr( 'href', catTitle.getUrl() );
570 }
571 if ( unsaved ) {
572 $link.addClass( 'mw-added-category' );
573 }
574 $( '.mw-ajax-addcategory' ).click();
575 },
576 $link: $link,
577 action: 'add'
578 } );
579 return this;
580 },
581
582 /**
583 * Execute or queue a category edit.
584 *
585 * @param $link {jQuery} Anchor tag of category link in #catlinks.
586 * @param oldCatName {String} Name of category before edit
587 * @param catTitle {mw.Title} Instance of mw.Title for new category
588 * @param catSortkey {String} Sort key of new category link (optional)
589 * @param isAdded {Boolean} True if this is a new link, false if it changed an existing one
590 */
591 handleCategoryEdit: function( $link, oldCatName, catTitle, catSortkey, isAdded ) {
592 var ajaxcat = this,
593 catName = catTitle.getMainText();
594
595 // Category add needs to be handled differently
596 if ( isAdded ) {
597 // Pass sortkey back
598 this.handleCategoryAdd( $link, catTitle, catSortkey, true );
599 return;
600 }
601
602 // User didn't change anything, trigger delete
603 // @todo Document why it's deleted.
604 if ( oldCatName === catName ) {
605 $link.data( 'deleteButton' ).click();
606 return;
607 }
608
609 // Mark red if missing
610 $link[(catTitle.exists() === false ? 'addClass' : 'removeClass')]( 'new' );
611
612 var categoryRegex = buildRegex( oldCatName ),
613 editSummary = '[[' + new mw.Title( oldCatName, catNsId ).toText() + ']] -> [[' + catTitle.toText() + ']]';
614
615 ajaxcat.doConfirmEdit({
616 modFn: function( oldText ) {
617 var newText = ajaxcat.runHooks( oldText, 'beforeChange', oldCatName, catName ),
618 matches = newText.match( categoryRegex );
619
620 // Old cat wasn't found, likely to be transcluded
621 if ( !$.isArray( matches ) ) {
622 ajaxcat.showError( mw.msg( 'ajax-edit-category-error' ) );
623 return false;
624 }
625
626 var suffix = catSortkey ? '|' + catSortkey : matches[0].replace( categoryRegex, '$2' ),
627 newCategoryWikitext = '[[' + catTitle + suffix + ']]';
628
629 if ( matches.length > 1 ) {
630 // The category is duplicated. Remove all but one match
631 for ( var i = 1; i < matches.length; i++ ) {
632 oldText = oldText.replace( matches[i], '' );
633 }
634 }
635 newText = oldText.replace( categoryRegex, newCategoryWikitext );
636
637 return ajaxcat.runHooks( newText, 'afterChange', oldCatName, catName );
638 },
639 dialogDescription: mw.message( 'ajax-edit-category-summary', oldCatName, catName ).escaped(),
640 editSummary: editSummary,
641 doneFn: function( unsaved ) {
642 // Remove input box & button
643 $link.data( 'deleteButton' ).click();
644
645 // Update link text and href
646 $link.show().text( catName ).attr( 'href', catTitle.getUrl() );
647 if ( unsaved ) {
648 $link.data( 'origCat', oldCatName ).addClass( 'mw-changed-category' );
649 }
650 },
651 $link: $link,
652 action: 'edit'
653 });
654 },
655
656 /**
657 * Checks the API whether the category in question is a redirect.
658 * Also returns existance info (to color link red/blue)
659 * @param category {String} Name of category to resolve
660 * @param callback {Function} Called with 1 argument (mw.Title object)
661 */
662 resolveRedirects: function( category, callback ) {
663 if ( !this.options.resolveRedirects ) {
664 callback( category, true );
665 return;
666 }
667 var catTitle = new mw.Title( category, catNsId ),
668 queryVars = {
669 action:'query',
670 titles: catTitle.toString(),
671 redirects: 1,
672 format: 'json'
673 };
674
675 $.getJSON( mw.util.wikiScript( 'api' ), queryVars, function( json ) {
676 var redirect = json.query.redirects,
677 exists = !json.query.pages[-1];
678
679 // Register existence
680 mw.Title.exist.set( catTitle.toString(), exists );
681
682 if ( redirect ) {
683 catTitle = new mw.Title( redirect[0].to ).getMainText();
684 // Redirect existence as well (non-existant pages can't be redirects)
685 mw.Title.exist.set( catTitle.toString(), true );
686 }
687 callback( catTitle );
688 } );
689 },
690
691 /**
692 * Append edit and remove buttons to a given category link
693 *
694 * @param DOMElement element Anchor element, to which the buttons should be appended.
695 * @return {mw.ajaxCategories}
696 */
697 createCatButtons: function( $element ) {
698 var deleteButton = createButton( 'icon-close', mw.msg( 'ajax-remove-category' ) ),
699 editButton = createButton( 'icon-edit', mw.msg( 'ajax-edit-category' ) ),
700 saveButton = createButton( 'icon-tick', mw.msg( 'ajax-confirm-save' ) ).hide(),
701 ajaxcat = this;
702
703 deleteButton.click( this.handleDeleteLink );
704 editButton.click( ajaxcat.createEditInterface );
705
706 $element.after( deleteButton ).after( editButton );
707
708 // Save references to all links and buttons
709 $element.data( {
710 deleteButton: deleteButton,
711 editButton: editButton,
712 saveButton: saveButton
713 } );
714 editButton.data( {
715 link: $element
716 } );
717 return this;
718 },
719
720 /**
721 * Append spinner wheel to element.
722 * @param $el {jQuery}
723 * @return {mw.ajaxCategories}
724 */
725 addProgressIndicator: function( $el ) {
726 $el.append( $( '<div>' ).addClass( 'mw-ajax-loader' ) );
727 return this;
728 },
729
730 /**
731 * Find and remove spinner wheel from inside element.
732 * @param $el {jQuery}
733 * @return {mw.ajaxCategories}
734 */
735 removeProgressIndicator: function( $el ) {
736 $el.find( '.mw-ajax-loader' ).remove();
737 return this;
738 },
739
740 /**
741 * Parse the DOM $container and build a list of
742 * present categories.
743 *
744 * @return {Array} All categories.
745 */
746 getCats: function() {
747 var cats = this.options.$container
748 .find( this.options.categoryLinkSelector )
749 .map( function() {
750 return $.trim( $( this ).text() );
751 } );
752 return cats;
753 },
754
755 /**
756 * Check whether a passed category is present in the DOM.
757 *
758 * @param newCat {String} Category name to be checked for.
759 * @return {Boolean}
760 */
761 containsCat: function( newCat ) {
762 newCat = $.ucFirst( newCat );
763 var match = false;
764 $.each( this.getCats(), function(i, cat) {
765 if ( $.ucFirst( cat ) === newCat ) {
766 match = true;
767 // Stop once we have a match
768 return false;
769 }
770 } );
771 return match;
772 },
773
774 /**
775 * Execute or queue a category delete.
776 *
777 * @param $link {jQuery}
778 * @param category
779 * @return ?
780 */
781 handleCategoryDelete: function( $link, category ) {
782 var categoryRegex = buildRegex( category, true ),
783 ajaxcat = this;
784
785 this.doConfirmEdit({
786 modFn: function( oldText ) {
787 var newText = ajaxcat.runHooks( oldText, 'beforeDelete', category );
788 newText = newText.replace( categoryRegex, '' );
789
790 if ( newText === oldText ) {
791 ajaxcat.showError( mw.msg( 'ajax-remove-category-error' ) );
792 return false;
793 }
794
795 return ajaxcat.runHooks( newText, 'afterDelete', category );
796 },
797 dialogDescription: mw.message( 'ajax-remove-category-summary', category ).escaped(),
798 editSummary: '-[[' + new mw.Title( category, catNsId ) + ']]',
799 doneFn: function( unsaved ) {
800 if ( unsaved ) {
801 $link.addClass( 'mw-removed-category' );
802 } else {
803 $link.parent().remove();
804 }
805 },
806 $link: $link,
807 action: 'delete'
808 });
809 },
810
811 /**
812 * Takes a category link element
813 * and strips all data from it.
814 *
815 * @param $link {jQuery}
816 * @param del {Boolean}
817 * @param dontRestoreText {Boolean}
818 * @return ?
819 */
820 resetCatLink: function( $link, del, dontRestoreText ) {
821 $link.removeClass( 'mw-removed-category mw-added-category mw-changed-category' );
822 var data = $link.data();
823
824 if ( typeof data.stashIndex === 'number' ) {
825 this.removeStashItem( data.stashIndex );
826 }
827 if ( del ) {
828 $link.parent().remove();
829 return;
830 }
831 if ( data.origCat && !dontRestoreText ) {
832 var catTitle = new mw.Title( data.origCat, catNsId );
833 $link.text( catTitle.getMainText() );
834 $link.attr( 'href', catTitle.getUrl() );
835 }
836
837 $link.removeData();
838
839 // Read static.
840 $link.data( {
841 saveButton: data.saveButton,
842 deleteButton: data.deleteButton,
843 editButton: data.editButton
844 } );
845 },
846
847 /**
848 * Do the actual edit.
849 * Gets token & text from api, runs it through fn
850 * and saves it with summary.
851 * @param page {String} Pagename
852 * @param fn {Function} edit function
853 * @param summary {String}
854 * @param doneFn {String} Callback after all is done
855 */
856 doEdit: function( page, fn, summary, doneFn ) {
857 // Get an edit token for the page.
858 var getTokenVars = {
859 action: 'query',
860 prop: 'info|revisions',
861 intoken: 'edit',
862 titles: page,
863 rvprop: 'content|timestamp',
864 format: 'json'
865 }, ajaxcat = this;
866
867 $.post(
868 mw.util.wikiScript( 'api' ),
869 getTokenVars,
870 function( reply ) {
871 var infos = reply.query.pages;
872 $.each( infos, function( pageid, data ) {
873 var token = data.edittoken,
874 timestamp = data.revisions[0].timestamp,
875 oldText = data.revisions[0]['*'],
876 nowikiKey = mw.user.generateId(), // Unique ID for nowiki replacement
877 nowikiFragments = []; // Nowiki fragments will be stored here during the changes
878
879 // Replace all nowiki parts with unique keys..
880 oldText = replaceNowikis( oldText, nowikiKey, nowikiFragments );
881
882 // ..then apply the changes to the page text..
883 var newText = fn( oldText );
884 if ( newText === false ) {
885 return;
886 }
887
888 // ..and restore the nowiki parts back.
889 newText = restoreNowikis( newText, nowikiKey, nowikiFragments );
890
891 var postEditVars = {
892 action: 'edit',
893 title: page,
894 text: newText,
895 summary: summary,
896 token: token,
897 basetimestamp: timestamp,
898 format: 'json'
899 };
900
901 $.post(
902 mw.util.wikiScript( 'api' ),
903 postEditVars,
904 doneFn,
905 'json'
906 )
907 .error( function( xhr, text, error ) {
908 ajaxcat.showError( mw.msg( 'ajax-api-error', text, error ) );
909 });
910 } );
911 },
912 'json'
913 ).error( function( xhr, text, error ) {
914 ajaxcat.showError( mw.msg( 'ajax-api-error', text, error ) );
915 } );
916 },
917
918 /**
919 * This gets called by all action buttons
920 * Displays a dialog to confirm the action
921 * Afterwards do the actual edit.
922 *
923 * @param props {Object}:
924 * - modFn {Function} text-modifying function
925 * - dialogDescription {String} Changes done (HTML in the dialog)
926 * - editSummary {String} Changes done (text for edit summary)
927 * - doneFn {Function} callback after everything is done
928 * - $link {jQuery}
929 * - action
930 * @return {mw.ajaxCategories}
931 */
932 doConfirmEdit: function( props ) {
933 var summaryHolder, reasonBox, dialog, submitFunction,
934 buttons = {},
935 dialogOptions = {
936 AutoOpen: true,
937 buttons: buttons,
938 width: 450
939 },
940 ajaxcat = this;
941
942 // Check whether to use multiEdit mode:
943 if ( this.options.multiEdit && props.action !== 'all' ) {
944
945 // Stash away
946 props.$link
947 .data( 'stashIndex', this.stash.fns.length )
948 .data( 'summary', props.dialogDescription );
949
950 this.stash.dialogDescriptions.push( props.dialogDescription );
951 this.stash.editSummaries.push( props.editSummary );
952 this.stash.fns.push( props.modFn );
953
954 this.saveAllButton.show();
955 this.cancelAllButton.show();
956
957 // Clear input field after action
958 ajaxcat.addContainer.find( '.mw-addcategory-input' ).val( '' );
959
960 // This only does visual changes, fire done and return.
961 props.doneFn( true );
962 return this;
963 }
964
965 // Summary of the action to be taken
966 summaryHolder = $( '<p>' )
967 .html( '<strong>' + mw.msg( 'ajax-category-question' ) + '</strong><br/>' + props.dialogDescription );
968
969 // Reason textbox.
970 reasonBox = $( '<input type="text" size="45"></input>' )
971 .addClass( 'mw-ajax-confirm-reason' );
972
973 // Produce a confirmation dialog
974 dialog = $( '<div>' )
975 .addClass( 'mw-ajax-confirm-dialog' )
976 .attr( 'title', mw.msg( 'ajax-confirm-title' ) )
977 .append( summaryHolder )
978 .append( reasonBox );
979
980 // Submit button
981 submitFunction = function() {
982 ajaxcat.addProgressIndicator( dialog );
983 ajaxcat.doEdit(
984 mw.config.get( 'wgPageName' ),
985 props.modFn,
986 props.editSummary + ': ' + reasonBox.val(),
987 function() {
988 props.doneFn();
989
990 // Clear input field after successful edit
991 ajaxcat.addContainer.find( '.mw-addcategory-input' ).val( '' );
992
993 dialog.dialog( 'close' );
994 ajaxcat.removeProgressIndicator( dialog );
995 }
996 );
997 };
998
999 buttons[mw.msg( 'ajax-confirm-save' )] = submitFunction;
1000
1001 dialog.dialog( dialogOptions ).keyup( function( e ) {
1002 // Close on enter
1003 if ( e.keyCode === 13 ) {
1004 submitFunction();
1005 }
1006 } );
1007
1008 return this;
1009 },
1010
1011 /**
1012 * @param index {Number|jQuery} Stash index or jQuery object of stash item.
1013 * @return {mw.ajaxCategories}
1014 */
1015 removeStashItem: function( i ) {
1016 if ( typeof i !== 'number' ) {
1017 i = i.data( 'stashIndex' );
1018 }
1019
1020 try {
1021 delete this.stash.fns[i];
1022 delete this.stash.dialogDescriptions[i];
1023 } catch(e) {}
1024
1025 if ( $.isEmpty( this.stash.fns ) ) {
1026 this.stash.fns = [];
1027 this.stash.dialogDescriptions = [];
1028 this.stash.editSummaries = [];
1029 this.saveAllButton.hide();
1030 this.cancelAllButton.hide();
1031 }
1032 return this;
1033 },
1034
1035 /**
1036 * Reset all data from the category links and the stash.
1037 *
1038 * @param del {Boolean} Delete any category links with .mw-removed-category
1039 * @return {mw.ajaxCategories}
1040 */
1041 resetAll: function( del ) {
1042 var $links = this.options.$container.find( this.options.categoryLinkSelector ),
1043 $del = $([]),
1044 ajaxcat = this;
1045
1046 if ( del ) {
1047 $del = $links.filter( '.mw-removed-category' ).parent();
1048 }
1049
1050 $links.each( function() {
1051 ajaxcat.resetCatLink( $( this ), false, del );
1052 } );
1053
1054 $del.remove();
1055
1056 this.options.$container.find( '#mw-hidden-catlinks' ).remove();
1057
1058 return this;
1059 },
1060
1061 /**
1062 * Add hooks
1063 * Currently available: beforeAdd, beforeChange, beforeDelete,
1064 * afterAdd, afterChange, afterDelete
1065 * If the hook function returns false, all changes are aborted.
1066 *
1067 * @param string type Type of hook to add
1068 * @param function fn Hook function. The following vars are passed to it:
1069 * 1. oldtext: The wikitext before the hook
1070 * 2. category: The deleted, added, or changed category
1071 * 3. (only for beforeChange/afterChange): newcategory
1072 */
1073 addHook: function( type, fn ) {
1074 if ( !this.hooks[type] || !$.isFunction( fn ) ) {
1075 return;
1076 }
1077 else {
1078 this.hooks[type].push( fn );
1079 }
1080 },
1081
1082
1083 /**
1084 * Open a dismissable error dialog
1085 *
1086 * @param string str The error description
1087 */
1088 showError: function( str ) {
1089 var oldDialog = $( '.mw-ajax-confirm-dialog' ),
1090 buttons = {},
1091 dialogOptions = {
1092 buttons: buttons,
1093 AutoOpen: true,
1094 title: mw.msg( 'ajax-error-title' )
1095 };
1096
1097 this.removeProgressIndicator( oldDialog );
1098 oldDialog.dialog( 'close' );
1099
1100 var dialog = $( '<div>' ).text( str );
1101
1102 mw.util.$content.append( dialog );
1103
1104 buttons[mw.msg( 'ajax-confirm-ok' )] = function( e ) {
1105 dialog.dialog( 'close' );
1106 };
1107
1108 dialog.dialog( dialogOptions ).keyup( function( e ) {
1109 if ( e.keyCode === 13 ) {
1110 dialog.dialog( 'close' );
1111 }
1112 } );
1113 },
1114
1115 /**
1116 * @param oldtext
1117 * @param type
1118 * @param category
1119 * @param categoryNew
1120 * @return oldtext
1121 */
1122 runHooks: function( oldtext, type, category, categoryNew ) {
1123 // No hooks registered
1124 if ( !this.hooks[type] ) {
1125 return oldtext;
1126 } else {
1127 for ( var i = 0; i < this.hooks[type].length; i++ ) {
1128 oldtext = this.hooks[type][i]( oldtext, category, categoryNew );
1129 if ( oldtext === false ) {
1130 this.showError( mw.msg( 'ajax-category-hook-error', category ) );
1131 return;
1132 }
1133 }
1134 return oldtext;
1135 }
1136 }
1137 };
1138
1139 } )( jQuery );