MediaWiki:Gadget-DelReqHandler.js

Note: After saving, you have to bypass your browser's cache to see the changes. Internet Explorer: press Ctrl-F5, Mozilla: hold down Shift while clicking Reload (or press Ctrl-Shift-R), Opera/Konqueror: press F5, Safari: hold down Shift + Alt while clicking Reload, Chrome: hold down Shift while clicking Reload.
/**
@description: Support for quick deletions and closing of deletion requests at the Commons.
@author: [[User:Lupo]], October 2007 - January 2008
@author: [[User:DieBuche]], February 2011
@author: [[User:Rillke]], April 2012; jsHint-validation, outsourcing
@author: [[User:Perhelion]], 2016; performance tuning
@revision: 21:11, 11 August 2019 (UTC)
@license: Quadruple licensed GFDL, GPL, LGPL and Creative Commons Attribution 3.0 (CC-BY-3.0)
Choose whichever license of these you like best :-)
IE not supported
@required modules: user.options, mediawiki.util, jquery.blockUI, jquery.tipsy
* TODO: replacement for deprecated Tipsy
**/
// <nowiki>
/* global mediaWiki:false, jQuery:false, prompt:false, alert:false*/
/* jshint bitwise:true, curly:false, eqeqeq:true, forin:false, laxbreak:true */
/* eslint-env es5*/

