more ajaxCategories fixes based on review in r93351 CR
[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 $catLinkWrapper = $( this.options.catLinkWrapper ),
450 $anchor = $( '<a>' )
451 .text( catName )
452 .attr( {
453 target: '_blank',
454 href: catTitle.getUrl()
455 } );
456
457 $catLinkWrapper.append( $anchor );
458
459 this.createCatButtons( $anchor );
460
461 return $anchor;
462 },
463
464 /**
465 * Create a suggestion box for use in edit/add dialogs
466 * @param prefill {String} Prefill input
467 * @param callback {Function} Called on submit
468 * @param buttonVal {String} Button text
469 */
470 makeSuggestionBox: function( prefill, callback, buttonVal ) {
471 // Create add category prompt
472 var $promptContainer = $( '<div class="mw-addcategory-prompt"></div>' ),
473 $promptTextbox = $( '<input type="text" size="30" class="mw-addcategory-input"></input>' ),
474 $addButton = $( '<input type="button" class="mw-addcategory-button"></input>' ),
475 ajaxcat = this;
476
477 if ( prefill !== '' ) {
478 $promptTextbox.val( prefill );
479 }
480
481 $addButton
482 .val( buttonVal )
483 .click( callback );
484
485 $promptTextbox
486 .keyup( function( e ) {
487 if ( e.keyCode === 13 ) {
488 $addButton.click();
489 }
490 } )
491 .suggestions( {
492 fetch: fetchSuggestions,
493 cancel: function() {
494 var req = this.data( 'suggestions-request' );
495 // XMLHttpRequest.abort is unimplemented in IE6, also returns nonstandard value of 'unknown' for typeof
496 if ( req && typeof req.abort !== 'unknown' && typeof req.abort !== 'undefined' && req.abort ) {
497 req.abort();
498 }
499 }
500 } )
501 .suggestions();
502
503 $promptContainer
504 .append( $promptTextbox )
505 .append( $addButton );
506
507 return $promptContainer;
508 },
509
510 /**
511 * Execute or queue a category addition.
512 *
513 * @param $link {jQuery} Anchor tag of category link inside #catlinks.
514 * @param catTitle {mw.Title} Instance of mw.Title of the category to be added.
515 * @param catSortkey {String} sort key (optional)
516 * @param noAppend
517 * @return {mw.ajaxCategories}
518 */
519 handleCategoryAdd: function( $link, catTitle, catSortkey, noAppend ) {
520 var ajaxcat = this,
521 // Suffix is wikitext between '[[Category:Foo' and ']]'.
522 suffix = catSortkey ? '|' + catSortkey : '',
523 catName = catTitle.getMainText(),
524 catFull = catTitle.toText();
525
526 if ( this.containsCat( catName ) ) {
527 this.showError( mw.msg( 'ajax-category-already-present', catName ) );
528 return this;
529 }
530
531 if ( !$link.length ) {
532 $link = this.createCatLink( catTitle );
533 }
534
535 // Mark red if missing
536 $link[(catTitle.exists() === false ? 'addClass' : 'removeClass')]( 'new' );
537
538 this.doConfirmEdit( {
539 modFn: function( oldText ) {
540 var newText = ajaxcat.runHooks( oldText, 'beforeAdd', catName );
541 newText = newText + "\n[[" + catFull + suffix + "]]\n";
542 return ajaxcat.runHooks( newText, 'afterAdd', catName );
543 },
544 dialogDescription: mw.message( 'ajax-add-category-summary', catName ).escaped(),
545 editSummary: '+[[' + catFull + ']]',
546 doneFn: function( unsaved ) {
547 if ( !noAppend ) {
548 ajaxcat.options.$container
549 .find( '#mw-normal-catlinks > .mw-addcategory-prompt' ).children( 'input' ).hide();
550 ajaxcat.options.$container
551 .find( '#mw-normal-catlinks ul' ).append( $link.parent() );
552 } else {
553 // Remove input box & button
554 $link.data( 'deleteButton' ).click();
555
556 // Update link text and href
557 $link.show().text( catName ).attr( 'href', catTitle.getUrl() );
558 }
559 if ( unsaved ) {
560 $link.addClass( 'mw-added-category' );
561 }
562 $( '.mw-ajax-addcategory' ).click();
563 },
564 $link: $link,
565 action: 'add'
566 } );
567 return this;
568 },
569
570 /**
571 * Execute or queue a category edit.
572 *
573 * @param $link {jQuery} Anchor tag of category link in #catlinks.
574 * @param oldCatName {String} Name of category before edit
575 * @param catTitle {mw.Title} Instance of mw.Title for new category
576 * @param catSortkey {String} Sort key of new category link (optional)
577 * @param isAdded {Boolean} True if this is a new link, false if it changed an existing one
578 */
579 handleCategoryEdit: function( $link, oldCatName, catTitle, catSortkey, isAdded ) {
580 var ajaxcat = this,
581 catName = catTitle.getMainText();
582
583 // Category add needs to be handled differently
584 if ( isAdded ) {
585 // Pass sortkey back
586 this.handleCategoryAdd( $link, catTitle, catSortkey, true );
587 return;
588 }
589
590 // User didn't change anything, trigger delete
591 // @todo Document why it's deleted.
592 if ( oldCatName === catName ) {
593 $link.data( 'deleteButton' ).click();
594 return;
595 }
596
597 // Mark red if missing
598 $link[(catTitle.exists() === false ? 'addClass' : 'removeClass')]( 'new' );
599
600 var categoryRegex = buildRegex( oldCatName ),
601 editSummary = '[[' + new mw.Title( oldCatName, catNsId ).toText() + ']] -> [[' + catTitle.toText() + ']]';
602
603 ajaxcat.doConfirmEdit({
604 modFn: function( oldText ) {
605 var newText = ajaxcat.runHooks( oldText, 'beforeChange', oldCatName, catName ),
606 matches = newText.match( categoryRegex );
607
608 // Old cat wasn't found, likely to be transcluded
609 if ( !$.isArray( matches ) ) {
610 ajaxcat.showError( mw.msg( 'ajax-edit-category-error' ) );
611 return false;
612 }
613
614 var suffix = catSortkey ? '|' + catSortkey : matches[0].replace( categoryRegex, '$2' ),
615 newCategoryWikitext = '[[' + catTitle + suffix + ']]';
616
617 if ( matches.length > 1 ) {
618 // The category is duplicated. Remove all but one match
619 for ( var i = 1; i < matches.length; i++ ) {
620 oldText = oldText.replace( matches[i], '' );
621 }
622 }
623 newText = oldText.replace( categoryRegex, newCategoryWikitext );
624
625 return ajaxcat.runHooks( newText, 'afterChange', oldCatName, catName );
626 },
627 dialogDescription: mw.message( 'ajax-edit-category-summary', oldCatName, catName ).escaped(),
628 editSummary: editSummary,
629 doneFn: function( unsaved ) {
630 // Remove input box & button
631 $link.data( 'deleteButton' ).click();
632
633 // Update link text and href
634 $link.show().text( catName ).attr( 'href', catTitle.getUrl() );
635 if ( unsaved ) {
636 $link.data( 'origCat', oldCatName ).addClass( 'mw-changed-category' );
637 }
638 },
639 $link: $link,
640 action: 'edit'
641 });
642 },
643
644 /**
645 * Checks the API whether the category in question is a redirect.
646 * Also returns existance info (to color link red/blue)
647 * @param category {String} Name of category to resolve
648 * @param callback {Function} Called with 1 argument (mw.Title object)
649 */
650 resolveRedirects: function( category, callback ) {
651 if ( !this.options.resolveRedirects ) {
652 callback( category, true );
653 return;
654 }
655 var catTitle = new mw.Title( category, catNsId ),
656 queryVars = {
657 action:'query',
658 titles: catTitle.toString(),
659 redirects: 1,
660 format: 'json'
661 };
662
663 $.getJSON( mw.util.wikiScript( 'api' ), queryVars, function( json ) {
664 var redirect = json.query.redirects,
665 exists = !json.query.pages[-1];
666
667 // If it's a redirect 'exists' is for the target, not the origin
668 if ( redirect ) {
669 // Register existance of redirect origin as well,
670 // a non-existent page can't be a redirect.
671 mw.Title.exist.set( catTitle.toString(), true );
672
673 // Override title with the redirect target
674 catTitle = new mw.Title( redirect[0].to ).getMainText();
675 }
676
677 // Register existence
678 mw.Title.exist.set( catTitle.toString(), exists );
679
680 callback( catTitle );
681 } );
682 },
683
684 /**
685 * Append edit and remove buttons to a given category link
686 *
687 * @param DOMElement element Anchor element, to which the buttons should be appended.
688 * @return {mw.ajaxCategories}
689 */
690 createCatButtons: function( $element ) {
691 var deleteButton = createButton( 'icon-close', mw.msg( 'ajax-remove-category' ) ),
692 editButton = createButton( 'icon-edit', mw.msg( 'ajax-edit-category' ) ),
693 saveButton = createButton( 'icon-tick', mw.msg( 'ajax-confirm-save' ) ).hide(),
694 ajaxcat = this;
695
696 deleteButton.click( this.handleDeleteLink );
697 editButton.click( ajaxcat.createEditInterface );
698
699 $element.after( deleteButton ).after( editButton );
700
701 // Save references to all links and buttons
702 $element.data( {
703 deleteButton: deleteButton,
704 editButton: editButton,
705 saveButton: saveButton
706 } );
707 editButton.data( {
708 link: $element
709 } );
710 return this;
711 },
712
713 /**
714 * Append spinner wheel to element.
715 * @param $el {jQuery}
716 * @return {mw.ajaxCategories}
717 */
718 addProgressIndicator: function( $el ) {
719 $el.append( $( '<div>' ).addClass( 'mw-ajax-loader' ) );
720 return this;
721 },
722
723 /**
724 * Find and remove spinner wheel from inside element.
725 * @param $el {jQuery}
726 * @return {mw.ajaxCategories}
727 */
728 removeProgressIndicator: function( $el ) {
729 $el.find( '.mw-ajax-loader' ).remove();
730 return this;
731 },
732
733 /**
734 * Parse the DOM $container and build a list of
735 * present categories.
736 *
737 * @return {Array} All categories.
738 */
739 getCats: function() {
740 var cats = this.options.$container
741 .find( this.options.categoryLinkSelector )
742 .map( function() {
743 return $.trim( $( this ).text() );
744 } );
745 return cats;
746 },
747
748 /**
749 * Check whether a passed category is present in the DOM.
750 *
751 * @param newCat {String} Category name to be checked for.
752 * @return {Boolean}
753 */
754 containsCat: function( newCat ) {
755 newCat = $.ucFirst( newCat );
756 var match = false;
757 $.each( this.getCats(), function(i, cat) {
758 if ( $.ucFirst( cat ) === newCat ) {
759 match = true;
760 // Stop once we have a match
761 return false;
762 }
763 } );
764 return match;
765 },
766
767 /**
768 * Execute or queue a category delete.
769 *
770 * @param $link {jQuery}
771 * @param category
772 * @return ?
773 */
774 handleCategoryDelete: function( $link, category ) {
775 var categoryRegex = buildRegex( category, true ),
776 ajaxcat = this;
777
778 this.doConfirmEdit({
779 modFn: function( oldText ) {
780 var newText = ajaxcat.runHooks( oldText, 'beforeDelete', category );
781 newText = newText.replace( categoryRegex, '' );
782
783 if ( newText === oldText ) {
784 ajaxcat.showError( mw.msg( 'ajax-remove-category-error' ) );
785 return false;
786 }
787
788 return ajaxcat.runHooks( newText, 'afterDelete', category );
789 },
790 dialogDescription: mw.message( 'ajax-remove-category-summary', category ).escaped(),
791 editSummary: '-[[' + new mw.Title( category, catNsId ) + ']]',
792 doneFn: function( unsaved ) {
793 if ( unsaved ) {
794 $link.addClass( 'mw-removed-category' );
795 } else {
796 $link.parent().remove();
797 }
798 },
799 $link: $link,
800 action: 'delete'
801 });
802 },
803
804 /**
805 * Takes a category link element
806 * and strips all data from it.
807 *
808 * @param $link {jQuery}
809 * @param del {Boolean}
810 * @param dontRestoreText {Boolean}
811 * @return ?
812 */
813 resetCatLink: function( $link, del, dontRestoreText ) {
814 $link.removeClass( 'mw-removed-category mw-added-category mw-changed-category' );
815 var data = $link.data();
816
817 if ( typeof data.stashIndex === 'number' ) {
818 this.removeStashItem( data.stashIndex );
819 }
820 if ( del ) {
821 $link.parent().remove();
822 return;
823 }
824 if ( data.origCat && !dontRestoreText ) {
825 var catTitle = new mw.Title( data.origCat, catNsId );
826 $link.text( catTitle.getMainText() );
827 $link.attr( 'href', catTitle.getUrl() );
828 }
829
830 $link.removeData();
831
832 // Re-add data
833 $link.data( {
834 saveButton: data.saveButton,
835 deleteButton: data.deleteButton,
836 editButton: data.editButton
837 } );
838 },
839
840 /**
841 * Do the actual edit.
842 * Gets token & text from api, runs it through fn
843 * and saves it with summary.
844 * @param page {String} Pagename
845 * @param fn {Function} edit function
846 * @param summary {String}
847 * @param doneFn {String} Callback after all is done
848 */
849 doEdit: function( page, fn, summary, doneFn ) {
850 // Get an edit token for the page.
851 var getTokenVars = {
852 action: 'query',
853 prop: 'info|revisions',
854 intoken: 'edit',
855 titles: page,
856 rvprop: 'content|timestamp',
857 format: 'json'
858 }, ajaxcat = this;
859
860 $.post(
861 mw.util.wikiScript( 'api' ),
862 getTokenVars,
863 function( json ) {
864 if ( 'error' in json ) {
865 ajaxcat.showError( mw.msg( 'ajax-api-error', json.error.code, json.error.info ) );
866 return;
867 } else if ( json.query && json.query.pages ) {
868 var infos = json.query.pages;
869 } else {
870 ajaxcat.showError( mw.msg( 'ajax-api-unknown-error' ) );
871 return;
872 }
873
874 $.each( infos, function( pageid, data ) {
875 var token = data.edittoken,
876 timestamp = data.revisions[0].timestamp,
877 oldText = data.revisions[0]['*'],
878 nowikiKey = mw.user.generateId(), // Unique ID for nowiki replacement
879 nowikiFragments = []; // Nowiki fragments will be stored here during the changes
880
881 // Replace all nowiki parts with unique keys..
882 oldText = replaceNowikis( oldText, nowikiKey, nowikiFragments );
883
884 // ..then apply the changes to the page text..
885 var newText = fn( oldText );
886 if ( newText === false ) {
887 return;
888 }
889
890 // ..and restore the nowiki parts back.
891 newText = restoreNowikis( newText, nowikiKey, nowikiFragments );
892
893 var postEditVars = {
894 action: 'edit',
895 title: page,
896 text: newText,
897 summary: summary,
898 token: token,
899 basetimestamp: timestamp,
900 format: 'json'
901 };
902
903 $.post(
904 mw.util.wikiScript( 'api' ),
905 postEditVars,
906 doneFn,
907 'json'
908 )
909 .error( function( xhr, text, error ) {
910 ajaxcat.showError( mw.msg( 'ajax-api-error', text, error ) );
911 });
912 } );
913 },
914 'json'
915 ).error( function( xhr, text, error ) {
916 ajaxcat.showError( mw.msg( 'ajax-api-error', text, error ) );
917 } );
918 },
919
920 /**
921 * This gets called by all action buttons
922 * Displays a dialog to confirm the action
923 * Afterwards do the actual edit.
924 *
925 * @param props {Object}:
926 * - modFn {Function} text-modifying function
927 * - dialogDescription {String} Changes done (HTML for in the dialog, escape before hand if needed)
928 * - editSummary {String} Changes done (text for the edit summary)
929 * - doneFn {Function} callback after everything is done
930 * - $link {jQuery}
931 * - action
932 * @return {mw.ajaxCategories}
933 */
934 doConfirmEdit: function( props ) {
935 var summaryHolder, reasonBox, dialog, submitFunction,
936 buttons = {},
937 dialogOptions = {
938 AutoOpen: true,
939 buttons: buttons,
940 width: 450
941 },
942 ajaxcat = this;
943
944 // Check whether to use multiEdit mode:
945 if ( this.options.multiEdit && props.action !== 'all' ) {
946
947 // Stash away
948 props.$link
949 .data( 'stashIndex', this.stash.fns.length )
950 .data( 'summary', props.dialogDescription );
951
952 this.stash.dialogDescriptions.push( props.dialogDescription );
953 this.stash.editSummaries.push( props.editSummary );
954 this.stash.fns.push( props.modFn );
955
956 this.saveAllButton.show();
957 this.cancelAllButton.show();
958
959 // Clear input field after action
960 ajaxcat.addContainer.find( '.mw-addcategory-input' ).val( '' );
961
962 // This only does visual changes, fire done and return.
963 props.doneFn( true );
964 return this;
965 }
966
967 // Summary of the action to be taken
968 summaryHolder = $( '<p>' )
969 .html( '<strong>' + mw.message( 'ajax-category-question' ).escaped() + '</strong><br/>' + props.dialogDescription );
970
971 // Reason textbox.
972 reasonBox = $( '<input type="text" size="45"></input>' )
973 .addClass( 'mw-ajax-confirm-reason' );
974
975 // Produce a confirmation dialog
976 dialog = $( '<div>' )
977 .addClass( 'mw-ajax-confirm-dialog' )
978 .attr( 'title', mw.msg( 'ajax-confirm-title' ) )
979 .append( summaryHolder )
980 .append( reasonBox );
981
982 // Submit button
983 submitFunction = function() {
984 ajaxcat.addProgressIndicator( dialog );
985 ajaxcat.doEdit(
986 mw.config.get( 'wgPageName' ),
987 props.modFn,
988 props.editSummary + ': ' + reasonBox.val(),
989 function() {
990 props.doneFn();
991
992 // Clear input field after successful edit
993 ajaxcat.addContainer.find( '.mw-addcategory-input' ).val( '' );
994
995 dialog.dialog( 'close' );
996 ajaxcat.removeProgressIndicator( dialog );
997 }
998 );
999 };
1000
1001 buttons[mw.msg( 'ajax-confirm-save' )] = submitFunction;
1002
1003 dialog.dialog( dialogOptions ).keyup( function( e ) {
1004 // Close on enter
1005 if ( e.keyCode === 13 ) {
1006 submitFunction();
1007 }
1008 } );
1009
1010 return this;
1011 },
1012
1013 /**
1014 * @param index {Number|jQuery} Stash index or jQuery object of stash item.
1015 * @return {mw.ajaxCategories}
1016 */
1017 removeStashItem: function( i ) {
1018 if ( typeof i !== 'number' ) {
1019 i = i.data( 'stashIndex' );
1020 }
1021
1022 try {
1023 delete this.stash.fns[i];
1024 delete this.stash.dialogDescriptions[i];
1025 } catch(e) {}
1026
1027 if ( $.isEmpty( this.stash.fns ) ) {
1028 this.stash.fns = [];
1029 this.stash.dialogDescriptions = [];
1030 this.stash.editSummaries = [];
1031 this.saveAllButton.hide();
1032 this.cancelAllButton.hide();
1033 }
1034 return this;
1035 },
1036
1037 /**
1038 * Reset all data from the category links and the stash.
1039 *
1040 * @param del {Boolean} Delete any category links with .mw-removed-category
1041 * @return {mw.ajaxCategories}
1042 */
1043 resetAll: function( del ) {
1044 var $links = this.options.$container.find( this.options.categoryLinkSelector ),
1045 $del = $([]),
1046 ajaxcat = this;
1047
1048 if ( del ) {
1049 $del = $links.filter( '.mw-removed-category' ).parent();
1050 }
1051
1052 $links.each( function() {
1053 ajaxcat.resetCatLink( $( this ), false, del );
1054 } );
1055
1056 $del.remove();
1057
1058 this.options.$container.find( '#mw-hidden-catlinks' ).remove();
1059
1060 return this;
1061 },
1062
1063 /**
1064 * Add hooks
1065 * Currently available: beforeAdd, beforeChange, beforeDelete,
1066 * afterAdd, afterChange, afterDelete
1067 * If the hook function returns false, all changes are aborted.
1068 *
1069 * @param string type Type of hook to add
1070 * @param function fn Hook function. The following vars are passed to it:
1071 * 1. oldtext: The wikitext before the hook
1072 * 2. category: The deleted, added, or changed category
1073 * 3. (only for beforeChange/afterChange): newcategory
1074 */
1075 addHook: function( type, fn ) {
1076 if ( !this.hooks[type] || !$.isFunction( fn ) ) {
1077 return;
1078 }
1079 else {
1080 this.hooks[type].push( fn );
1081 }
1082 },
1083
1084
1085 /**
1086 * Open a dismissable error dialog
1087 *
1088 * @param string str The error description
1089 */
1090 showError: function( str ) {
1091 var oldDialog = $( '.mw-ajax-confirm-dialog' ),
1092 buttons = {},
1093 dialogOptions = {
1094 buttons: buttons,
1095 AutoOpen: true,
1096 title: mw.msg( 'ajax-error-title' )
1097 };
1098
1099 this.removeProgressIndicator( oldDialog );
1100 oldDialog.dialog( 'close' );
1101
1102 var dialog = $( '<div>' ).text( str );
1103
1104 mw.util.$content.append( dialog );
1105
1106 buttons[mw.msg( 'ajax-confirm-ok' )] = function( e ) {
1107 dialog.dialog( 'close' );
1108 };
1109
1110 dialog.dialog( dialogOptions ).keyup( function( e ) {
1111 if ( e.keyCode === 13 ) {
1112 dialog.dialog( 'close' );
1113 }
1114 } );
1115 },
1116
1117 /**
1118 * @param oldtext
1119 * @param type
1120 * @param category
1121 * @param categoryNew
1122 * @return oldtext
1123 */
1124 runHooks: function( oldtext, type, category, categoryNew ) {
1125 // No hooks registered
1126 if ( !this.hooks[type] ) {
1127 return oldtext;
1128 } else {
1129 for ( var i = 0; i < this.hooks[type].length; i++ ) {
1130 oldtext = this.hooks[type][i]( oldtext, category, categoryNew );
1131 if ( oldtext === false ) {
1132 this.showError( mw.msg( 'ajax-category-hook-error', category ) );
1133 return;
1134 }
1135 }
1136 return oldtext;
1137 }
1138 }
1139 };
1140
1141 } )( jQuery );