Solve undefined-message problem by removing it all together. I've moved the .contains...
[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 // Register existence
668 mw.Title.exist.set( catTitle.toString(), exists );
669
670 if ( redirect ) {
671 catTitle = new mw.Title( redirect[0].to ).getMainText();
672 // Redirect existence as well (non-existant pages can't be redirects)
673 mw.Title.exist.set( catTitle.toString(), true );
674 }
675 callback( catTitle );
676 } );
677 },
678
679 /**
680 * Append edit and remove buttons to a given category link
681 *
682 * @param DOMElement element Anchor element, to which the buttons should be appended.
683 * @return {mw.ajaxCategories}
684 */
685 createCatButtons: function( $element ) {
686 var deleteButton = createButton( 'icon-close', mw.msg( 'ajax-remove-category' ) ),
687 editButton = createButton( 'icon-edit', mw.msg( 'ajax-edit-category' ) ),
688 saveButton = createButton( 'icon-tick', mw.msg( 'ajax-confirm-save' ) ).hide(),
689 ajaxcat = this;
690
691 deleteButton.click( this.handleDeleteLink );
692 editButton.click( ajaxcat.createEditInterface );
693
694 $element.after( deleteButton ).after( editButton );
695
696 // Save references to all links and buttons
697 $element.data( {
698 deleteButton: deleteButton,
699 editButton: editButton,
700 saveButton: saveButton
701 } );
702 editButton.data( {
703 link: $element
704 } );
705 return this;
706 },
707
708 /**
709 * Append spinner wheel to element.
710 * @param $el {jQuery}
711 * @return {mw.ajaxCategories}
712 */
713 addProgressIndicator: function( $el ) {
714 $el.append( $( '<div>' ).addClass( 'mw-ajax-loader' ) );
715 return this;
716 },
717
718 /**
719 * Find and remove spinner wheel from inside element.
720 * @param $el {jQuery}
721 * @return {mw.ajaxCategories}
722 */
723 removeProgressIndicator: function( $el ) {
724 $el.find( '.mw-ajax-loader' ).remove();
725 return this;
726 },
727
728 /**
729 * Parse the DOM $container and build a list of
730 * present categories.
731 *
732 * @return {Array} All categories.
733 */
734 getCats: function() {
735 var cats = this.options.$container
736 .find( this.options.categoryLinkSelector )
737 .map( function() {
738 return $.trim( $( this ).text() );
739 } );
740 return cats;
741 },
742
743 /**
744 * Check whether a passed category is present in the DOM.
745 *
746 * @param newCat {String} Category name to be checked for.
747 * @return {Boolean}
748 */
749 containsCat: function( newCat ) {
750 newCat = $.ucFirst( newCat );
751 var match = false;
752 $.each( this.getCats(), function(i, cat) {
753 if ( $.ucFirst( cat ) === newCat ) {
754 match = true;
755 // Stop once we have a match
756 return false;
757 }
758 } );
759 return match;
760 },
761
762 /**
763 * Execute or queue a category delete.
764 *
765 * @param $link {jQuery}
766 * @param category
767 * @return ?
768 */
769 handleCategoryDelete: function( $link, category ) {
770 var categoryRegex = buildRegex( category, true ),
771 ajaxcat = this;
772
773 this.doConfirmEdit({
774 modFn: function( oldText ) {
775 var newText = ajaxcat.runHooks( oldText, 'beforeDelete', category );
776 newText = newText.replace( categoryRegex, '' );
777
778 if ( newText === oldText ) {
779 ajaxcat.showError( mw.msg( 'ajax-remove-category-error' ) );
780 return false;
781 }
782
783 return ajaxcat.runHooks( newText, 'afterDelete', category );
784 },
785 dialogDescription: mw.message( 'ajax-remove-category-summary', category ).escaped(),
786 editSummary: '-[[' + new mw.Title( category, catNsId ) + ']]',
787 doneFn: function( unsaved ) {
788 if ( unsaved ) {
789 $link.addClass( 'mw-removed-category' );
790 } else {
791 $link.parent().remove();
792 }
793 },
794 $link: $link,
795 action: 'delete'
796 });
797 },
798
799 /**
800 * Takes a category link element
801 * and strips all data from it.
802 *
803 * @param $link {jQuery}
804 * @param del {Boolean}
805 * @param dontRestoreText {Boolean}
806 * @return ?
807 */
808 resetCatLink: function( $link, del, dontRestoreText ) {
809 $link.removeClass( 'mw-removed-category mw-added-category mw-changed-category' );
810 var data = $link.data();
811
812 if ( typeof data.stashIndex === 'number' ) {
813 this.removeStashItem( data.stashIndex );
814 }
815 if ( del ) {
816 $link.parent().remove();
817 return;
818 }
819 if ( data.origCat && !dontRestoreText ) {
820 var catTitle = new mw.Title( data.origCat, catNsId );
821 $link.text( catTitle.getMainText() );
822 $link.attr( 'href', catTitle.getUrl() );
823 }
824
825 $link.removeData();
826
827 // Read static.
828 $link.data( {
829 saveButton: data.saveButton,
830 deleteButton: data.deleteButton,
831 editButton: data.editButton
832 } );
833 },
834
835 /**
836 * Do the actual edit.
837 * Gets token & text from api, runs it through fn
838 * and saves it with summary.
839 * @param page {String} Pagename
840 * @param fn {Function} edit function
841 * @param summary {String}
842 * @param doneFn {String} Callback after all is done
843 */
844 doEdit: function( page, fn, summary, doneFn ) {
845 // Get an edit token for the page.
846 var getTokenVars = {
847 action: 'query',
848 prop: 'info|revisions',
849 intoken: 'edit',
850 titles: page,
851 rvprop: 'content|timestamp',
852 format: 'json'
853 }, ajaxcat = this;
854
855 $.post(
856 mw.util.wikiScript( 'api' ),
857 getTokenVars,
858 function( reply ) {
859 var infos = reply.query.pages;
860 $.each( infos, function( pageid, data ) {
861 var token = data.edittoken,
862 timestamp = data.revisions[0].timestamp,
863 oldText = data.revisions[0]['*'],
864 nowikiKey = mw.user.generateId(), // Unique ID for nowiki replacement
865 nowikiFragments = []; // Nowiki fragments will be stored here during the changes
866
867 // Replace all nowiki parts with unique keys..
868 oldText = replaceNowikis( oldText, nowikiKey, nowikiFragments );
869
870 // ..then apply the changes to the page text..
871 var newText = fn( oldText );
872 if ( newText === false ) {
873 return;
874 }
875
876 // ..and restore the nowiki parts back.
877 newText = restoreNowikis( newText, nowikiKey, nowikiFragments );
878
879 var postEditVars = {
880 action: 'edit',
881 title: page,
882 text: newText,
883 summary: summary,
884 token: token,
885 basetimestamp: timestamp,
886 format: 'json'
887 };
888
889 $.post(
890 mw.util.wikiScript( 'api' ),
891 postEditVars,
892 doneFn,
893 'json'
894 )
895 .error( function( xhr, text, error ) {
896 ajaxcat.showError( mw.msg( 'ajax-api-error', text, error ) );
897 });
898 } );
899 },
900 'json'
901 ).error( function( xhr, text, error ) {
902 ajaxcat.showError( mw.msg( 'ajax-api-error', text, error ) );
903 } );
904 },
905
906 /**
907 * This gets called by all action buttons
908 * Displays a dialog to confirm the action
909 * Afterwards do the actual edit.
910 *
911 * @param props {Object}:
912 * - modFn {Function} text-modifying function
913 * - dialogDescription {String} Changes done (HTML in the dialog)
914 * - editSummary {String} Changes done (text for edit summary)
915 * - doneFn {Function} callback after everything is done
916 * - $link {jQuery}
917 * - action
918 * @return {mw.ajaxCategories}
919 */
920 doConfirmEdit: function( props ) {
921 var summaryHolder, reasonBox, dialog, submitFunction,
922 buttons = {},
923 dialogOptions = {
924 AutoOpen: true,
925 buttons: buttons,
926 width: 450
927 },
928 ajaxcat = this;
929
930 // Check whether to use multiEdit mode:
931 if ( this.options.multiEdit && props.action !== 'all' ) {
932
933 // Stash away
934 props.$link
935 .data( 'stashIndex', this.stash.fns.length )
936 .data( 'summary', props.dialogDescription );
937
938 this.stash.dialogDescriptions.push( props.dialogDescription );
939 this.stash.editSummaries.push( props.editSummary );
940 this.stash.fns.push( props.modFn );
941
942 this.saveAllButton.show();
943 this.cancelAllButton.show();
944
945 // Clear input field after action
946 ajaxcat.addContainer.find( '.mw-addcategory-input' ).val( '' );
947
948 // This only does visual changes, fire done and return.
949 props.doneFn( true );
950 return this;
951 }
952
953 // Summary of the action to be taken
954 summaryHolder = $( '<p>' )
955 .html( '<strong>' + mw.msg( 'ajax-category-question' ) + '</strong><br/>' + props.dialogDescription );
956
957 // Reason textbox.
958 reasonBox = $( '<input type="text" size="45"></input>' )
959 .addClass( 'mw-ajax-confirm-reason' );
960
961 // Produce a confirmation dialog
962 dialog = $( '<div>' )
963 .addClass( 'mw-ajax-confirm-dialog' )
964 .attr( 'title', mw.msg( 'ajax-confirm-title' ) )
965 .append( summaryHolder )
966 .append( reasonBox );
967
968 // Submit button
969 submitFunction = function() {
970 ajaxcat.addProgressIndicator( dialog );
971 ajaxcat.doEdit(
972 mw.config.get( 'wgPageName' ),
973 props.modFn,
974 props.editSummary + ': ' + reasonBox.val(),
975 function() {
976 props.doneFn();
977
978 // Clear input field after successful edit
979 ajaxcat.addContainer.find( '.mw-addcategory-input' ).val( '' );
980
981 dialog.dialog( 'close' );
982 ajaxcat.removeProgressIndicator( dialog );
983 }
984 );
985 };
986
987 buttons[mw.msg( 'ajax-confirm-save' )] = submitFunction;
988
989 dialog.dialog( dialogOptions ).keyup( function( e ) {
990 // Close on enter
991 if ( e.keyCode === 13 ) {
992 submitFunction();
993 }
994 } );
995
996 return this;
997 },
998
999 /**
1000 * @param index {Number|jQuery} Stash index or jQuery object of stash item.
1001 * @return {mw.ajaxCategories}
1002 */
1003 removeStashItem: function( i ) {
1004 if ( typeof i !== 'number' ) {
1005 i = i.data( 'stashIndex' );
1006 }
1007
1008 try {
1009 delete this.stash.fns[i];
1010 delete this.stash.dialogDescriptions[i];
1011 } catch(e) {}
1012
1013 if ( $.isEmpty( this.stash.fns ) ) {
1014 this.stash.fns = [];
1015 this.stash.dialogDescriptions = [];
1016 this.stash.editSummaries = [];
1017 this.saveAllButton.hide();
1018 this.cancelAllButton.hide();
1019 }
1020 return this;
1021 },
1022
1023 /**
1024 * Reset all data from the category links and the stash.
1025 *
1026 * @param del {Boolean} Delete any category links with .mw-removed-category
1027 * @return {mw.ajaxCategories}
1028 */
1029 resetAll: function( del ) {
1030 var $links = this.options.$container.find( this.options.categoryLinkSelector ),
1031 $del = $([]),
1032 ajaxcat = this;
1033
1034 if ( del ) {
1035 $del = $links.filter( '.mw-removed-category' ).parent();
1036 }
1037
1038 $links.each( function() {
1039 ajaxcat.resetCatLink( $( this ), false, del );
1040 } );
1041
1042 $del.remove();
1043
1044 this.options.$container.find( '#mw-hidden-catlinks' ).remove();
1045
1046 return this;
1047 },
1048
1049 /**
1050 * Add hooks
1051 * Currently available: beforeAdd, beforeChange, beforeDelete,
1052 * afterAdd, afterChange, afterDelete
1053 * If the hook function returns false, all changes are aborted.
1054 *
1055 * @param string type Type of hook to add
1056 * @param function fn Hook function. The following vars are passed to it:
1057 * 1. oldtext: The wikitext before the hook
1058 * 2. category: The deleted, added, or changed category
1059 * 3. (only for beforeChange/afterChange): newcategory
1060 */
1061 addHook: function( type, fn ) {
1062 if ( !this.hooks[type] || !$.isFunction( fn ) ) {
1063 return;
1064 }
1065 else {
1066 this.hooks[type].push( fn );
1067 }
1068 },
1069
1070
1071 /**
1072 * Open a dismissable error dialog
1073 *
1074 * @param string str The error description
1075 */
1076 showError: function( str ) {
1077 var oldDialog = $( '.mw-ajax-confirm-dialog' ),
1078 buttons = {},
1079 dialogOptions = {
1080 buttons: buttons,
1081 AutoOpen: true,
1082 title: mw.msg( 'ajax-error-title' )
1083 };
1084
1085 this.removeProgressIndicator( oldDialog );
1086 oldDialog.dialog( 'close' );
1087
1088 var dialog = $( '<div>' ).text( str );
1089
1090 mw.util.$content.append( dialog );
1091
1092 buttons[mw.msg( 'ajax-confirm-ok' )] = function( e ) {
1093 dialog.dialog( 'close' );
1094 };
1095
1096 dialog.dialog( dialogOptions ).keyup( function( e ) {
1097 if ( e.keyCode === 13 ) {
1098 dialog.dialog( 'close' );
1099 }
1100 } );
1101 },
1102
1103 /**
1104 * @param oldtext
1105 * @param type
1106 * @param category
1107 * @param categoryNew
1108 * @return oldtext
1109 */
1110 runHooks: function( oldtext, type, category, categoryNew ) {
1111 // No hooks registered
1112 if ( !this.hooks[type] ) {
1113 return oldtext;
1114 } else {
1115 for ( var i = 0; i < this.hooks[type].length; i++ ) {
1116 oldtext = this.hooks[type][i]( oldtext, category, categoryNew );
1117 if ( oldtext === false ) {
1118 this.showError( mw.msg( 'ajax-category-hook-error', category ) );
1119 return;
1120 }
1121 }
1122 return oldtext;
1123 }
1124 }
1125 };
1126
1127 } )( jQuery );