User:Rillke/MwJSBot.js

From Wikimedia Commons, the free media repository
Jump to navigation Jump to search
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.
// Usage:
// mw.loader.implement('mediawiki.commons.MwJSBot', ["//commons.wikimedia.org/w/index.php?action=raw&ctype=text/javascript&title=User:Rillke/MwJSBot.js"], {/*no styles*/}, {/*no messages*/});
// mw.loader.load('mediawiki.commons.MwJSBot');

/* global jQuery:false, mediaWiki:false, unescape:false, console:false, File, Blob, MwJSBot */
/* eslint indent:[error,tab,{outerIIFEBody:0}] */
/* jshint curly:false, bitwise:false, unused:false */

( function ( $, mw ) {
'use strict';

var myModuleName = 'mediawiki.commons.MwJSBot',
	isCommonsWiki = mw.config.get( 'wgDBname' ) === 'commonswiki';

function ContinuousAverage() {
	this.n = 0;
	this.avg = null;
	this.lastDateTime = Date.now();
}
ContinuousAverage.fn = ContinuousAverage.prototype;
$.extend( true, ContinuousAverage.fn, {
	push: function ( val ) {
		if ( this.avg === null ) {
			this.avg = val;
			this.n = 1;
		} else {
			this.avg = ( this.avg * this.n + val ) / ( ++this.n );
		}
	},
	pushTimeDiff: function ( now ) {
		now = now || Date.now();
		this.push( now - this.lastDateTime );
		this.lastDateTime = now;
	},
	getN: function () {
		return this.n;
	},
	getAvg: function () {
		return this.avg;
	}
} );

function firstItem( o ) {
	for ( var i in o ) {
		if ( o.hasOwnProperty( i ) ) {
			return o[ i ];
		}
	}
}

function encode_utf8( s ) {
	return unescape( encodeURIComponent( s ) );
}

var jsb = function () {},
	clnt = $.client.profile(),
	APIURL = mw.util.wikiScript( 'api' ),
	MPB = '----------' + myModuleName + Math.random();

jsb.fn = jsb.prototype;

$.extend( true, jsb.fn, {
	$downloadRawFile: function ( url ) {
		return $.ajax( {
			url: url,
			beforeSend: function ( xhr ) {
				xhr.overrideMimeType( 'text/plain; charset=x-user-defined' );
			},
			dataFilter: function ( d/* , dataType*/ ) {
				// Some more sophisticated stuff avoiding killing performance with memory allocations and thousands of function invocations
				// https://developer.mozilla.org/en-US/docs/JavaScript/Typed_arrays would be perhaps also valuable
				var f = '',
					len = d.length,
					buff = 1018, // You can't apply huge arrays to functions!
					arrCC = new Array( Math.min( buff, len ) ),
					arrF = new Array( Math.ceil( len / buff ) );

				// Remove junk-high-order-bytes
				for ( var i = 0, j = 0, z = 0; i < len; i++ ) {
					arrCC[ j ] = ( d.charCodeAt( i ) & 0xff );
					j++;
					if ( ( j % buff ) === 0 ) {
						// Convert char codes to chars
						arrF[ z ] = String.fromCharCode.apply( null, arrCC );
						// Empty the char code array
						arrCC = new Array( Math.min( buff, len - i - 1 ) );
						z++;
						j = 0;
					}
				}
				if ( j !== 0 ) { arrF[ z + 1 ] = String.fromCharCode.apply( null, arrCC ); }
				f = arrF.join( '' );
				return f;
			}
		} );
	},
	$downloadXMLFile: function ( url ) {
		return $.get( url, 'xml' );
	},
	// NEVER use this twice to send something. Instead, create a new message!
	// TODO allow/review re-sending same message again
	multipartMessageForBinaryFiles: function () {
		// Body parts that must be considered to end with line breaks, therefore, should have two CRLFs preceding the encapsulation line,
		// the first of which is part of the preceding body part, and the second of which is part of the encapsulation boundary
		var pendingParts = 0,
			tokenPart = '',
			useStuckWatcher = true,
			msgParts,
			createPart,
			appendPart,
			send,
			formData;

		if ( clnt.name === 'firefox' && clnt.versionNumber < 22 ) {
			msgParts = [];
			createPart = function ( param, value, ContentType, ContentTransferEncoding, ContentDisposition ) {
				var p = '--' + MPB + '\r\n';
				if ( !ContentDisposition ) {
					p += 'Content-Disposition: form-data; name=\"' + param + '\"\r\n';
				} else {
					p += 'Content-Disposition: ' + ContentDisposition + '\r\n';
				}
				p += 'Content-Type: ' + ContentType + '\r\n';
				p += 'Content-Transfer-Encoding: ' + ContentTransferEncoding + '\r\n';
				return [ p, '\r\n', value, '\r\n' ].join( '' );
			};
			appendPart = function ( param, value, filename, MIME ) {
				var ContentType, ContentTransferEncoding, ContentDisposition, doPush = function ( value ) {
					msgParts.push( createPart( param, value, ContentType, ContentTransferEncoding, ContentDisposition ) );
				};

				if ( filename ) {
					ContentType = MIME || 'application/octet-stream';
					ContentTransferEncoding = 'binary';
					ContentDisposition = 'attachment; name=\"' + param + '\"; filename=\"' + encode_utf8( filename.replace( /\"/g, '-' ) ) + '\"';
					if ( value instanceof Blob || value instanceof File ) {
						pendingParts++;
						var reader = new FileReader();
						reader.onload = function () {
							value = reader.result;
							doPush( value );
							if ( tokenPart ) { msgParts.push( tokenPart ); }
							pendingParts--;
							if ( pendingParts === 0 && typeof send === 'function' ) { send(); }
						};
						reader.readAsBinaryString( value );
						return;
					}
				} else {
					value = encode_utf8( value );
					ContentType = 'text/plain; charset=UTF-8';
					ContentTransferEncoding = '8bit';
					if ( param === 'token' && pendingParts ) {
						// Ensure token last (This is done to catch connection errors. This way we can circumvent calculating MD5)
						tokenPart = createPart( param, value, ContentType, ContentTransferEncoding, ContentDisposition );
						return;
					}
				}
				doPush( value );
			};
		} else {
			formData = new FormData();
		}

		var msg = {
			appendPart: function ( param, value, filename, MIME ) {
				if ( msgParts ) {
					appendPart.apply( window, Array.prototype.slice.call( arguments, 0 ) );
				} else {
					if ( filename ) {
						var bl;
						if ( value instanceof Blob || value instanceof File ) {
							bl = value;
						} else {
							bl = new Blob( [ value ], { type: MIME || 'application/octet-stream' } );
						}
						formData.append( param, bl, filename );
					} else {
						formData.append( param, value );
					}
				}
				// allow something like new MwJSBot().multipartMessage().appendPart("param1", "value").appendPart("param2", "value").$send();
				return msg;
			},
			noStuckWatcher: function () {
				useStuckWatcher = false;
				return msg;
			},
			$send: function ( url, responseType ) {
				var $def = $.Deferred();

				send = function () {
					var req = new XMLHttpRequest(),
						ca = new ContinuousAverage(),
						lastProgressEvt, intervalId, killProgressWatchers, progressWatcher;

					// Browsers sometimes have the attitude not to continue uploading
					// upon network interruptions but they send new requests correctly
					killProgressWatchers = function () {
						clearInterval( intervalId );
					};
					progressWatcher = function () {
						var now = Date.now(),
							diff = now - lastProgressEvt;

						if ( ca.getN() > 10 && diff > ca.getAvg() * 5 && diff > 7000 ) {
							$def.notify( 'stuck', req );
						}
					};
					req.onreadystatechange = function () {
						if ( req.readyState !== 4 ) { return; }
						if ( req.status === 200 ) {
							// TODO: Pass more args
							$def.resolve( req.statusText, req.response );
							killProgressWatchers();
						} else {
							$def.reject( req.statusText, req.response, req );
							killProgressWatchers();
						}
					};
					req.onerror = function () {
						setTimeout( function () {
							$def.reject( req.statusText, req.response, req );
							killProgressWatchers();
						}, 100 );
					};
					req.onabort = function () {
						setTimeout( function () {
							$def.reject( req.statusText, req.response, req );
							killProgressWatchers();
						}, 100 );
					};
					// Can we monitor upload status?
					if ( req.upload ) {
						req.upload.onprogress = function ( e ) {
							// Ensure compatible event
							if ( !e.loaded || !e.total ) { return; }
							$def.notify( 'uploadstatus', e );
							lastProgressEvt = Date.now();
							ca.pushTimeDiff( lastProgressEvt );
						};
					}
					req.open( 'POST', url || APIURL );
					if ( responseType ) {
						req.responseType = responseType;
					}
					if ( useStuckWatcher ) {
						intervalId = setInterval( progressWatcher, 5000 );
					}
					if ( msgParts ) {
						req.setRequestHeader( 'Content-Type', 'multipart/form-data; charset=UTF-8; boundary=' + MPB );
						msgParts.push( '--', MPB, '--', '\r\n' );
						req.sendAsBinary( msgParts.join( '' ) );
					} else {
						req.send( formData );
					}
				};
				if ( pendingParts === 0 ) {
					send();
				}

				return $def;
			}
		};
		return msg;
	},
	multipartMessageForUTF8Files: function () {
		var msgParts,
			createPart,
			appendPart;

		msgParts = [];
		createPart = function ( param, value, ContentType, ContentTransferEncoding, ContentDisposition ) {
			var p = '--' + MPB + '\n';
			if ( !ContentDisposition ) {
				p += 'Content-Disposition: form-data; name=\"' + param + '\"\n';
			} else {
				p += 'Content-Disposition: ' + ContentDisposition + '\n';
			}
			p += 'Content-Type: ' + ContentType + '\n';
			p += 'Content-Transfer-Encoding: ' + ContentTransferEncoding + '\n';
			return [ p, '\n', value, '\n' ].join( '' );
		};
		appendPart = function ( param, value, filename ) {
			var ContentType, ContentTransferEncoding, ContentDisposition;
			if ( filename ) {
				ContentType = 'application/octet-stream';
				ContentTransferEncoding = '8bit';
				ContentDisposition = 'attachment; name=\"' + param + '\"; filename=\"' + filename.replace( /\"/g, '-' ) + '\"';
			} else {
				ContentType = 'text/plain; charset=UTF-8';
				ContentTransferEncoding = '8bit';
			}
			msgParts.push( createPart( param, value, ContentType, ContentTransferEncoding, ContentDisposition ) );
		};

		var msg = {
			appendPart: function ( /* param, value, filename*/ ) {
				appendPart.apply( window, Array.prototype.slice.call( arguments, 0 ) );
				// allow something like new MwJSBot().multipartMessage().appendPart("param1", "value").appendPart("param2", "value").$send();
				return msg;
			},
			$send: function ( url, responseType ) {
				var $def = $.Deferred(),

					req = new XMLHttpRequest();
				req.onreadystatechange = function () {
					if ( req.readyState !== 4 ) { return; }
					if ( req.status === 200 ) {
						// TODO: Pass more args
						$def.resolve( req.statusText, req.response );
					} else {
						$def.reject( req.response );
					}
				};
				req.open( 'POST', url || APIURL );
				if ( responseType ) {
					req.responseType = responseType;
				}
				req.setRequestHeader( 'Content-Type', 'multipart/form-data; charset=UTF-8; boundary=' + MPB );
				msgParts.push( '--', MPB, '--', '\n' );
				req.send( msgParts.join( '' ) );
				return $def;
			}
		};
		return msg;
	},
	refreshToken: function ( type, cb ) {
		var j = this;
		mw.loader.using( [ 'ext.gadget.libAPI', 'mediawiki.user' ], function () {
			/* FIXME: This is causing an error: Error: api.query is for queries only. For editing use the stable Commons edit-api. */
			return;
			mw.libs.commons.api.query( {
				action: 'tokens',
				type: type
			}, {
				method: 'POST',
				cache: false,
				cb: function ( r ) {
					$.each( r.tokens, function ( type, v ) {
						type = type.replace( 'token', 'Token' );
						mw.user.tokens.set( type, v );
					} );
					cb();
				},
				// r-result, query, text
				errCb: function ( /* t, r, q */ ) {
					j.fail( 'Failed to refresh token.' );
				}
			} );
		} );
	},
	chunkedUploadDefault: {
		maxChunkSize: 0.5 * 1024 * 1024, // 2MB
		retry: {
			emptyResponse: 9,
			serverError: 9,
			apiErrors: 6,
			offsetError: 2
		},
		file: null,
		title: '',
		summary: '',
		useStash: true,
		async: true,
		callbacks: {
			nameNeedsChange: function () {},
			ignorewarningsRequired: function () {},
			loginRequired: function () {}
		},
		passToAPI: {
			upload: {
				// e.g. ignorewarnings: 1
				tags: isCommonsWiki ? 'rillke-mw-js-bot' : ''
			},
			finish: {
				tags: isCommonsWiki ? 'rillke-mw-js-bot' : ''
			}
		}
	},
	uploadWarnings: {
		filename: [ 'exists', 'page-exists', 'was-deleted', 'exists-normalized', 'thumb', 'thumb-name', 'bad-prefix', 'badfilename' ],
		other: [ 'duplicate-archive', 'duplicate', 'large-file', 'emptyfile', 'filetype-unwanted-type' ]
	},
	chunkedUpload: function ( p, file ) {
		var j = this,
			$def = $.Deferred(),
			filekey = '',
			size = 0,
			remaining = 0,
			chunkSize = 0,
			offset = 0,
			offsetid = 0,
			addToNextChunk = 0,
			stuckCounter = 0,
			chunkinfo = [],
			waitingFinish,
			startTime,
			waitTime,
			$stuckXhr;

		p = $.extend( true, {}, j.chunkedUploadDefault, p );
		if ( !file || !p.title ) {
			return j.fail( 'chunked upload> Either no file or no title specified.' );
		}
		if ( !( window.File && window.File.prototype.slice && window.FileReader && window.Blob ) ) {
			return j.fail( 'chunked upload> Your browser does not support the full File-API, FileReader and Blob.' );
		}

		var internal = {
			status: 'uploading',
			uploadAPI: function ( params ) {
				params = $.extend( {
					format: 'json',
					action: 'upload',
					filekey: filekey,
					token: mw.user.tokens.get( 'csrfToken' )
				}, params );
				return $.post( APIURL, params );
			},
			nextChunk: function () {
				chunkSize = Math.min( remaining, p.maxChunkSize + addToNextChunk );
				var blob = file.slice( offset, offset + chunkSize ),
					mpm = j.multipartMessageForBinaryFiles();

				addToNextChunk = 0;

				// Notify everyone who likes to know how the situation is progressing
				chunkinfo.currentchunk = chunkinfo[ offsetid ];
				$def.notify( 'prog', chunkinfo );

				mpm.appendPart( 'format', 'json' );
				mpm.appendPart( 'action', 'upload' );
				mpm.appendPart( 'filename', p.title );
				if ( filekey ) { mpm.appendPart( 'filekey', filekey ); }
				if ( p.useStash ) { mpm.appendPart( 'stash', 1 ); }
				mpm.appendPart( 'filesize', size );
				mpm.appendPart( 'offset', offset );
				if ( p.async ) { mpm.appendPart( 'async', 1 ); }
				mpm.appendPart( 'chunk', blob, p.title, file.type );
				mpm.appendPart( 'token', mw.user.tokens.get( 'csrfToken' ) );
				$.each( p.passToAPI.upload, function ( k, v ) {
					mpm.appendPart( k, v );
				} );
				if ( !p.async ) { mpm.noStuckWatcher(); }
				startTime = Date.now();
				waitTime = 4000;
				mpm.$send().done( internal.chunkUploaded ).fail( internal.chunkFailed ).progress( internal.chunkUploadProgess );
			},
			chunkStuck: function ( xhr ) {
				if ( $stuckXhr ) {
					$stuckXhr.abort();
				}
				$stuckXhr = $.getJSON( APIURL, {
					format: 'json',
					action: 'tokens'
				} ).done( function () {
					// Connection Ok ... we can try to fix this
					if ( ++stuckCounter > 10 ) {
						stuckCounter = 0;
						chunkinfo.currentchunk.progressText = 'Connection seems to be okay. Re-sending this request.';
						p.retry.serverError += 0.8;
						xhr.abort();
					} else {
						chunkinfo.currentchunk.progressText = 'Connection seems to be okay. Waiting one more time...';
					}
					$def.notify( 'stuckok', chunkinfo );
				} ).fail( function ( jqXHR, textStatus, errorThrown ) {
					// Connection broken ... user or server admins have to fix this
					chunkinfo.currentchunk.progressText = 'Please check your connection! Error: ' + textStatus + ' | ' + errorThrown;
					$def.notify( 'stuckbroken', chunkinfo );
				} );
			},
			chunkUploadProgess: function ( type, e ) {
				switch ( type ) {
				case 'uploadstatus':
					stuckCounter = 0;
					chunkinfo.currentchunk.progress = 100 * e.loaded / e.total;
					chunkinfo.currentchunk.progressText = 'upload in progress';
					$def.notify( type, chunkinfo );
					break;
				case 'stuck':
					chunkinfo.currentchunk.progressText = 'upload is stuck';
					$def.notify( type, chunkinfo );
					internal.chunkStuck( e );
					break;
				}
			},
			chunkUploaded: function ( status, r ) {
				stuckCounter = 0;
				r = JSON.parse( r );
				var txt, args, defaultError;

				defaultError = function ( args ) {
					args = Array.prototype.slice.call( args, 0 );
					args.unshift( r.error.code + ': ' + r.error.info );
					return internal.fail.apply( internal, args );
				};

				if ( r && r.error ) {
					switch ( r.error.code ) {
					// r.error.info
					case 'badtoken':
						j.refreshToken( 'edit', $.proxy( internal.nextChunk, internal ) );
						break;
					case 'stasherror':
						if ( r.error.info.indexOf( 'UploadStashNotLoggedInException' ) === -1 ) {
							return defaultError( arguments );
						}
						/* falls through */
					case 'readapidenied':
					case 'writeapidenied':
					case 'invalid-file-key':
					case 'mustbeloggedin':
					case 'permissiondenied':
					case 'internal_api_error_UploadStashNotLoggedInException':
					case 'stashnotloggedin':
						p.callbacks.loginRequired( r.error.code + ': ' + r.error.info, function () {
							j.refreshToken( 'edit', $.proxy( internal.nextChunk, internal ) );
						} );
						break;
					case 'stashfailed':
					case 'offseterror':
					case 'offsetmismatch':
						if ( --p.retry.apiErrors < 0 ) {
							return defaultError( arguments );
						}
						if ( r.error.offset && Number( r.error.offset ) !== offset ) {
							return internal.offsetmismatch( arguments, Number( r.error.offset ) );
						}
						internal.nextChunk();
						break;
					default:
						return defaultError( arguments );
					}
					return;
				}
				if ( !r || !r.upload ) {
					// Simply retry when getting an empty response
					txt = 'Empty response';
					if ( --p.retry.emptyResponse < 0 ) {
						args = Array.prototype.slice.call( arguments, 0 );
						args.unshift( txt );
						return internal.fail.apply( internal, args );
					}
					$def.notify( 'err', chunkinfo, txt );
					internal.nextChunk();
				}
				if ( r.upload.filekey ) { filekey = r.upload.filekey; }

				var _successfullytransmitted = function () {
					chunkinfo.currentchunk.progress = 100;
					chunkinfo.currentchunk.progressText = 'Chunk uploaded';
					$def.notify( 'prog', chunkinfo );
					offset += chunkSize;
					remaining -= chunkSize;
					offsetid++;
				};

				if ( r.upload.result === 'Warning' ) {
					var fileNameRelated = true,
						warnings = [],
						__insertNewParams = function ( newparams ) {
							p = $.extend( p, newparams );
							if ( waitingFinish ) {
								internal.finish();
							}
						};

					$.each( r.upload.warnings, function ( k, v ) {
						warnings.push( k + ': \"' + v + '\"' );
						fileNameRelated = fileNameRelated && $.inArray( k, j.uploadWarnings.filename ) > -1;
						return fileNameRelated;
					} );
					warnings = warnings.join( ', ' );
					if ( fileNameRelated ) {
						p.callbacks.nameNeedsChange( warnings, __insertNewParams );
					} else {
						p.callbacks.ignorewarningsRequired( warnings, __insertNewParams );
					}
					if ( remaining === 0 ) {
						waitingFinish = true;
					} else {
						// Simply continue with ignorewarnings flag
						p.passToAPI.upload.ignorewarnings = 1;
						internal.nextChunk();
					}
					return;
				}
				if ( r.upload.result === 'Continue' && remaining !== 0 ) {
					var offsetRequested = Number( r.upload.offset ),
						offsetCalculated = offset + chunkSize,
						diff = offsetCalculated - offsetRequested;

					if ( offsetRequested === offsetCalculated ) {
						_successfullytransmitted();
						internal.nextChunk();
					} else if ( offsetRequested < offsetCalculated ) {
						$def.notify( 'warn', chunkinfo, 'Offset requested by API is lower than offset calculated. \r\n issue?' );
						// Correct current chunk size
						chunkSize -= diff;
						addToNextChunk = diff;
						_successfullytransmitted();
						internal.nextChunk();
					} else {
						// Error handling: Simply upload last chunk again
						txt = 'Offset error: API wants to continue at ' + r.upload.offset + ' but calculated offset is ' + ( offset + chunkSize );
						if ( --p.retry.offsetError < 0 ) {
							args = Array.prototype.slice.call( arguments, 0 );
							args.unshift( txt );
							return internal.fail.apply( internal, args );
						}
						$def.notify( 'err', chunkinfo, txt );
						internal.nextChunk();
					}
					return;
				}
				_successfullytransmitted();
				if ( r.upload.result === 'Success' && remaining === 0 ) {
					chunkinfo.currentchunk = chunkinfo.finalize;
					internal.finish();
					return;
				}
				if ( r.upload.result === 'Poll' ) {
					if ( remaining === 0 ) {
						chunkinfo.currentchunk = chunkinfo.finalize;
						internal.status = 'rebuilding';

						chunkinfo.finalize.progress = 1;
						chunkinfo.finalize.progressText = 'Assembling chunks';
						$def.notify( 'prog', chunkinfo, txt );
					}
					setTimeout( internal.poll, 2000 );
					return;
				}
			},
			offsetmismatch: function ( args, offsetExpectedByServer ) {
				var txt,
					_successfullytransmitted = function ( newOffset, newRemaining, newOffsetid ) {
						chunkinfo.currentchunk.progress = 100;
						chunkinfo.currentchunk.progressText = 'Chunk uploaded';
						$def.notify( 'prog', chunkinfo );
						offset = newOffset;
						remaining = newRemaining;
						offsetid = newOffsetid;
					};

				$def.notify( 'warn', chunkinfo, 'Offset issue by Server detected. Attempting to fix automatically.' );
				if ( offsetExpectedByServer === offset + chunkSize ) {
					txt = "Looks like this chunk was successfully transmitted but didn't receive a success message for it." +
						'Please have a look at the file after uploading.';
					$def.notify( 'warn', chunkinfo, txt );
					_successfullytransmitted(
						offsetExpectedByServer,
						remaining - chunkSize,
						offsetid + 1
					);
					p.retry.offsetError += 0.5;
				} else if ( Math.abs( offsetExpectedByServer - offset ) <= chunkSize ) {
					txt = 'The offset requested by the server differs by one or less than one chunk size from client side calculations.\r\n' +
						'Going to return what is requested but please have a close look at the file after uploading.\r\n' +
						'Offset expected by server: ' + offsetExpectedByServer + '. Offset calculated by client: ' + offset;
					$def.notify( 'warn', chunkinfo, txt );
					_successfullytransmitted(
						offsetExpectedByServer,
						size - offsetExpectedByServer,
						offsetExpectedByServer > offset ? offsetid + 1 : offsetid
					);
				} else {
					txt = 'The server expects continuation at byte ' + offsetExpectedByServer + '.\r\n' +
						'However, to the client side calculated offset is ' + offset + '.\r\n';
					$def.notify( 'warn', chunkinfo, txt );
				}
				if ( --p.retry.offsetError < 0 ) {
					args = Array.prototype.slice.call( args, 0 );
					args.unshift( txt );
					return internal.fail.apply( internal, args );
				}
				internal.nextChunk();
			},
			/**
			* status checker
			**/
			poll: function () {
				var txt,
					args,
					cb,
					word;

				switch ( internal.status ) {
				case 'uploading':
					word = 'process chunk';
					cb = function ( r ) {
						internal.chunkUploaded( 'OK', r );
						return true;
					};
					break;
				case 'rebuilding':
					word = 'rebuild uploaded file';
					cb = function ( r ) {
						return ( r.upload.stage === 'queued' ) ? false : ( internal.finish() || true );
					};
					break;
				case 'publishing':
					word = 'publish uploaded file';
					cb = function ( r ) {
						internal.finished( r );
						return true;
					};
					break;
				}
				internal.uploadAPI( {
					checkstatus: 1
				} ).done( function ( r ) {
					if ( r && r.error ) {
						switch ( r.error.code ) {
						// r.error.info
						case 'badtoken':
							j.refreshToken( 'edit', $.proxy( internal.poll, internal ) );
							break;
						case 'readapidenied':
						case 'writeapidenied':
						case 'invalid-file-key':
						case 'mustbeloggedin':
						case 'permissiondenied':
						case 'internal_api_error_UploadStashNotLoggedInException':
							p.callbacks.loginRequired( r.error.code + ': ' + r.error.info, function () {
								j.refreshToken( 'edit', $.proxy( internal.poll, internal ) );
							} );
							break;
						default:
							args = Array.prototype.slice.call( arguments, 0 );
							args.unshift( r.error.code + ': ' + r.error.info );
							return internal.fail.apply( internal, args );
						}
						return;
					}
					if ( !r || !r.upload ) {
						txt = 'Empty response: Still waiting for server to ' + word;
						if ( --p.retry.emptyResponse < 0 ) {
							args = Array.prototype.slice.call( arguments, 0 );
							args.unshift( txt );
							return internal.fail.apply( internal, args );
						}
						setTimeout( internal.poll, 7000 );
						$def.notify( 'err', chunkinfo, txt );
						return j.log( txt, r );
					}
					if ( r.upload.filekey ) { filekey = r.upload.filekey; }
					// If the operation succeeded, the callback is called (which returns true for most cases)
					// otherwise the callback is not called in JavaScript because the expression would evaluate to
					// false anyway
					if ( !( ( r.upload.result === 'Success' || r.upload.result === 'Continue' ) && cb( r ) ) ) {
						txt = 'Still waiting for server to ' + word;
						$def.notify( 'prog', chunkinfo, txt );
						j.log( txt, r );
						setTimeout( internal.poll, 4500 );
					}
				} ).fail( function ( jqXHR, textStatus, errorThrown ) {
					// TODO: Server status etc.
					// Simply re-query
					txt = 'Sever-Error ' + jqXHR.status + '. Reason: ' + textStatus + ' ' + errorThrown + ' ... Still waiting for server to ' + word;
					if ( --p.retry.serverError < 0 ) {
						args = Array.prototype.slice.call( arguments, 0 );
						args.unshift( txt );
						return internal.fail.apply( internal, args );
					}
					setTimeout( internal.poll, 7000 );
					$def.notify( 'err', chunkinfo, txt );
					j.log( txt );
				} );
			},
			finish: function () {
				internal.status = 'publishing';
				chunkinfo.finalize.progress = 10;
				chunkinfo.finalize.progressText = 'Finishing';

				$def.notify( 'prog', chunkinfo );
				j.log( 'chunked upload> Finishing.' );
				var params = {
					filename: p.title,
					filesize: size,
					comment: p.summary,
					text: p.text
				};
				if ( p.async ) { params.async = 1; }
				$.each( p.passToAPI.finish, function ( k, v ) {
					params[ k ] = v;
				} );
				internal.uploadAPI( params ).done( internal.possiblyFinished ).fail( internal.finishFailed );
			},
			possiblyFinished: function ( r ) {
				if ( r && r.error ) {
					switch ( r.error.code ) {
					// r.error.info
					case 'badtoken':
						j.refreshToken( 'edit', $.proxy( internal.finish, internal ) );
						break;
					case 'readapidenied':
					case 'writeapidenied':
					case 'invalid-file-key':
					case 'mustbeloggedin':
					case 'permissiondenied':
					case 'internal_api_error_UploadStashNotLoggedInException':
						p.callbacks.loginRequired( r.error.code + ': ' + r.error.info, function () {
							j.refreshToken( 'edit', $.proxy( internal.finish, internal ) );
						} );
						break;
					default:
						var args = Array.prototype.slice.call( arguments, 0 );
						args.unshift( r.error.code + ': ' + r.error.info );
						return internal.fail.apply( internal, args );
					}
					return;
				}

				if ( !r || !r.upload ) { return internal.finishFailed( 'empty response received' ); }
				if ( r.upload.filekey ) { filekey = r.upload.filekey; }

				switch ( r.upload.result ) {
				case 'Poll':
					j.log( 'Waiting for upload to be published' );
					chunkinfo.finalize.progress = 50;
					chunkinfo.finalize.progressText = 'Waiting for upload to be published';

					$def.notify( 'prog', chunkinfo );
					setTimeout( internal.poll, 2000 );
					break;
				case 'Success':
					internal.finished( r );
					break;
				}
			},
			finished: function ( r ) {
				j.log( 'Uploaded successfully' );
				chunkinfo.finalize.progress = 100;
				chunkinfo.finalize.progressText = 'Uploaded successfully';

				$def.notify( 'prog', chunkinfo );
				$def.resolve( chunkinfo, r );
			},
			chunkFailed: function ( statusText, response, xhr ) {
				stuckCounter = 0;
				var txt = 'Server error ' + xhr.status + ' after uploading chunk: ' + statusText + '\nResponse: ' + response.slice( 0, 500 ).replace( /\n\n/g, '\n' );

				if ( --p.retry.serverError < 0 ) {
					var args = Array.prototype.slice.call( arguments, 0 );
					args.shift( txt );
					return internal.fail.apply( internal, args );
				}
				$def.notify( 'err', chunkinfo, txt );
				if ( startTime + 750 > Date.now() ) {
					setTimeout( function () {
						internal.nextChunk();
					}, waitTime *= 2 );
				} else {
					internal.nextChunk();
				}
			},
			finishFailed: function ( reasonOrXHR, textStatus, errorThrown ) {
				if ( typeof reasonOrXHR === 'object' ) { reasonOrXHR = textStatus + ' ' + errorThrown; }

				var txt = 'Server error while publishing upload. Reason: ' + reasonOrXHR;
				if ( --p.retry.serverError < 0 ) {
					var args = Array.prototype.slice.call( arguments, 0 );
					args.unshift( txt );
					return internal.fail.apply( internal, args );
				}
				$def.notify( 'err', chunkinfo, txt );
				internal.finish();
			},
			fail: function () {
				var args = Array.prototype.slice.call( arguments, 0 );
				j.fail.apply( j, args );
				$def.reject.apply( $def, args );
			}
		};

		// Get some statistics about the file
		size = remaining = file.size;
		var remains4loop = size,
			chunksize4loop,
			i = 0;

		while ( remains4loop > 0 ) {
			chunksize4loop = Math.min( remaining, p.maxChunkSize );
			chunkinfo[ i ] = {
				chunksize: chunksize4loop,
				remaining: remains4loop,
				progress: 0,
				progressText: 'in progress',
				id: i
			};
			remains4loop -= chunksize4loop;
			i++;
		}
		chunkinfo.finalize = {
			progress: 0,
			progressText: '',
			id: 'finalize'
		};
		internal.nextChunk();
		$def.chunkinfo = chunkinfo;

		return $def;
	},
	createSampleUploadButton: function () {
		var j = this;
		$( '<input type="file" id="files" name="file">' ).prependTo( mw.util.$content ).on( 'change', function ( /* e */ ) {
			j.chunkedUpload( {
				title: 'A random title.png'
			}, this.files[ 0 ] ).progress( function () {
				console.log( 'prog', arguments );
			} ).done( function () {
				console.log( 'done', arguments );
			} );
		} );
	},
	$loadContinue: function ( title ) {
		var $def = $.Deferred(),
			j = this;
		mw.loader.using( [ 'ext.gadget.libAPI' ], function () {
			mw.libs.commons.api.query( {
				action: 'query',
				prop: 'revisions',
				rvprop: 'content',
				rvlimit: 1,
				titles: title
			}, {
				method: 'POST',
				cache: false,
				cb: function ( r ) {
					try {
						$def.resolve( firstItem( r.query.pages ).revisions[ 0 ][ '*' ] );
					} catch ( ex ) {
						$def.reject( ex );
					}
				},
				// r-result, query, text
				errCb: function ( t, r/* , q*/ ) {
					j.fail( 'Failed to retrieve continue param.' );
					$def.reject( r );
				}
			} );
		} );
		return $def;
	},
	$continueQuery: function ( query, result ) {
		var $def = $.Deferred(),
			qc = result[ 'query-continue' ],
			j = this,
			oldProp = query.prop,
			oldList = query.list;

		if ( qc ) {
			var props = [],
				lists = [];
			$.each( qc, function ( k, v ) {
				if ( oldProp && oldProp.indexOf( k ) > -1 ) {
					props.push( k );
				}
				if ( oldList && oldList.indexOf( k ) > -1 ) {
					lists.push( k );
				}
				$.extend( query, v );
			} );
			if ( props.length ) {
				query.prop = props.join( '|' );
			} else {
				delete query.prop;
			}
			if ( lists.length ) {
				query.list = lists.join( '|' );
			} else {
				delete query.list;
			}
		} else if ( result.continue ) {
			// new style continuation
			$.extend( query, result.continue );
		} else {
			throw new Error( 'MW-JS-BOT: Nothing to continue.' );
		}
		mw.loader.using( [ 'ext.gadget.libAPI' ], function () {
			mw.libs.commons.api.query( query, {
				method: 'POST',
				cache: false,
				cb: function ( r ) {
					$def.resolve( r );
				},
				// r-result, query, text
				errCb: function ( t, r/* , q*/ ) {
					j.fail( 'Failed to continue query.' );
					$def.reject( r );
				}
			} );
		} );

		return $def;
	},
	$saveContinue: function ( title, value, summary ) {
		var $def = $.Deferred(),
			j = this;

		mw.loader.using( [ 'ext.gadget.libAPI' ], function () {
			mw.libs.commons.api.editPage( {
				editType: 'text',
				text: value,
				title: title,
				summary: 'MW-JS-BOT: ' + ( summary || ' updating continue-param' ),
				cb: function ( r ) {
					$def.resolve( r );
				},
				// r-result, query, text
				errCb: function ( t, r/* , q*/ ) {
					j.fail( 'Failed to save continue param.' );
					$def.reject( r );
				}
			} );
		} );
		return $def;
	},
	$addLogline: function ( title, value, summary ) {
		var $def = $.Deferred(),
			j = this;

		mw.loader.using( [ 'ext.gadget.libAPI' ], function () {
			mw.libs.commons.api.editPage( {
				editType: 'appendtext',
				text: '\n# ' + value,
				title: title,
				summary: 'MW-JS-BOT: ' + ( summary || ' logging' ),
				cb: function ( r ) {
					$def.resolve( r );
				},
				// r-result, query, text
				errCb: function ( t, r/* , q*/ ) {
					j.fail( 'Failed to log.' );
					$def.reject( r );
				}
			} );
		} );
		return $def;
	},
	$xmlFromString: function ( xmlString, title ) {
		var xml = $.parseXML( xmlString ),
			isXmlDoc = $.isXMLDoc( xml ),
			$xmlDoc = $( xml ),
			j = this;

		if ( !isXmlDoc || !$xmlDoc || !$xmlDoc.length ) {
			j.warn( title + ' is not an XML Document.' );
		}
		return $xmlDoc;
	},
	stringFrom$xml: function ( $xml ) {
		if ( !window.XMLSerializer ) {
			window.XMLSerializer = function () {};
			window.XMLSerializer.prototype.serializeToString = function ( XMLObject ) {
				return XMLObject.xml || '';
			};
		}

		var oSerializer = new XMLSerializer(),
			xmlStringOut = oSerializer.serializeToString( $xml[ 0 ] );

		return xmlStringOut;
	},
	$getWindowConsole: function () {
		if ( this.$windowConsole ) {
			return this.$windowConsole;
		}

		var $console = this.$windowConsole = $( '<div>' ).css( {
			'font-family': '"Lucida Console", "Courier New", Monospace'
		} ).appendTo( mw.util.$content );
		$console.$consoleTop = $( '<div>' ).text( 'Window Console by MW-JS-BOT' ).appendTo( $console );
		$console.$droppedEntryNote = $( '<span>' ).appendTo( $console.$consoleTop );
		$console.droppedEntries = 0;
		$console.visibleEntries = 0;

		$console.dropFirstEntry = function () {
			$console.$consoleTop.next().remove();
			$console.droppedEntries++;
			$console.visibleEntries--;
			$console.$droppedEntryNote.text( $console.droppedEntries + ' entries were dropped from the window console.' );
		};
		$console.log = function () {
			if ( $console.totalEntries > 400 ) {
				$console.dropFirstEntry();
			}
			$console.visibleEntries++;
			var $entry = $( '<div>' ).attr( {
					class: 'windowconsole-entry'
				} ),
				argslen = arguments.length;
			for ( var i = 0; i < argslen; i++ ) {
				try {
					var $subentry = $( '<p>' ).text( arguments[ i ] );
					$subentry.appendTo( $entry );
				} catch ( ex ) {}
			}
			$entry.appendTo( $console );
		};
		return $console;
	},
	log: function ( /* unlimitedArgs*/ ) {
		if ( window.console ) {
			var arrArgs = Array.prototype.slice.call( arguments, 0 );
			arrArgs.unshift( 'mwbot>' );
			if ( typeof console.log === 'function' ) {
				console.log.apply( console, arrArgs );
			} else {
				this.$getWindowConsole().apply( this, arrArgs );
			}
		}
	},
	warn: function ( /* unlimitedArgs*/ ) {
		var j = this;
		if ( window.console ) {
			var arrArgs = Array.prototype.slice.call( arguments, 0 );
			if ( typeof console.warn === 'function' ) {
				arrArgs.unshift( 'mwbot>' );
				console.warn.apply( console, arrArgs );
			} else {
				arrArgs.unshift( 'WARNING>' );
				j.log.apply( j, arrArgs );
			}
		}
	},
	fail: function ( /* unlimitedArgs*/ ) {
		var j = this;
		if ( window.console ) {
			var arrArgs = Array.prototype.slice.call( arguments, 0 );
			if ( typeof console.error === 'function' ) {
				arrArgs.unshift( 'mwbot>' );
				console.error.apply( console, arrArgs );
			} else {
				arrArgs.unshift( 'ERR>' );
				j.log.apply( j, arrArgs );
			}
		}
	}
} );
window.MwJSBot = jsb;

var h = {};
h[ myModuleName ] = 'ready';
mw.loader.state( h );

new MwJSBot().log( 'Hello. I am your MwJSBot framework.' );
}( jQuery, mediaWiki ) );