http://www.mediawiki.org/wiki/Special:Code/MediaWiki/89918
Revision: 89918 Author: neilk Date: 2011-06-12 05:28:16 +0000 (Sun, 12 Jun 2011) Log Message: ----------- Use FileAPI for previews when possible. This complicates the situation of when a thumbnail may be generated. Depending on config or layout, it could be any of: 1) Thumbnail wanted before ready 2) Thumbnail wanted after ready multiplied by 1) FileAPI, locally scaled 2) Stashed Image API, remotely scaled, with exponential backoff retries 3) Image API, remotely scaled, with exponential backoff retries And of course all the error conditions! So, I have moved to a different model for obtaining thumbnails, where you just declare that a certain HTML element needs a thumbnail with setThumbnail(), and the framework figures out what it's supposed to do, and the thumbnail should show up eventually (or finally give you a broken image and an error condition). The thumbnail framework is now using a pub/sub model with a twist; certain events can happen only once, and you can subscribe to them in the past. This is a little bit like the 'ready' state of some DOM elements, so these are called by 'publishReady' and 'subscribeReady'. Think of it as "publish that we are now ready to show this thumbnail" or "do this action when the thumbnail is ready (or right away if it already is)". Modified Paths: -------------- trunk/extensions/UploadWizard/UploadWizardHooks.php trunk/extensions/UploadWizard/resources/mw.UploadWizard.js trunk/extensions/UploadWizard/resources/mw.UploadWizardUploadInterface.js Modified: trunk/extensions/UploadWizard/UploadWizardHooks.php =================================================================== --- trunk/extensions/UploadWizard/UploadWizardHooks.php 2011-06-12 05:22:54 UTC (rev 89917) +++ trunk/extensions/UploadWizard/UploadWizardHooks.php 2011-06-12 05:28:16 UTC (rev 89918) @@ -46,6 +46,8 @@ 'resources/jquery/jquery.validate.wmCommonsBlacklist.js', // common utilities + 'resources/mw.fileApi.js', + 'resources/mw.units.js', 'resources/mw.Log.js', 'resources/mw.Utilities.js', 'resources/mw.UtilitiesTime.js', @@ -333,7 +335,12 @@ 'mwe-upwiz-feedback-adding', 'mwe-upwiz-feedback-error1', 'mwe-upwiz-feedback-error2', - 'mwe-upwiz-feedback-error3' + 'mwe-upwiz-feedback-error3', + 'size-terabytes', + 'size-gigabytes', + 'size-megabytes', + 'size-kilobytes', + 'size-bytes' ), 'group' => 'ext.uploadWizard' ), Modified: trunk/extensions/UploadWizard/resources/mw.UploadWizard.js =================================================================== --- trunk/extensions/UploadWizard/resources/mw.UploadWizard.js 2011-06-12 05:22:54 UTC (rev 89917) +++ trunk/extensions/UploadWizard/resources/mw.UploadWizard.js 2011-06-12 05:28:16 UTC (rev 89918) @@ -8,6 +8,10 @@ ( function( $j ) { mw.UploadWizardUpload = function( api, filesDiv ) { + + this.index = mw.UploadWizardUpload.prototype.count; + mw.UploadWizardUpload.prototype.count++; + this.api = api; this.state = 'new'; this.thumbnails = {}; @@ -20,8 +24,8 @@ this.sessionKey = undefined; // this should be moved to the interface, if we even keep this - this.transportWeight = 1; // default - this.detailsWeight = 1; // default + this.transportWeight = 1; // default all same + this.detailsWeight = 1; // default all same // details this.ui = new mw.UploadWizardUploadInterface( this, filesDiv ); @@ -32,8 +36,6 @@ this.handler = this.getUploadHandler(); - this.index = mw.UploadWizardUpload.prototype.count; - mw.UploadWizardUpload.prototype.count++; }; mw.UploadWizardUpload.prototype = { @@ -231,32 +233,18 @@ * @param {Mixed} result -- result of AJAX call */ setSuccess: function( result ) { - var _this = this; // was a triumph + var _this = this; _this.state = 'transported'; _this.transportProgress = 1; - // I'm making a note here _this.ui.setStatus( 'mwe-upwiz-getting-metadata' ); if ( result.upload ) { _this.extractUploadInfo( result.upload ); - _this.getThumbnail( - function( image ) { - // n.b. if server returns a URL, which is a 404, we do NOT get broken image - _this.ui.setPreview( image ); // make the thumbnail the preview image - }, - mw.UploadWizard.config[ 'thumbnailWidth' ], - mw.UploadWizard.config[ 'thumbnailMaxHeight' ] - ); - // create the large thumbnail that the other thumbnails link to - _this.getThumbnail( - function( image ) {}, - mw.UploadWizard.config[ 'largeThumbnailWidth' ], - mw.UploadWizard.config[ 'largeThumbnailMaxHeight' ] - ); _this.deedPreview.setup(); _this.details.populate(); _this.state = 'stashed'; _this.ui.showStashed(); + $.publishReady( 'thumbnails.' + _this.index, 'api' ); } else { _this.setError( 'noimageinfo' ); } @@ -265,18 +253,46 @@ /** * Called when the file is entered into the file input - * Get as much data as possible -- maybe exif, even thumbnail maybe + * Get as much data as possible with this browser, including thumbnail. + * TODO exif + * @param {HTMLFileInput} file input field + * @param {Function} callback when ready */ - extractLocalFileInfo: function( filename ) { - if ( false ) { // FileAPI, one day - this.transportWeight = getFileSize(); - } - // XXX sanitize filename + extractLocalFileInfo: function( fileInput, callback ) { + var _this = this; + if ( mw.fileApi.isAvailable() ) { + if ( fileInput.files && fileInput.files.length ) { + // TODO multiple files in an input + this.file = fileInput.files[0]; + } + // TODO check max upload size, alert user if too big + this.transportWeight = this.file.size; + + if ( mw.fileApi.isPreviewableFile( this.file ) ) { + // TODO all that other complicated file rotation / binary stuff from mediawiki.special.upload.js + var image = document.createElement( 'img' ); + image.onload = function() { + $.publishReady( 'thumbnails.' + _this.index, image ); + }; + var reader = new FileReader(); + reader.onload = function( progressEvent ) { + _this.thumbnails['*'] = reader.result; + image.src = reader.result; + }; + reader.readAsDataURL( _this.file ); + } + + } + // TODO sanitize filename + var filename = fileInput.value; try { this.title = new mw.Title( mw.UploadWizardUtil.getBasename( filename ).replace( /:/g, '_' ), 'file' ); } catch ( e ) { this.setError( 'mwe-upwiz-unparseable-filename', filename ); } + if ( this.title ) { + callback(); + } }, /** @@ -285,16 +301,17 @@ * @param result The JSON object from a successful API upload result. */ extractUploadInfo: function( resultUpload ) { + if ( resultUpload.sessionkey ) { this.sessionKey = resultUpload.sessionkey; } + if ( resultUpload.imageinfo ) { this.extractImageInfo( resultUpload.imageinfo ); } else if ( resultUpload.stashimageinfo ) { this.extractImageInfo( resultUpload.stashimageinfo ); } - }, /** @@ -335,6 +352,10 @@ } */ } + + + + }, /** @@ -386,7 +407,69 @@ this.api.get( params, { ok: ok, err: err } ); }, + + /** + * Get information about published images + * (There is some overlap with getStashedImageInfo, but it's different at every stage so it's clearer to have separate functions) + * See API documentation for prop=imageinfo for what 'props' can contain + * @param {Function} callback -- called with null if failure, with imageinfo data structure if success + * @param {Array} properties to extract + * @param {Number} optional, width of thumbnail. Will force 'url' to be added to props + * @param {Number} optional, height of thumbnail. Will force 'url' to be added to props + */ + getImageInfo: function( callback, props, width, height ) { + var _this = this; + if (!mw.isDefined( props ) ) { + props = []; + } + var requestedTitle = _this.title.getPrefixedText(); + var params = { + 'prop': 'imageinfo', + 'titles': requestedTitle, + 'iiprop': props.join( '|' ) + }; + + if ( mw.isDefined( width ) || mw.isDefined( height ) ) { + if ( ! $j.inArray( 'url', props ) ) { + props.push( 'url' ); + } + if ( mw.isDefined( width ) ) { + params['iiurlwidth'] = width; + } + if ( mw.isDefined( height ) ) { + params['iiurlheight'] = height; + } + } + + var ok = function( data ) { + if ( data && data.query && data.query.pages ) { + var found = false; + $j.each( data.query.pages, function( pageId, page ) { + if ( page.title && page.title === requestedTitle && page.imageinfo ) { + found = true; + callback( page.imageinfo ); + return false; + } + } ); + if ( found ) { + return; + } + } + mw.log("mw.UploadWizardUpload::getImageInfo> No data matching " + requestedTitle + " ? "); + callback( null ); + }; + + var err = function( code, result ) { + mw.log( 'mw.UploadWizardUpload::getImageInfo> error: ' + code, 'debug' ); + callback( null ); + }; + + this.api.get( params, { ok: ok, err: err } ); + }, + + + /** * Get the upload handler per browser capabilities */ getUploadHandler: function(){ @@ -405,173 +488,167 @@ } return this.uploadHandler; }, + /** - * Fetch a thumbnail for a stashed upload of the desired width. - * It is assumed you don't call this until it's been transported. + * Explicitly fetch a thumbnail for a stashed upload of the desired width. + * Publishes to any event listeners that might have wanted it. * - * @param callback - callback to execute once thumbnail has been obtained -- must accept Image object for success, null for error * @param width - desired width of thumbnail (height will scale to match) * @param height - (optional) maximum height of thumbnail */ - getThumbnail: function( callback, width, height ) { + getAndPublishApiThumbnail: function( key, width, height ) { var _this = this; + if ( mw.isEmpty( height ) ) { height = -1; } - // this key is overspecified for this thumbnail ( we don't need to reiterate the index ) but - // we can use this as key as an event now, that might fire much later - var key = 'thumbnail.' + _this.index + '.width' + width + ',height' + height; - if ( mw.isDefined( _this.thumbnails[key] ) ) { - callback( _this.thumbnails[key] ); - return; - } - // subscribe to the event that this thumbnail is ready -- will give null or an Image to the callback - $j.subscribe( key, callback ); - - // if someone else already started a thumbnail fetch & publish, then don't bother, just wait for the event. - if ( ! mw.isDefined( _this.thumbnailPublishers[ key ] ) ) { - // The thumbnail publisher accepts the result of a stashImageInfo, and then tries to get the thumbnail, and eventually - // will trigger the event we just subscribed to. - _this.thumbnailPublishers[ key ] = _this.getThumbnailPublisher( key ); - _this.getStashImageInfo( _this.thumbnailPublishers[ key ], [ 'url' ], width, height ); - } - }, - - /** - * Returns a callback that can be used with a stashImageInfo call to fetch images, and then fire off an event to - * let everyone else know the image is loaded. - * - * Will retry the thumbnail URL several times, as thumbnails are known to be slow in production. - * @param {String} name of event to publish when thumbnails received (or final failure) - */ - getThumbnailPublisher: function( key ) { - var _this = this; - return function( thumbnails ) { + if ( !mw.isDefined( _this.thumbnailPublishers[key] ) ) { + var thumbnailPublisher = function( thumbnails ) { if ( thumbnails === null ) { - // the api call failed somehow, no thumbnail data. - $j.publish( key, null ); + // the api call failed somehow, no thumbnail data. + $j.publishReady( key, null ); } else { - // ok, the api callback has returned us information on where the thumbnail(s) ARE, but that doesn't mean - // they are actually there yet. Keep trying to set the source ( which should trigger "error" or "load" event ) - // on the image. If it loads publish the event with the image. If it errors out too many times, give up and publish - // the event with a null. - $j.each( thumbnails, function( i, thumb ) { + // ok, the api callback has returned us information on where the thumbnail(s) ARE, but that doesn't mean + // they are actually there yet. Keep trying to set the source ( which should trigger "error" or "load" event ) + // on the image. If it loads publish the event with the image. If it errors out too many times, give up and publish + // the event with a null. + $j.each( thumbnails, function( i, thumb ) { if ( thumb.thumberror || ( ! ( thumb.thumburl && thumb.thumbwidth && thumb.thumbheight ) ) ) { mw.log( "mw.UploadWizardUpload::getThumbnail> thumbnail error or missing information" ); - $j.publish( key, null ); + $j.publishReady( key, null ); return; } - // try to load this image with exponential backoff - // if the delay goes past 8 seconds, it gives up and publishes the event with null - var timeoutMs = 100; - + // try to load this image with exponential backoff + // if the delay goes past 8 seconds, it gives up and publishes the event with null + var timeoutMs = 100; var image = document.createElement( 'img' ); image.width = thumb.thumbwidth; image.height = thumb.thumbheight; - $j( image ) - .load( function() { - // cache this thumbnail - _this.thumbnails[key] = image; - // publish the image to anyone who wanted it - $j.publish( key, image ); - } ) - .error( function() { - // retry with exponential backoff - if ( timeoutMs < 8000 ) { - setTimeout( function() { - timeoutMs = timeoutMs * 2 + Math.round( Math.random() * ( timeoutMs / 10 ) ); - setSrc(); - }, timeoutMs ); - } else { - $j.publish( key, null ); - } - } ); + $j( image ) + .load( function() { + // cache this thumbnail + _this.thumbnails[key] = image; + // publish the image to anyone who wanted it + $j.publishReady( key, image ); + } ) + .error( function() { + // retry with exponential backoff + if ( timeoutMs < 8000 ) { + setTimeout( function() { + timeoutMs = timeoutMs * 2 + Math.round( Math.random() * ( timeoutMs / 10 ) ); + setSrc(); + }, timeoutMs ); + } else { + $j.publishReady( key, null ); + } + } ); - // executing this should cause a .load() or .error() event on the image - function setSrc() { - image.src = thumb.thumburl; - } + // executing this should cause a .load() or .error() event on the image + function setSrc() { + image.src = thumb.thumburl; + } - // and, go! - setSrc(); - } ); + // and, go! + setSrc(); + } ); + } + }; + + _this.thumbnailPublishers[key] = thumbnailPublisher; + if ( _this.state !== 'complete' ) { + _this.getStashImageInfo( thumbnailPublisher, [ 'url' ], width, height ); + } else { + _this.getImageInfo( thumbnailPublisher, [ 'url' ], width, height ); + } + } - }; }, /** - * Look up thumbnail info and set it in HTML, with loading spinner + * Given a jQuery selector, subscribe to the "ready" event that fills the thumbnail + * This will trigger if the thumbnail is added in the future or if it already has been * * @param selector - * @param width - * @param height (optional) + * @param width Width constraint + * @param height Height constraint (optional) */ setThumbnail: function( selector, width, height ) { var _this = this; if ( typeof width === 'undefined' || width === null || width <= 0 ) { - width = mw.UploadWizard.config[ 'thumbnailWidth' ]; + width = mw.UploadWizard.config['thumbnailWidth']; } - width = parseInt( width, 10 ); - height = null; - if ( !mw.isEmpty( height ) ) { - height = parseInt( height, 10 ); - } + var constraints = { + width: parseInt( width, 10 ), + height: ( mw.isDefined( height ) ? parseInt( height, 10 ) : null ) + }; - var callback = function( image ) { + /** + * This callback will add an image to the selector, using in-browser scaling if necessary + * @param {HTMLImageElement} + */ + var placed = false; + var placeImageCallback = function( image ) { if ( image === null ) { $j( selector ).addClass( 'mwe-upwiz-file-preview-broken' ); _this.ui.setStatus( 'mwe-upwiz-thumbnail-failed' ); - } else { - var $thumbnailLink = $j( '<a class="mwe-upwiz-thumbnail-link"></a>' ); - if ( _this.state != 'complete' ) { // don't use lightbox for thank you page thumbnail - $thumbnailLink + return; + } + + // if this debugger isn't here, it's okay?? + // figure out what scaling is needed, if any + var scaling = 1; + $j.each( [ 'width', 'height' ], function( i, dim ) { + if ( constraints[dim] && image[dim] > constraints[dim] ) { + var s = constraints[dim] / image[dim]; + if ( s < scaling ) { + scaling = s; + } + } + } ); + + // add the image to the DOM, finally + $j( selector ).html( + $j( '<a class="mwe-upwiz-thumbnail-link"></a>' ).append( + $j( '<img/>' ) .attr( { - 'href': '#', - 'target' : '_new' + width: parseInt( image.width * scaling, 10 ), + height: parseInt( image.height * scaling, 10 ), + src: image.src } ) - // set up lightbox behavior for thumbnail - .click( function() { - // get large preview image - _this.getThumbnail( - // open large preview in modal dialog box - function( image ) { - var dialogWidth = ( image.width > 200 ) ? image.width : 200; - $( '<div class="mwe-upwiz-lightbox"></div>' ) - .append( image ) - .dialog( { - 'width': dialogWidth, - 'autoOpen': true, - 'title': gM( 'mwe-upwiz-image-preview' ), - 'modal': true, - 'resizable': false - } ); - }, - mw.UploadWizard.config[ 'largeThumbnailWidth' ], - mw.UploadWizard.config[ 'largeThumbnailMaxHeight' ] - ); - return false; - } ); // close thumbnail click function - } // close if - - $j( selector ).html( - // insert the thumbnail into the anchor - $thumbnailLink.append( - $j( '<img/>' ) - .attr( { - 'width': image.width, - 'height': image.height, - 'src': image.src - } ) - ) // close append - ); // close html - } // close image !== null else condition + ) + ); + placed = true; }; - _this.getThumbnail( callback, width, height ); + // Listen for even which says some kind of thumbnail is available. + // The argument is an either an ImageHtmlElement ( if we could get the thumbnail locally ) or the string 'api' indicating you + // now need to get the scaled thumbnail via the API + $.subscribeReady( + 'thumbnails.' + _this.index, + function ( x ) { + if ( !placed ) { + if ( x === 'api' ) { + // get the thumbnail via API. This also works with an async pub/sub model; if this thumbnail was already + // fetched for some reason, we'll get it immediately + var key = 'apiThumbnail.' + _this.index + ',width=' + width + ',height=' + height; + $.subscribeReady( key, placeImageCallback ); + _this.getAndPublishApiThumbnail( key, width, height ); + } else if ( x instanceof HTMLImageElement ) { + placeImageCallback( x ); + } else { + // something else went wrong, place broken image + mw.log( 'unexpected argument to thumbnails event: ' + x ); + placeImageCallback( null ); + } + } + } + ); + }, + /** * Given a filename like "Foo.jpg", get the URL to that filename, assuming the browser is on the same wiki. * Candidate for a utility function... Modified: trunk/extensions/UploadWizard/resources/mw.UploadWizardUploadInterface.js =================================================================== --- trunk/extensions/UploadWizard/resources/mw.UploadWizardUploadInterface.js 2011-06-12 05:22:54 UTC (rev 89917) +++ trunk/extensions/UploadWizard/resources/mw.UploadWizardUploadInterface.js 2011-06-12 05:28:16 UTC (rev 89918) @@ -15,7 +15,13 @@ _this.isFilled = false; _this.$fileInputCtrl = $j('<input size="1" class="mwe-upwiz-file-input" name="file" type="file"/>') - .change( function() { _this.fileChanged(); } ); + .change( function() { + _this.clearErrors(); + _this.upload.extractLocalFileInfo( + this, // the file input + function() { _this.fileChanged(); } + ); + } ); _this.$indicator = $j( '<div class="mwe-upwiz-file-indicator"></div>' ); @@ -82,6 +88,14 @@ $j( _this.div ).bind( 'transportProgressEvent', function(e) { _this.showTransportProgress(); } ); // $j( _this.div ).bind( 'transportedEvent', function(e) { _this.showStashed(); } ); + // XXX feature envy + var $preview = $j( this.div ).find( '.mwe-upwiz-file-preview' ); + _this.upload.setThumbnail( + $preview, + mw.UploadWizard.config[ 'thumbnailWidth' ], + mw.UploadWizard.config[ 'thumbnailMaxHeight' ] + ); + }; @@ -143,7 +157,6 @@ * Set the status line for this upload with an internationalized message string. * @param String msgKey: key for the message * @param Array args: array of values, in case any need to be fed to the image. - * @param Boolean error: if true, show an error */ setStatus: function( msgKey, args ) { if ( !mw.isDefined( args ) ) { @@ -155,6 +168,14 @@ }, /** + * Set status line directly with a string + * @param {String} + */ + setStatusString: function( s ) { + $j( this.div ).find( '.mwe-upwiz-file-status' ).html( s ).show(); + }, + + /** * Clear the status line for this upload (hide it, in case there are paddings and such which offset other things.) */ clearStatus: function() { @@ -209,10 +230,10 @@ */ fileChanged: function() { var _this = this; - _this.clearErrors(); - _this.upload.extractLocalFileInfo( _this.$fileInputCtrl.val() ); var extension = _this.upload.title.getExtension(); var hasExtension = ! mw.isEmpty( extension ); + + /* this should not be in the interface */ var isGoodExtension = false; if ( hasExtension ) { isGoodExtension = $j.inArray( extension.toLowerCase(), mw.UploadWizard.config[ 'fileExtensions' ] ) !== -1; @@ -220,19 +241,20 @@ if ( hasExtension && isGoodExtension ) { _this.updateFilename(); } else { + var $errorMessage; // Check if firefogg should be recommended to be installed ( user selects an extension that can be converted) if( mw.UploadWizard.config['enableFirefogg'] && $j.inArray( extension.toLowerCase(), mw.UploadWizard.config['transcodeExtensionList'] ) !== -1 ){ - var $errorMessage = $j( '<p>' ).msg('mwe-upwiz-upload-error-bad-extension-video-firefogg', + $errorMessage = $j( '<p>' ).msg('mwe-upwiz-upload-error-bad-extension-video-firefogg', mw.Firefogg.getFirefoggInstallUrl(), 'http://commons.wikimedia.org/wiki/Help:Converting_video' ); } else { var errorKey = hasExtension ? 'mwe-upwiz-upload-error-bad-filename-extension' : 'mwe-upwiz-upload-error-bad-filename-no-extension'; - var $errorMessage = $j( '<p>' ).msg( errorKey, extension ); + $errorMessage = $j( '<p>' ).msg( errorKey, extension ); } $( '<div></div>' ) .append( @@ -250,7 +272,11 @@ modal: true }); } + this.clearStatus(); + if ( this.upload.file ) { + this.setStatusString( mw.units.bytes( this.upload.file.size ) ); + } }, /** @@ -326,6 +352,8 @@ // But for UploadWizard, at this stage, it's the reverse. We want to stop same-content dead, but for now we ignore same-filename $j( _this.filenameCtrl ).val( ( new Date() ).getTime().toString() +_this.upload.title.getMain() ); + + // deal with styling the file inputs and making it react to mouse if ( ! _this.isFilled ) { var $div = $j( _this.div ); _this.isFilled = true; _______________________________________________ MediaWiki-CVS mailing list MediaWiki-CVS@lists.wikimedia.org https://lists.wikimedia.org/mailman/listinfo/mediawiki-cvs