(function ($, mw) {
'use strict';
// Guard against double inclusions // Enable the whole shebang only for sysops.
if (window.DelReqHandler || mw.config.get('wgUserGroups').indexOf('sysop') === -1) return;
// window.delReqGlobalUsage = 1;

var DRH = window.DelReqHandler = {
/* ------------------------------------------------------------------------------------------
Deletion request closing: add "[del]" and "[keep]" links to the left of the section edit
links of a deletion request. [del] and [keep] prompt for an (optional) reason, then
add "delh" and "delf" with "Deleted." or "Kept." plus the reason and signature (four tildes).

Links are added to every non-deleted image mentioned on a deletion request page. The "[del]" link
triggers deletion (auto-completed!) of the image, with a deletion summary linking to the
deletion request. If the image has a talk page, it is deleted as well. The "[keep]" link
automatically removes the "delete" template from the image page and adds the "kept" template
to the image talk page, both linking back to the deletion request.
Additional there is a quick delete link [qd] without any prompt.
------------------------------------------------------------------------------------------*/
	running: [], // for race event?

	titleFromHref: function (href) {
		href = decodeURI(href.getAttribute('href')); // only Wikilinks
		if (/^\/wiki\//.test(href)) // faster than indexOf
			return RegExp.rightContext || href.substring(6);
		return '';
	},

	spanFragC: $('<span class="navbar reqHandlerLinks2 mw-editsection-bracket"> [<a name="1" href="#">Close: Kept</a>] [<a href="#">Close: Deleted</a>]</span>')[0],
	spanFragA: $('<span class="navbar reqHandlerLinks2 mw-editsection-bracket"> [<a name="1" href="#" title="Mass handle only here selected">MASS process</a>]\
		<a href="#" class="new" style="display:none"><s>Del all</s></a></span>')[0],
	spanFragF: $('<span class="navbar reqHandlerLinks mw-editsection-bracket"> [<a name="1" href="#">keep</a>] [<a href="#" class="new">del</a>] \
	[<a href="#" onclick="DelReqHandler.quickDeleteFile(event);" title="QuickDelete" class="new">qd</a>]</span>')[0],

	quickDeleteFile: function (e) {
		e.preventDefault();
		e = e.target;
		// take the function from the adjacent del link
		$(e).prev().attr('title', e.title).trigger('click');
		return false;
	},

	nextUntilH3: function (cur) {
		var matched = [cur];
		cur = cur.nextElementSibling;
		while (cur && !(cur.nodeName === 'H3' || (cur.nodeName === 'DIV' && cur.className === 'delh'))) {
			matched.push(cur);
			cur = cur.nextElementSibling;
		}
		return matched;
	},

	parse: function () {
		var $content = $('#mw-content-text');
		if (!$content.length) return;
		if (window.delReqGlobalUsage && $.fn.badge) {
			this.spanFragF.appendChild(
				$('<a>', {
					'title': 'GlobalUsage',
					'onclick': 'DelReqHandler._onBadge(event)',
					'class': 'guGU'
				}).badge('?', 'inline', true).get(0));
		} else if (window.delReqGlobalUsage) {
			// module not ready yet, try once again
			return setTimeout(function () {
				DRH.parse();
				setTimeout(function () {
					window.delReqGlobalUsage = 0;
				}, 300);
			}, 200);
		}

		// var parent = $content.parent();
		// $content.detach(); // speedup DOM manipulation?
		var h3 = $content[0].getElementsByTagName('H3'),
			h = h3.length,
			linkReg = /Deletion_requests\/[^\n]*?&section=(T-)?\d$/;

		/*
		* Main DOM loop: use as less as possibly operations, especially omit jQuery,
		* as we could scan over 10.000 links.
		*/
		while (h--) {
			var th = h3[h],
				discussion = [],
				headLine = th.querySelector('span.mw-headline'),
				requestPage = th.querySelector('span.mw-editsection a');
			// For some reason, not all h3 have a link, e.q.: [[Commons:Deletion_requests/Files_in_Category:Liquor_bottles]]
			if (requestPage) requestPage = requestPage.getAttribute('href');
			// It’s really an editlink to a deletion request subpage, and not a section
			// edit for a daily subpage or something else
			if (!requestPage || !linkReg.test(requestPage)) continue;
			discussion = this.nextUntilH3(th); // .printfooter?
			if (th.parentNode.className !== 'delh')
				this.addLinks(requestPage, headLine, /* title*/ '', true, discussion);

			var links = [],
				d = 0,
				i = discussion.length;
			while (i--) {
				var al = discussion[i].getElementsByTagName('A'),
					l = al.length;
				while (l--) {
					var a = al[l];
					if (a.className !== 'new') {
						links[d] = a;
						d++;
					}
				}
			}
			i = links.length;
			// Probably last link is topic
			if (i > 16 && !/^File:/.test(this.titleFromHref(links[i - 1]))) { // We have a non image link
				this.addLinks(requestPage, links.pop(), '', false, discussion); // Add mass links
				i--;
			}

			while (i--) {
				var link = links[i],
					title = this.titleFromHref(link);
				if (/^File:/.test(title) && !/\//.test(title) && link.className !== 'internal') { // We have an image link
					this.addLinks(requestPage, link, title, false, discussion);
				}
			}
		}
		mw.util.addCSS(
			'.reqHandlerLinks a,.reqHandlerLinks2 a, input.reqHandlerBox {margin:0 .25em}\n\
			input.reqHandlerBox {vertical-align:middle}');
		// parent.append( $content );
	},

	/**
	* Adds links to each headline.
	*
	* @param	{string}		requestPage		The href property containing the URL.
	* @param	{HTMLElement}	element			The HTMLAnchorElement
	* @param	{string}		imagePage		If image href
	* @param	{boolean}		closeRequest	Keep/Del
	* @param	{NodeList}		discussion		The whole DR discussion section
	*/
	addLinks: function (requestPage, element, imagePage, closeRequest, discussion) {
		// jQuery is too slow here! // with vars tiny faster
		var frag = document.createDocumentFragment(),
			span = (closeRequest ? this.spanFragC : (imagePage ? this.spanFragF : this.spanFragA)).cloneNode(1),
			click = function (e) {
				e.preventDefault();
				// Use link.name for keep boolean // link.title for quick boolean
				e = new DRH.Process(e.target, closeRequest, requestPage, imagePage, element, span, discussion);
				DRH.running.push(e); // for race event?
			},
			lks = span.children;

		lks[0].onclick = click;
		lks[1].onclick = click;

		frag.appendChild(span);

		element.parentNode.insertBefore(frag, element.nextSibling);
	},

	Process: function (e, closeRequestBool, requestPage, imagePage, element, span, discussion) {
		// Merge the page processing functions into our new process
		$.extend(this, DRH.processHelpers);
		this.keep = e.name;
		var reason = this.keep ?
				['keep', window.keepReqReason || 'no valid reason for deletion'] :
				['delete', window.delReqReason || 'per nomination'],
			why = 'Why did you decide to %1 this file?';
		this.tasks = [];
		this.requestPage = this.titleFromTitle(requestPage);
		this.closeRequestBool = closeRequestBool;
		this.imagePage = decodeURIComponent(imagePage);
		this.summary = 'per [[' + this.requestPage + ']]';
		this.domElements = [$(element), $(span), $(discussion)];
		this.pageIDs = [];
		// getToken
		this.addTask('getPages');

		if (closeRequestBool) {
			this.reason = prompt(why.replace(/%1/, reason[0]), reason[1]);
			// User canceled
			if (!this.reason)
				return;
			this.pagesToGet = [this.requestPage];
			this.sectionCount = this.getSectionCount(requestPage);
			this.addTask('closeRequest');
		} else if (this.imagePage) {
			this.pagesToGet = [this.imagePage];
			this.redirect = this.domElements[0].hasClass('mw-redirect');
			if (this.keep) {
				this.addTask('markAsKept');
				this.addTask('getDate'); // runs addKeepToTalk
				this.summary = 'Kept ' + this.summary;
			} else {
				this.addTask('deleteFile');
				// this.addTask('nothing'); // ?
			}
			this.summary = (e.title === 'QuickDelete') ? this.summary : prompt('Summary:', this.summary);
			// User canceled
			if (!this.summary)
				return;
		} else {
			this.tasks.pop(); // remove normal getPages
			// Merge more functions into our new process
			$.extend(this, {
				setMassCheckBoxes: DRH.setMassCheckBoxes,
				processAll: DRH.processAll,
				processAllChunks: DRH.processAllChunks
			});
			return this.setMassCheckBoxes();
		}
		this.showProgress();
		this.addTask('fakeReload');
		this.nextTask();
	},

	setMassCheckBoxes: function () {
		var checkFrag = $('<input class="reqHandlerBox" type="checkbox" checked>')[0],
			$lks = this.domElements[1].children(),
			$lk2 = $lks.eq(1);
		// e.preventDefault();

		if ($lk2.is(':hidden')) {
			$lk2.after('] ');
			$lk2.before(' [');
			$lk2.show();
			$lks.eq(0).text('Keep all');
			this.domElements[1].css('background-color', '#FB9');
			// Get all page links from relevant discussion section
			$(this.domElements[2]).find('.reqHandlerLinks').each(function (a) {
				var li = this.parentNode;
				if (li.tagName === 'LI') {
					a = li.firstChild;
					if (a.tagName === 'A' && a.className !== 'new')
						li.insertBefore(checkFrag.cloneNode(), a);
				}
			});
			delete DRH.running[0];
		} else { this.processAll(); }
	// return false;
	},

	processAll: function () {
		var allPages = [],
			cSize = 50; // Max chunk size for API, bots 500
		this.chunkPagesToGet = []; // list of arrays

		if (this.keep)
			this.processTasks = ['markAsKept']; // 'getDate' add msg on talk on mass?
		else
			this.processTasks = ['deleteFile'];

		this.summary = prompt('Summary:', this.summary);
		if (!this.summary) {
			if (this.domElements[3]) this.domElements[3].unblock();
			return;
		}

		// :checkbox
		$(this.domElements[2]).find('input.reqHandlerBox:checked').each(function (a) {
			a = DRH.titleFromHref(this.nextSibling);
			if (a) allPages.push(a);
			this.parentNode.removeChild(this);
		});

		// this.redirect = 1;

		// Make chunks due the API limit
		for (var p = 0; p < allPages.length; p += cSize)
			this.chunkPagesToGet.push(allPages.slice(p, p + cSize));
		this.showProgress();
		this.addTask('processAllChunks');
		this.nextTask();
	},

	processAllChunks: function () {
		this.pagesToGet = this.chunkPagesToGet.pop();

		if (this.pagesToGet) {
			this.addTask('getPages');
			this.addTask(this.processTasks[0]); // currently only one
			// this.tasks.concat(this.processTasks);
			this.addTask('processAllChunks');
		} else { this.addTask('fakeReload'); }
		this.nextTask();
	},

	_onBadge: function (e) {
		var query = {},
			$gu = $(e.target).closest('a.guGU'),
			t = $gu.closest('span.reqHandlerLinks').prev('a');
		t = window.DelReqHandler.titleFromHref(t[0]);
		$gu[0].onclick = null;
		if (!t) return;
		t = decodeURIComponent(t).replace(/_/g, ' ');
		query[t] = $gu;
		$gu = mw.libs.GlobalUsage(5, 5);
		$gu.tipsyGravity = $('body').is('.rtl') ? 'sw' : 'se';
		$gu.query(query);
	},

	setup: function () {
		var title = mw.config.get('wgTitle');
		if (mw.config.get('wgNamespaceNumber') === 4 &&
			/^Deletion requests\/|\/Deletion requests$/.test(title) &&
			mw.config.get('wgAction') === 'view' &&
			document.URL.search(/[?&]oldid=/) === -1) {
			// We’re on COM:DEL or one of its daily subpages
			// Don’t do anything if we're not viewing the current version of the page
			var ext = ['user.options', 'mediawiki.util'];
			if (window.delReqGlobalUsage)
				ext.push('ext.gadget.jquery.badge');
			$.when(mw.loader.using(ext), $.ready).done(function () {
				DRH.parse();
				setTimeout(function () { // not needed at startup
					ext = ['ext.gadget.jquery.blockUI'];
					if (window.delReqGlobalUsage)
						ext = ext.concat(['ext.gadget.GlobalUsage', 'ext.gadget.tipsyDeprecated']);
					mw.loader.load(ext);
				}, 500);
			});
		}
	}
};

DRH.processHelpers = {
	titleFromTitle: function (title) {
		if (title) {
			title = mw.util.getParamValue('title', title);
			if (title)
				return title.replace(/_/g, ' ');
		}
		return '';
	},
	getSectionCount: function (title) {
		if (title) {
			title = mw.util.getParamValue('section', title);
			if (title) {
				title = parseInt(title.replace(/T-/g, ''));
				if (!isNaN(title))
					return title;
			}
		}
		return '';
	},
	getPages: function () {
		var query = {
			action: 'query',
			prop: 'revisions|info',
			rvprop: 'content|timestamp',
			// inprop: 'talkid', not needed if we only handle files
			titles: this.pagesToGet.join('|'),
			redirects: this.redirect,
			meta: 'tokens'
		};
		this.doAPICall(query, 'getPagesCallback');
	},
	getPagesCallback: function (result) {

		var pages = result.query.pages,
			task = this.tasks.shift();
		this.unknownResult = {};
		this.imagePageResult = {};
		this.requestPageResult = {};
		// The edittoken only changes between logins
		this.edittoken = result.query.tokens.csrftoken;
		for (var id in pages) { // there should be only one, but we don't know it's ID
			if (pages.hasOwnProperty(id)) {
				var page = pages[id];
				// FIXME better fail handling
				if (!page.revisions) continue;
				this.pageIDs.push(id); // For mulitple pages
				var type = 'unknown';
				switch (page.ns) {
					case 6:
						type = 'imagePage';
						// if (this.redirect) this.imagePage = page.title;
						break;
					case 4:
						type = 'requestPage';
						break;
				}
				this.tasks.unshift(task); // Add much as pages
				this[type + 'Result'][id] = {
					title: page.title,
					pageContent: page.revisions[0]['*'],
					starttimestamp: page.starttimestamp,
					timestamp: page.revisions[0].timestamp
				};
			}
		}
		this.nextTask();
	},

	closeRequest: function () {
		// (we always load the whole page)
		var text = this.requestPageResult[this.pageIDs.pop()].pageContent,
			watchFor = '<noinclude>[[Category:MobileUpload-related deletion requests',
			c = 0,
			hRegex = /^=+.+=+.*$/gm,
			sec = ']]</noinclude>';
		this.decision = this.keep ? 'Kept' : 'Deleted';
		text = text.replace(watchFor + sec, watchFor + '/' + this.decision.toLowerCase() + sec);
		// Multiple nominations
		if ((sec = this.sectionCount)) {
			while ((watchFor = hRegex.exec(text)) !== null) {
				c++;
				if (c === sec) {
					sec = watchFor.index;
					break;
				}
			}
			c = 0;
			if (watchFor[0]) {
				c = text.indexOf('{{delh}}\n', hRegex.lastIndex);
				if (c === -1) c = text.indexOf(watchFor[0], hRegex.lastIndex);
				if (c !== -1) {
					// closed section at end
					if (!(watchFor = text.slice(c))) c = 0;
				} else { c = 0; } // last section so skip to default
			}
		}
		if (!c) c = undefined;
		if (!sec && !c) // Check anyway for a second previous nomination
			sec = text.lastIndexOf('{{delf}}\n') + 9; // Additional more accurately: text.substr(sec).search(/^==+/m) but not really needed
		text = (sec > 51 || c) ? // minimum text-size
			text.slice(0, sec) + '{{delh}}\n' + text.slice(sec, c).trim() :
			'{{delh}}\n' + text.trim(); // the whole page
		text += '\n----\n';
		// Add dashes on 'lesser' individual signatures
		sec = (mw.user.options.get('fancysig') && mw.user.options.get('nickname').search(/^[ ']*\[\[/) !== 0) ?
			'' : '--';
		if (this.reason) {
			this.decision += ':';
			this.reason = this.reason.replace(/[.\s-]*$/, '. ');
		} else { this.decision += '.'; }

		text += '\'\'\'' + this.decision + '\'\'\' ' + this.reason + sec + '~~~~\n{{delf}}\n';

		if (c) text += watchFor;

		this.savePage({
			title: this.requestPage,
			text: text,
			summary: this.decision + ' ' + this.reason,
			editType: 'text'
		}, 'nextTask');
	},

	markAsKept: function () {
		var text = this.pageIDs.pop(); // id
		this.imagePage = this.imagePageResult[text].title;
		this.imageTalkPage = this.imagePage.replace(/^File:/, 'File_talk:');
		text = this.removeTemplate(this.imagePageResult[text].pageContent);
		if (text) {
			this.savePage({
				text: text,
				title: this.imagePage, // pageid: id,
				summary: this.summary,
				editType: 'text'
			}, 'nextTask');
		} else { this.nextTask(); }
	},

	removeTemplate: function (text) {
		var start = text.search(/\{\{[dD]elete/),
			level = 0,
			curr = start + 2,
			end = 0,
			opening = -1,
			closing = -1;
		if (start >= 0) {
			while (curr < text.length && !end) {
				opening = text.indexOf('{{', curr);
				closing = text.indexOf('}}', curr);
				if (opening >= 0 && opening < closing) {
					level++;
					curr = opening + 2;
				} else {
					if (closing < 0) {
						// No closing braces found
						curr = text.length;
					} else {
						if (level > 0)
							level--;
						else
							end = closing + 2;
						curr = closing + 2;
					}
				}
			}
			if (end) {
				// Also strip whitespace after the "delete" template
				end = text.substring(end).replace(/^\s+/, '');
				return start ? text.substring(0, start) + end : end;
			}
		}
		end = 'Couldn’t remove the {{delete}} template, please check the ' + this.imagePage + ' manually.';
		mw.log.warn(end);
		if (!this.processAllChunks) alert(end);
	},

	// Get start date of the DR
	getDate: function (c) {
		var query = {
			action: 'query',
			prop: 'revisions',
			titles: this.requestPage,
			// rvprop: 'comment|timestamp',
			rvlimit: 50
		};
		if (c)
			query.rvcontinue = c;
		this.doAPICall(query, 'addKeepToTalk');
	},

	addKeepToTalk: function (result) {
		var cont = result['continue']; // parse error on this line if not as bracket selector
		if (!result.hasOwnProperty('batchcomplete') && cont && cont.rvcontinue)
			cont = cont.rvcontinue;
		var date = '',
			pages = result.query.pages,
			rev = {},
			revLen;
		for (var id in pages) {
			// There should be only one, but we don't know it's ID
			if (pages.hasOwnProperty(id) && pages[id].revisions) {
				rev = pages[id].revisions;
				revLen = rev.length;
				for (var i = 0; i < revLen; i++) {
					if (rev[i].comment === 'Starting deletion request') {
						date = rev[i].timestamp;
						if (date)
							break;
					}
				}
			}
		}
		if (!date && cont) { this.getDate(cont); } else {
			if (!date) { // Fallback first edit if no appropriate comment?
				date = rev[revLen - 1].timestamp;
			}
			// Extract year, month, and day from the timestamp.
			date = date.substr(0, 4) + '-' + date.substr(5, 2) + '-' + date.substr(8, 2);
			this.savePage({
				title: this.imageTalkPage,
				text: '{{kept|' + date + '|' + this.requestPage + '}}\n',
				summary: 'Adding {{kept}}',
				editType: 'prependtext'
			}, 'nextTask');
		}
	},

	reload: function () {
		window.location.reload();
	},

	fakeReload: function () {
		var dE = this.domElements;
		if (dE[3]) dE[3].unblock(); // showProgress
		// Remove links with keep width for following links position
		dE[1].css('opacity', '0').find('a').removeAttr('href onclick title').css('cursor', 'default');
		if (this.closeRequestBool) {
			dE[3].toggleClass('delh delreqworking');
			dE[2].eq(0).before('<i>This deletion debate is now closed. Please do not make any edits to this archive.</i>');
			dE[2].eq(-1).after('<br><span class="success">Saved successfully.\
					<br>This is just an approximate rendering. Reload to see the actual request.</span>');
			dE[2].eq(-1).after('<b>' + this.decision + '</b> ' + this.reason + ' --' + mw.config.get('wgUserName'));
			dE[2].eq(-1).after('<hr>');
		} else {
			if (!this.keep)
				dE[0].addClass('new'); // Color link red
		}
	},

	/**
	* Simple task queue.  addTask() adds a new task to the queue, nextTask() executes
	* the next scheduled task.  Tasks are specified as method names to call.
	**/
	// list of pending tasks
	currentTask: '',
	// current task, for error reporting
	addTask: function (task) {
		this.tasks.push(task);
	},

	nextTask: function () {
		var task = this.currentTask = this.tasks.shift();
		try {
			this[task]();
		} catch (e) {
			this.fail(e);
		}
	},

	deleteFile: function () {
		var imagePage = this.imagePageResult[this.pageIDs.pop()].title;
		var edit = {
			action: 'delete',
			reason: this.summary,
			title: imagePage,
			recreate: ''
		};
		this.doAPICall(edit, 'nothing');
		edit = {
			action: 'delete',
			reason: 'Talk page of deleted image',
			title: imagePage.replace(/^File:/, 'File talk:'),
			recreate: ''
		};
		this.doAPICall(edit, 'nextTask', true);
	},

	savePage: function (page, callback) {
		var edit = {
			action: 'edit',
			summary: page.summary,
			notminor: 1,
			watchlist: window.AjaxDeleteWatchFile ? 'watch' : 'nochange',
			title: page.title
		};
		edit[page.editType] = page.text;
		this.doAPICall(edit, callback);
	},

	fail: function (e) {
		mw.notify(e, { title: 'DelReqHandler', type: 'error' });
	},

	doAPICall: function (params, callback, ignoreErrors) {
		var k = this;
		params.format = 'json';
		params.token = this.edittoken;
		$.ajax({
			url: mw.util.wikiScript('api'),
			cache: false,
			dataType: 'json',
			data: params,
			type: 'POST',
			success: function (result, status, x) {
				if (ignoreErrors) {
					k[callback](result);
					return;
				}
				if (!result)
					return k.fail('Receive empty API response:\n' + x.responseText);
				// In case we get the mysterious 231 unknown error, just try again
				if (result.error && result.error.info.indexOf('231') !== -1) {
					return setTimeout(function () {
						k.doAPICall(params, callback);
					}, 500);
				}
				if (result.error)
					return k.fail('API request failed (' + result.error.code + '): ' + result.error.info);
				k[callback](result);
			},
			error: function (x, status, error) {
				return k.fail('API request returned code ' + x.status + ' ' + status + 'Error code is ' + error);
			}
		});
	},

	showProgress: function () {
		var dE = this.domElements;
		if (this.closeRequestBool) {
			dE[2].wrapAll('<div class="delreqworking">');
			dE[3] = dE[2].parent('.delreqworking');
			dE[3].block({
				message: '<img src="https://upload.wikimedia.org/wikipedia/commons/3/39/Spinning_wheel_throbber_blue.gif"/> Closing request…',
				css: {
					border: '3px solid #9C3',
					fontSize: '135%'
				}
			});
		} else {
			dE[3] = dE[0].parent();
			dE[3].block({
				message: '<img src="https://upload.wikimedia.org/wikipedia/commons/f/f8/Ajax-loader%282%29.gif"/> Working…',
				css: {
					color: '#9C3',
					fontWeight: 'bold',
					background: 'none',
					border: 'none'
				}
			});
		}
	},
	nothing: function () {}
};

DRH.setup();
}(jQuery, mediaWiki));
// </nowiki> EOF