diff options
Diffstat (limited to 'www/wiki/extensions/UploadWizard/resources/mw.UploadWizardDetails.js')
-rw-r--r-- | www/wiki/extensions/UploadWizard/resources/mw.UploadWizardDetails.js | 1308 |
1 files changed, 1308 insertions, 0 deletions
diff --git a/www/wiki/extensions/UploadWizard/resources/mw.UploadWizardDetails.js b/www/wiki/extensions/UploadWizard/resources/mw.UploadWizardDetails.js new file mode 100644 index 00000000..7ab60b85 --- /dev/null +++ b/www/wiki/extensions/UploadWizard/resources/mw.UploadWizardDetails.js @@ -0,0 +1,1308 @@ +( function ( mw, uw, $, OO ) { + + var NS_FILE = mw.config.get( 'wgNamespaceIds' ).file; + + /** + * Object that represents the Details (step 2) portion of the UploadWizard + * n.b. each upload gets its own details. + * + * @param {mw.UploadWizardUpload} upload + * @param {jQuery} containerDiv The `div` to put the interface into + */ + mw.UploadWizardDetails = function ( upload, containerDiv ) { + this.upload = upload; + this.containerDiv = containerDiv; + this.api = upload.api; + + this.mainFields = []; + + this.deedChooserDetails = new uw.DeedChooserDetailsWidget(); + this.customDeedChooser = false; + + this.div = $( '<div class="mwe-upwiz-info-file ui-helper-clearfix filled"></div>' ); + }; + + mw.UploadWizardDetails.prototype = { + + // Has this details object been attached to the DOM already? + isAttached: false, + + /** + * Build the interface and attach all elements - do this on demand. + */ + buildInterface: function () { + var descriptionRequired, uri, + $moreDetailsWrapperDiv, $moreDetailsDiv, + details = this; + + this.thumbnailDiv = $( '<div class="mwe-upwiz-thumbnail mwe-upwiz-thumbnail-side"></div>' ); + + this.dataDiv = $( '<div class="mwe-upwiz-data"></div>' ); + + this.titleDetails = new uw.TitleDetailsWidget( { + // Normalize file extension, e.g. 'JPEG' to 'jpg' + extension: mw.Title.normalizeExtension( this.upload.title.getExtension() ) + } ); + this.titleDetailsField = new uw.FieldLayout( this.titleDetails, { + label: mw.message( 'mwe-upwiz-title' ).text(), + help: mw.message( 'mwe-upwiz-tooltip-title' ).text(), + required: true + } ); + this.mainFields.push( this.titleDetailsField ); + + this.captionsDetails = new uw.MultipleLanguageInputWidget( { + required: false, + // Messages: mwe-upwiz-caption-add-0, mwe-upwiz-caption-add-n + label: mw.message( 'mwe-upwiz-caption-add' ), + error: mw.message( 'mwe-upwiz-error-bad-captions' ), + remove: mw.message( 'mwe-upwiz-remove-caption' ), + minLength: mw.UploadWizard.config.minCaptionLength, + maxLength: mw.UploadWizard.config.maxCaptionLength + } ); + this.captionsDetailsField = new uw.FieldLayout( this.captionsDetails, { + required: false, + label: mw.message( 'mwe-upwiz-caption' ).text(), + help: mw.message( 'mwe-upwiz-tooltip-caption' ).text() + } ); + if ( mw.UploadWizard.config.wikibase.enabled ) { + this.mainFields.push( this.captionsDetailsField ); + } + + // descriptions + // Description is not required if a campaign provides alternative wikitext fields, + // which are assumed to function like a description + descriptionRequired = !( + mw.UploadWizard.config.fields && + mw.UploadWizard.config.fields.length && + mw.UploadWizard.config.fields[ 0 ].wikitext + ); + this.descriptionsDetails = new uw.MultipleLanguageInputWidget( { + required: descriptionRequired, + // Messages: mwe-upwiz-desc-add-0, mwe-upwiz-desc-add-n + label: mw.message( 'mwe-upwiz-desc-add' ), + error: mw.message( 'mwe-upwiz-error-bad-descriptions' ), + remove: mw.message( 'mwe-upwiz-remove-description' ), + minLength: mw.UploadWizard.config.minDescriptionLength, + maxLength: mw.UploadWizard.config.maxDescriptionLength + } ); + this.descriptionsDetailsField = new uw.FieldLayout( this.descriptionsDetails, { + required: descriptionRequired, + label: mw.message( 'mwe-upwiz-desc' ).text(), + help: mw.message( 'mwe-upwiz-tooltip-description' ).text() + } ); + this.mainFields.push( this.descriptionsDetailsField ); + + this.deedChooserDetailsField = new uw.FieldLayout( this.deedChooserDetails, { + label: mw.message( 'mwe-upwiz-copyright-info' ).text(), + required: true + } ); + this.deedChooserDetailsField.toggle( this.customDeedChooser ); // See useCustomDeedChooser() + this.mainFields.push( this.deedChooserDetailsField ); + + this.categoriesDetails = new uw.CategoriesDetailsWidget(); + this.categoriesDetailsField = new uw.FieldLayout( this.categoriesDetails, { + label: mw.message( 'mwe-upwiz-categories' ).text(), + help: new OO.ui.HtmlSnippet( + mw.message( 'mwe-upwiz-tooltip-categories', $( '<a>' ).attr( { + target: '_blank', + href: 'https://commons.wikimedia.org/wiki/Commons:Categories' + } ) ).parse() + ) + } ); + this.mainFields.push( this.categoriesDetailsField ); + + this.dateDetails = new uw.DateDetailsWidget( { upload: this.upload } ); + this.dateDetailsField = new uw.FieldLayout( this.dateDetails, { + label: mw.message( 'mwe-upwiz-date-created' ).text(), + help: mw.message( 'mwe-upwiz-tooltip-date' ).text(), + required: true + } ); + this.mainFields.push( this.dateDetailsField ); + + this.otherDetails = new uw.OtherDetailsWidget(); + this.otherDetailsField = new uw.FieldLayout( this.otherDetails, { + label: mw.message( 'mwe-upwiz-other' ).text(), + help: mw.message( 'mwe-upwiz-tooltip-other' ).text() + } ); + this.mainFields.push( this.otherDetailsField ); + + this.locationInput = new uw.LocationDetailsWidget( { showHeading: true } ); + this.locationInputField = new uw.FieldLayout( this.locationInput, { + // No 'label', labels are included in this widget + help: new OO.ui.HtmlSnippet( + mw.message( 'mwe-upwiz-tooltip-location', $( '<a>' ).attr( { + target: '_blank', + href: '//commons.wikimedia.org/wiki/Commons:Geocoding' + } ) ).parse() + ) + } ); + this.mainFields.push( this.locationInputField ); + + /* Build the form for the file upload */ + this.$form = $( '<form id="mwe-upwiz-detailsform' + this.upload.index + '"></form>' ).addClass( 'detailsForm' ); + this.$form.append( + this.titleDetailsField.$element, + mw.UploadWizard.config.wikibase.enabled ? this.captionsDetailsField.$element : null, + this.descriptionsDetailsField.$element, + this.deedChooserDetailsField.$element, + this.dateDetailsField.$element, + this.categoriesDetailsField.$element + ); + + this.$form.on( 'submit', function ( e ) { + // Prevent actual form submission + e.preventDefault(); + } ); + + this.campaignDetailsFields = []; + $.each( mw.UploadWizard.config.fields, function ( i, field ) { + var customDetails, customDetailsField; + + if ( field.wikitext ) { + customDetails = new uw.CampaignDetailsWidget( field ); + customDetailsField = new uw.FieldLayout( customDetails, { + label: $( $.parseHTML( field.label ) ), + required: !!field.required + } ); + + if ( field.initialValue ) { + customDetails.setSerialized( { value: field.initialValue } ); + } + + details.$form.append( customDetailsField.$element ); + details.campaignDetailsFields.push( customDetailsField ); + } + } ); + + $moreDetailsWrapperDiv = $( '<div class="mwe-more-details">' ); + $moreDetailsDiv = $( '<div>' ); + + $moreDetailsDiv.append( + this.locationInputField.$element, + this.otherDetailsField.$element + ); + + $moreDetailsWrapperDiv + .append( + $( '<a>' ).text( mw.msg( 'mwe-upwiz-more-options' ) ) + .addClass( 'mwe-upwiz-details-more-options mw-collapsible-toggle mw-collapsible-arrow' ), + $moreDetailsDiv.addClass( 'mw-collapsible-content' ) + ) + .makeCollapsible( { collapsed: true } ); + + // Expand collapsed sections if the fields within were changed (e.g. by metadata copier) + this.locationInput.on( 'change', function () { + $moreDetailsWrapperDiv.data( 'mw-collapsible' ).expand(); + } ); + this.otherDetails.on( 'change', function () { + $moreDetailsWrapperDiv.data( 'mw-collapsible' ).expand(); + } ); + + this.$form.append( + $moreDetailsWrapperDiv + ); + + // Add in remove control to form + this.removeCtrl = new OO.ui.ButtonWidget( { + label: mw.message( 'mwe-upwiz-remove' ).text(), + title: mw.message( 'mwe-upwiz-remove-upload' ).text(), + classes: [ 'mwe-upwiz-remove-upload' ], + flags: 'destructive', + icon: 'trash', + framed: false + } ).on( 'click', function () { + OO.ui.confirm( mw.message( 'mwe-upwiz-license-confirm-remove' ).text(), { + title: mw.message( 'mwe-upwiz-license-confirm-remove-title' ).text() + } ).done( function ( confirmed ) { + if ( confirmed ) { + details.upload.emit( 'remove-upload' ); + } + } ); + } ); + + this.$form.append( this.removeCtrl.$element ); + + this.submittingDiv = $( '<div>' ).addClass( 'mwe-upwiz-submitting' ) + .append( + $( '<div>' ).addClass( 'mwe-upwiz-file-indicator' ), + $( '<div>' ).addClass( 'mwe-upwiz-details-texts' ).append( + $( '<div>' ).addClass( 'mwe-upwiz-visible-file-filename-text' ), + $( '<div>' ).addClass( 'mwe-upwiz-file-status-line' ) + ) + ); + + $( this.dataDiv ).append( + this.$form, + this.submittingDiv + ).morphCrossfader(); + + $( this.div ).append( + this.thumbnailDiv, + this.dataDiv + ); + + uri = new mw.Uri( location.href, { overrideKeys: true } ); + if ( mw.UploadWizard.config.defaults.caption || uri.query.captionlang ) { + this.captionsDetails.setSerialized( { + inputs: [ + { + language: uri.query.captionlang ? + uw.SingleLanguageInputWidget.static.getClosestAllowedLanguage( uri.query.captionlang ) : + uw.SingleLanguageInputWidget.static.getDefaultLanguage(), + text: mw.UploadWizard.config.defaults.caption || '' + } + ] + } ); + } + + if ( mw.UploadWizard.config.defaults.description || uri.query.descriptionlang ) { + this.descriptionsDetails.setSerialized( { + inputs: [ + { + language: uri.query.descriptionlang ? + uw.SingleLanguageInputWidget.static.getClosestAllowedLanguage( uri.query.descriptionlang ) : + uw.SingleLanguageInputWidget.static.getDefaultLanguage(), + text: mw.UploadWizard.config.defaults.description || '' + } + ] + } ); + } + + this.populate(); + + this.interfaceBuilt = true; + + if ( this.savedSerialData ) { + this.setSerialized( this.savedSerialData ); + this.savedSerialData = undefined; + } + }, + + /* + * Append the div for this details object to the DOM. + * We need to ensure that we add divs in the right order + * (the order in which the user selected files). + * + * Will only append once. + */ + attach: function () { + var $window = $( window ), + details = this; + + function maybeBuild() { + if ( !this.interfaceBuilt && $window.scrollTop() + $window.height() + 1000 >= details.div.offset().top ) { + details.buildInterface(); + $window.off( 'scroll', maybeBuild ); + } + } + + if ( !this.isAttached ) { + $( this.containerDiv ).append( this.div ); + + if ( $window.scrollTop() + $window.height() + 1000 >= this.div.offset().top ) { + this.buildInterface(); + } else { + $window.on( 'scroll', maybeBuild ); + } + + this.isAttached = true; + } + }, + + /** + * Get file page title for this upload. + * + * @return {mw.Title|null} + */ + getTitle: function () { + return this.titleDetails.getTitle(); + }, + + /** + * Display error message about multiple uploaded files with the same title specified + * + * @return {mw.UploadWizardDetails} + * @chainable + */ + setDuplicateTitleError: function () { + // TODO This should give immediate response, not only when submitting the form + this.titleDetailsField.setErrors( [ mw.message( 'mwe-upwiz-error-title-duplicate' ) ] ); + return this; + }, + + /** + * @private + * + * @return {uw.FieldLayout[]} + */ + getAllFields: function () { + return [].concat( + this.mainFields, + this.upload.deedChooser.deed ? this.upload.deedChooser.deed.getFields() : [], + this.campaignDetailsFields + ); + }, + + /** + * Check all the fields for validity. + * + * @return {jQuery.Promise} Promise resolved with multiple array arguments, each containing a + * list of error messages for a single field. If API requests necessary to check validity + * fail, the promise may be rejected. The form is valid if the promise is resolved with all + * empty arrays. + */ + getErrors: function () { + return $.when.apply( $, this.getAllFields().map( function ( fieldLayout ) { + return fieldLayout.fieldWidget.getErrors(); + } ) ); + }, + + /** + * Check all the fields for warnings. + * + * @return {jQuery.Promise} Same as #getErrors + */ + getWarnings: function () { + return $.when.apply( $, this.getAllFields().map( function ( fieldLayout ) { + return fieldLayout.fieldWidget.getWarnings(); + } ) ); + }, + + /** + * Check all the fields for errors and warnings and display them in the UI. + * + * @param {boolean} thorough True to perform a thorough validity check. Defaults to false for a fast on-change check. + * @return {jQuery.Promise} Combined promise of all fields' validation results. + */ + checkValidity: function ( thorough ) { + var fields = this.getAllFields(); + + return $.when.apply( $, fields.map( function ( fieldLayout ) { + // Update any error/warning messages + return fieldLayout.checkValidity( thorough ); + } ) ); + }, + + /** + * Get a thumbnail caption for this upload (basically, the first caption). + * + * @return {string} + */ + getThumbnailCaption: function () { + var captions = []; + if ( mw.UploadWizard.config.wikibase.enabled ) { + captions = this.captionsDetails.getSerialized().inputs; + } else { + captions = this.descriptionsDetails.getSerialized().inputs; + } + + if ( captions.length > 0 ) { + return mw.Escaper.escapeForTemplate( captions[ 0 ].text.trim() ); + } else { + return ''; + } + }, + + /** + * toggles whether we use the 'macro' deed or our own + */ + useCustomDeedChooser: function () { + this.customDeedChooser = true; + this.deedChooserDetails.useCustomDeedChooser( this.upload ); + }, + + /** + * Pull some info into the form ( for instance, extracted from EXIF, desired filename ) + */ + populate: function () { + var thumbnailDiv = this.thumbnailDiv; + this.upload.getThumbnail().done( function ( thumb ) { + mw.UploadWizard.placeThumbnail( thumbnailDiv, thumb ); + } ); + this.prefillDate(); + this.prefillAuthor(); + this.prefillTitle(); + this.prefillDescription(); + this.prefillLocation(); + }, + + /** + * Check if we got an EXIF date back and enter it into the details + * XXX We ought to be using date + time here... + * EXIF examples tend to be in ISO 8601, but the separators are sometimes things like colons, and they have lots of trailing info + * (which we should actually be using, such as time and timezone) + */ + prefillDate: function () { + var dateObj, metadata, dateTimeRegex, matches, dateStr, saneTime, + dateMode = 'calendar', + yyyyMmDdRegex = /^(\d\d\d\d)[:/-](\d\d)[:/-](\d\d)\D.*/, + timeRegex = /\D(\d\d):(\d\d):(\d\d)/; + + // XXX surely we have this function somewhere already + function pad( n ) { + return ( n < 10 ? '0' : '' ) + String( n ); + } + + function getSaneTime( date ) { + var str = ''; + + str += pad( date.getHours() ) + ':'; + str += pad( date.getMinutes() ) + ':'; + str += pad( date.getSeconds() ); + + return str; + } + + if ( this.upload.imageinfo.metadata ) { + metadata = this.upload.imageinfo.metadata; + $.each( [ 'datetimeoriginal', 'datetimedigitized', 'datetime', 'date' ], function ( i, propName ) { + var matches, timeMatches, + dateInfo = metadata[ propName ]; + if ( dateInfo ) { + matches = dateInfo.trim().match( yyyyMmDdRegex ); + if ( matches ) { + timeMatches = dateInfo.trim().match( timeRegex ); + if ( timeMatches ) { + dateObj = new Date( parseInt( matches[ 1 ], 10 ), + parseInt( matches[ 2 ], 10 ) - 1, + parseInt( matches[ 3 ], 10 ), + parseInt( timeMatches[ 1 ], 10 ), + parseInt( timeMatches[ 2 ], 10 ), + parseInt( timeMatches[ 3 ], 10 ) ); + } else { + dateObj = new Date( parseInt( matches[ 1 ], 10 ), + parseInt( matches[ 2 ], 10 ) - 1, + parseInt( matches[ 3 ], 10 ) ); + } + return false; // break from $.each + } + } + } ); + } + + // If we don't have EXIF lets try other sources - Flickr + if ( dateObj === undefined && this.upload.file !== undefined && this.upload.file.date !== undefined ) { + dateTimeRegex = /^\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d/; + matches = this.upload.file.date.match( dateTimeRegex ); + if ( matches ) { + this.dateDetails.setSerialized( { + mode: dateMode, + value: this.upload.file.date + } ); + return; + } + } + + // if we don't have EXIF or other metadata, just don't put a date in. + // XXX if we have FileAPI, it might be clever to look at file attrs, saved + // in the upload object for use here later, perhaps + if ( dateObj === undefined ) { + return; + } + + dateStr = dateObj.getFullYear() + '-' + pad( dateObj.getMonth() + 1 ) + '-' + pad( dateObj.getDate() ); + + // Add the time + // If the date but not the time is set in EXIF data, we'll get a bogus + // time value of '00:00:00'. + // FIXME: Check for missing time value earlier rather than blacklisting + // a potentially legitimate time value. + saneTime = getSaneTime( dateObj ); + if ( saneTime !== '00:00:00' ) { + dateStr += ' ' + saneTime; + + // Switch to freeform date field. DateInputWidget (with calendar) handles dates only, not times. + dateMode = 'arbitrary'; + } + + // ok by now we should definitely have a dateObj and a date string + this.dateDetails.setSerialized( { + mode: dateMode, + value: dateStr + } ); + }, + + /** + * Set the title of the thing we just uploaded, visibly + */ + prefillTitle: function () { + this.titleDetails.setSerialized( { + title: this.upload.title.getNameText() + } ); + }, + + /** + * Prefill the image description if we have a description + * + * Note that this is not related to specifying the description from the query + * string (that happens earlier). This is for when we have retrieved a + * description from an upload_by_url upload (e.g. Flickr transfer) + * or from the metadata. + */ + prefillDescription: function () { + var m, descText; + + if ( + this.descriptionsDetails.getWikiText() === '' && + this.upload.file !== undefined + ) { + m = this.upload.imageinfo.metadata; + descText = this.upload.file.description || + ( m && m.imagedescription && m.imagedescription[ 0 ] && m.imagedescription[ 0 ].value ); + + if ( descText ) { + // strip out any HTML tags + descText = descText.replace( /<[^>]+>/g, '' ); + // & and " are escaped by Flickr, so we need to unescape + descText = descText.replace( /&/g, '&' ).replace( /"/g, '"' ); + + this.descriptionsDetails.setSerialized( { + inputs: [ + { + // The language is probably wrong in many cases... + language: uw.DescriptionDetailsWidget.static.getClosestAllowedLanguage( mw.config.get( 'wgContentLanguage' ) ), + text: descText.trim() + } + ] + } ); + } + } + }, + + /** + * Prefill location input from image info and metadata + * + * As of MediaWiki 1.18, the exif parser translates the rational GPS data tagged by the camera + * to decimal format. Let's just use that. + */ + prefillLocation: function () { + var dir, + m = this.upload.imageinfo.metadata, + modified = false, + values = {}; + + if ( mw.UploadWizard.config.defaults.lat ) { + values.latitude = mw.UploadWizard.config.defaults.lat; + modified = true; + } + if ( mw.UploadWizard.config.defaults.lon ) { + values.longitude = mw.UploadWizard.config.defaults.lon; + modified = true; + } + if ( mw.UploadWizard.config.defaults.heading ) { + values.heading = mw.UploadWizard.config.defaults.heading; + modified = true; + } + + if ( m ) { + dir = m.gpsimgdirection || m.gpsdestbearing; + + if ( dir ) { + if ( dir.match( /^\d+\/\d+$/ ) !== null ) { + // Apparently it can take the form "x/y" instead of + // a decimal value. Mighty silly, but let's save it. + dir = dir.split( '/' ); + dir = parseInt( dir[ 0 ], 10 ) / parseInt( dir[ 1 ], 10 ); + } + + values.heading = dir; + + modified = true; + } + + // Prefill useful stuff only + if ( Number( m.gpslatitude ) && Number( m.gpslongitude ) ) { + values.latitude = m.gpslatitude; + values.longitude = m.gpslongitude; + modified = true; + } else if ( + this.upload.file && + this.upload.file.location && + this.upload.file.location.latitude && + this.upload.file.location.longitude + ) { + values.latitude = this.upload.file.location.latitude; + values.longitude = this.upload.file.location.longitude; + modified = true; + } + } + + this.locationInput.setSerialized( values ); + + if ( modified ) { + this.$form.find( '.mwe-more-details' ) + .data( 'mw-collapsible' ).expand(); + } + }, + + /** + * Prefill author (such as can be determined) from image info and metadata + * XXX user pref? + */ + prefillAuthor: function () { + if ( this.upload.imageinfo.metadata && this.upload.imageinfo.metadata.author ) { + $( this.authorInput ).val( this.upload.imageinfo.metadata.author ); + } + }, + + /** + * Get a machine-readable representation of the current state of the upload details. It can be + * passed to #setSerialized to restore this state (or to set it for another instance of the same + * class). + * + * Note that this doesn't include custom deed's state. + * + * @return {Object.<string,Object>} + */ + getSerialized: function () { + if ( !this.interfaceBuilt ) { + // We don't have the interface yet, but it'll get filled out as + // needed. + return; + } + + return { + title: this.titleDetails.getSerialized(), + caption: this.captionsDetails.getSerialized(), + description: this.descriptionsDetails.getSerialized(), + date: this.dateDetails.getSerialized(), + categories: this.categoriesDetails.getSerialized(), + location: this.locationInput.getSerialized(), + other: this.otherDetails.getSerialized(), + campaigns: this.campaignDetailsFields.map( function ( field ) { + return field.fieldWidget.getSerialized(); + } ), + deed: this.deedChooserDetails.getSerialized() + }; + }, + + /** + * Set the state of this widget from machine-readable representation, as returned by + * #getSerialized. + * + * Fields from the representation can be omitted to keep the current value. + * + * @param {Object.<string,Object>} [serialized] + */ + setSerialized: function ( serialized ) { + var i; + + if ( !this.interfaceBuilt ) { + // There's no interface yet! Don't load the data, just keep it + // around. + if ( serialized === undefined ) { + // Note: This will happen if we "undo" a copy operation while + // some of the details interfaces aren't loaded. + this.savedSerialData = undefined; + } else { + this.savedSerialData = $.extend( true, + this.savedSerialData || {}, + serialized + ); + } + return; + } + + if ( serialized === undefined ) { + // This is meaningless if the interface is already built. + return; + } + + if ( serialized.title ) { + this.titleDetails.setSerialized( serialized.title ); + } + if ( serialized.caption ) { + this.captionsDetails.setSerialized( serialized.caption ); + } + if ( serialized.description ) { + this.descriptionsDetails.setSerialized( serialized.description ); + } + if ( serialized.date ) { + this.dateDetails.setSerialized( serialized.date ); + } + if ( serialized.categories ) { + this.categoriesDetails.setSerialized( serialized.categories ); + } + if ( serialized.location ) { + this.locationInput.setSerialized( serialized.location ); + } + if ( serialized.other ) { + this.otherDetails.setSerialized( serialized.other ); + } + if ( serialized.campaigns ) { + for ( i = 0; i < this.campaignDetailsFields.length; i++ ) { + this.campaignDetailsFields[ i ].fieldWidget.setSerialized( serialized.campaigns[ i ] ); + } + } + if ( serialized.deed ) { + this.deedChooserDetails.setSerialized( serialized.deed ); + } + }, + + /** + * Convert entire details for this file into wikiText, which will then be posted to the file + * + * This function assumes that all input is valid. + * + * @return {string} wikitext representing all details + */ + getWikiText: function () { + var deed, info, key, + information, + wikiText = ''; + + // https://commons.wikimedia.org/wiki/Template:Information + // can we be more slick and do this with maps, applys, joins? + information = { + // {{lang|description in lang}}* required + description: '', + // YYYY, YYYY-MM, or YYYY-MM-DD required - use jquery but allow editing, then double check for sane date. + date: '', + // {{own}} or wikitext optional + source: '', + // any wikitext, but particularly {{Creator:Name Surname}} required + author: '', + // leave blank unless OTRS pending; by default will be "see below" optional + permission: '', + // pipe separated list, other versions optional + 'other versions': '' + }; + + information.description = this.descriptionsDetails.getWikiText(); + + $.each( this.campaignDetailsFields, function ( i, layout ) { + information.description += layout.fieldWidget.getWikiText(); + } ); + + information.date = this.dateDetails.getWikiText(); + + deed = this.upload.deedChooser.deed; + + information.source = deed.getSourceWikiText( this.upload ); + + information.author = deed.getAuthorWikiText( this.upload ); + + info = ''; + + for ( key in information ) { + if ( information.hasOwnProperty( key ) ) { + info += '|' + key.replace( /:/g, '_' ); + info += '=' + mw.Escaper.escapeForTemplate( information[ key ] ) + '\n'; + } + } + + wikiText += '=={{int:filedesc}}==\n'; + wikiText += '{{Information\n' + info + '}}\n'; + + wikiText += this.locationInput.getWikiText() + '\n'; + + // add an "anything else" template if needed + wikiText += this.otherDetails.getWikiText() + '\n\n'; + + // add licensing information + wikiText += '\n=={{int:license-header}}==\n'; + wikiText += deed.getLicenseWikiText( this.upload ) + '\n\n'; + + if ( mw.UploadWizard.config.autoAdd.wikitext !== undefined ) { + wikiText += mw.UploadWizard.config.autoAdd.wikitext + '\n'; + } + + // add parameters for list callback bot + // this cue will be used to supplement a wiki page with an image thumbnail + if ( $( '#imgPicker' + this.upload.index ).prop( 'checked' ) ) { + wikiText += '\n<!-- WIKIPAGE_UPDATE_PARAMS ' + + mw.UploadWizard.config.defaults.objref + + ' -->\n'; + } + + // categories + wikiText += '\n' + this.categoriesDetails.getWikiText(); + + // sanitize wikitext if TextCleaner is defined (MediaWiki:TextCleaner.js) + if ( typeof window.TextCleaner !== 'undefined' && typeof window.TextCleaner.sanitizeWikiText === 'function' ) { + wikiText = window.TextCleaner.sanitizeWikiText( wikiText, true ); + } + + // remove too many newlines in a row + wikiText = wikiText.replace( /\n{3,}/g, '\n\n' ); + + return wikiText; + }, + + /** + * @return {jQuery.Promise} + */ + submit: function () { + var details = this, + wikitext, captions, promise; + + $( 'form', this.containerDiv ).submit(); + + this.upload.title = this.getTitle(); + this.upload.state = 'submitting-details'; + this.setStatus( mw.message( 'mwe-upwiz-submitting-details' ).text() ); + this.showIndicator( 'progress' ); + + wikitext = this.getWikiText(); + promise = this.submitWikiText( wikitext ); + + captions = this.captionsDetails.getValues(); + if ( mw.UploadWizard.config.wikibase.enabled && Object.keys( captions ).length > 0 ) { + promise = promise + .then( function ( result ) { + // after having submitted the upload, fetch the entity id from page_props + // we might fail to retrieve the prop if it has not yet been created, + // so try at least 20 times... + var status = mw.message( 'mwe-upwiz-submitting-captions', Object.keys( captions ).length ), + title = mw.Title.makeTitle( 6, result.upload.filename ), + callable = details.getPageProp.bind( details, title, 'mediainfo_entity' ); + details.setStatus( status.text() ); + return details.attemptExecute( callable, 20 ); + } ) + // submit captions to wikibase + .then( this.submitCaptions.bind( this, captions ) ) + .catch( function ( code, result ) { + var languageCodes = Object.keys( captions ), + allLanguages = mw.UploadWizard.config.uwLanguages, + message = mw.message( + 'mwe-upwiz-error-submit-captions', + details.upload.imageinfo.canonicaltitle, + result.errors[ 0 ].html, + languageCodes.length + ).parse(), + $list, + i; + + // add captions as well, so they can easily be copied over + for ( i = 0; i < languageCodes.length; i++ ) { + $list = $( '<dl>' ); + $list.append( $( '<dt>' ).text( allLanguages[ languageCodes[ i ] ] ) ); + $list.append( $( '<dd>' ).text( captions[ languageCodes[ i ] ] ) ); + } + message += $list[ 0 ].outerHTML; + + details.upload.state = 'error'; + details.showError( 'caption-fail', message ); + + return $.Deferred().reject( code, result ).promise(); + } ); + } + + return promise.then( function () { + details.showIndicator( 'uploaded' ); + details.setStatus( mw.message( 'mwe-upwiz-published' ).text() ); + } ); + }, + + /** + * Attempt to resolve a promise (a couple of times, with an increasing delay). + * + * @param {function} callable + * @param {number} attempts + * @return {jQuery.Promise} + */ + attemptExecute: function ( callable, attempts ) { + var deferred = $.Deferred(), + attempt = 0, + retry; + + attempts = attempts || 10; + retry = function () { + callable().then( + deferred.resolve, + function () { + attempt++; + if ( attempt >= attempts ) { + // don't keep trying forever; just give up if we fail a few times + deferred.reject.apply( deferred, arguments ); + } else { + // try again on failure, with an increasing timeout... + setTimeout( retry, 2000 * attempt ); + } + + } + ); + }; + + retry(); + + return deferred.promise(); + }, + + /** + * @param {mw.Title} title + * @param {string} prop + * @return {jQuery.Promise} + */ + getPageProp: function ( title, prop ) { + return this.upload.api.get( { + action: 'query', + prop: 'pageprops', + titles: title.getPrefixedDb() + } ).then( function ( result ) { + var props, message; + + if ( result.query.pages[ 0 ].missing ) { + // page doesn't exist (yet) + message = mw.message( 'mwe-upwiz-error-pageprops-missing-page' ).parse(); + return $.Deferred().reject( 'pageprops-missing-page', { errors: [ { html: message } ] } ).promise(); + } + + props = result.query.pages[ 0 ].pageprops; + if ( !props || !( prop in props ) ) { + // prop doesn't exist (yet) + message = mw.message( 'mwe-upwiz-error-pageprops-missing-prop' ).parse(); + return $.Deferred().reject( 'pageprops-missing-prop', { errors: [ { html: message } ] } ).promise(); + } + + return props[ prop ]; + } ); + }, + + /** + * Post wikitext as edited here, to the file + * + * This function is only called if all input seems valid (which doesn't mean that we can't get + * an error, see #processError). + * + * @param {string} wikiText + * @return {jQuery.Promise} + */ + submitWikiText: function ( wikiText ) { + var params, + tags = [ 'uploadwizard' ]; + + this.firstPoll = ( new Date() ).getTime(); + + if ( this.upload.file.source ) { + tags.push( 'uploadwizard-' + this.upload.file.source ); + } + + params = { + action: 'upload', + filekey: this.upload.fileKey, + filename: this.getTitle().getMain(), + comment: 'User created page with ' + mw.UploadWizard.userAgent, + tags: mw.UploadWizard.config.CanAddTags ? tags : [], + // we can ignore upload warnings here, we've already checked + // when stashing the file + // not ignoring warnings would prevent us from uploading a file + // that is a duplicate of something in a foreign repo + ignorewarnings: true, + text: wikiText + }; + + // Only enable async publishing if file is larger than 10MiB + if ( this.upload.transportWeight > 10 * 1024 * 1024 ) { + params.async = true; + } + + params.text = this.getWikiText(); + return this.submitWikiTextInternal( params ); + }, + + /** + * @param {object} captions {<languagecode>: <caption text>} map + * @param {string} entityId + * @return {jQuery.Promise} + */ + submitCaptions: function ( captions, entityId ) { + var languages = Object.keys( captions ), + // `promises` will hold all promises for all captions; + // prefilling with a bogus promise to ensure $.when always + // resolves with an array of multiple results (if there's + // just 1, it would otherwise have just that one's arguments, + // instead of a multi-dimensional array of results) + promises = [ $.Deferred().resolve().promise() ], + callable, promise, language, text, i; + + for ( i = 0; i < languages.length; i++ ) { + language = languages[ i ]; + text = captions[ language ]; + callable = this.submitCaption.bind( this, entityId, language, text ); + + promise = this.attemptExecute( callable, 3 ); + promises.push( promise ); + } + + return $.when.apply( $, promises ); + }, + + /** + * @param {string} id + * @param {string} language + * @param {string} value + * @return {jQuery.Promise} + */ + submitCaption: function ( id, language, value ) { + var config = mw.UploadWizard.config.wikibase, + params = { + action: 'wbsetlabel', + id: id, + language: language, + value: value + }, + ajaxOptions = { url: config.api }; + + if ( !config.enabled ) { + return $.Deferred().reject().promise(); + } + + return this.upload.api.postWithEditToken( params, ajaxOptions ); + }, + + /** + * Perform the API call with given parameters (which is expected to publish this file) and + * handle the result. + * + * @param {Object} params API call parameters + * @return {jQuery.Promise} + */ + submitWikiTextInternal: function ( params ) { + var details = this, + apiPromise = this.upload.api.postWithEditToken( params ); + + return apiPromise + // process the successful (in terms of HTTP status...) API call first: + // there may be warnings or other issues with the upload that need + // to be dealt with + .then( this.validateWikiTextSubmitResult.bind( this, params ) ) + // making it here means the upload is a success, or it would've been + // rejected by now (either by HTTP status code, or in validateWikiTextSubmitResult) + .then( function ( result ) { + details.upload.extractImageInfo( result.upload.imageinfo ); + details.upload.thisProgress = 1.0; + details.upload.state = 'complete'; + return result; + } ) + // uh-oh - something went wrong! + .catch( function ( code, result ) { + uw.eventFlowLogger.logApiError( 'details', result ); + details.upload.state = 'error'; + details.processError( code, result ); + return $.Deferred().reject( code, result ); + } ) + .promise( { abort: apiPromise.abort } ); + }, + + /** + * Validates the result of a submission & returns a resolved promise with + * the API response if all went well, or rejects with error code & error + * message as you would expect from failed mediawiki API calls. + * + * @param {Object} params What we passed to the API that caused this response. + * @param {Object} result API result of an upload or status check. + * @return {jQuery.Promise} + */ + validateWikiTextSubmitResult: function ( params, result ) { + var wx, warningsKeys, existingFile, existingFileUrl, existingFileExt, ourFileExt, code, message, + details = this, + warnings = null, + ignoreTheseWarnings = false, + deferred = $.Deferred(); + + if ( result && result.upload && result.upload.result === 'Poll' ) { + // if async publishing takes longer than 10 minutes give up + if ( ( ( new Date() ).getTime() - this.firstPoll ) > 10 * 60 * 1000 ) { + return deferred.reject( 'server-error', { errors: [ { + code: 'server-error', + html: 'Unknown server error' + } ] } ); + } else { + if ( result.upload.stage === undefined ) { + return deferred.reject( 'no-stage', { errors: [ { + code: 'no-stage', + html: 'Unable to check file\'s status' + } ] } ); + } else { + // Messages that can be returned: + // * mwe-upwiz-queued + // * mwe-upwiz-publish + // * mwe-upwiz-assembling + this.setStatus( mw.message( 'mwe-upwiz-' + result.upload.stage ).text() ); + setTimeout( function () { + if ( details.upload.state !== 'aborted' ) { + details.submitWikiTextInternal( { + action: 'upload', + checkstatus: true, + filekey: details.upload.fileKey + } ).then( deferred.resolve, deferred.reject ); + } else { + deferred.resolve( 'aborted' ); + } + }, 3000 ); + + return deferred.promise(); + } + } + } + if ( result && result.upload && result.upload.warnings ) { + warnings = result.upload.warnings; + } + if ( warnings && warnings.exists ) { + existingFile = warnings.exists; + } else if ( warnings && warnings[ 'exists-normalized' ] ) { + existingFile = warnings[ 'exists-normalized' ]; + existingFileExt = mw.Title.normalizeExtension( existingFile.split( '.' ).pop() ); + ourFileExt = mw.Title.normalizeExtension( this.getTitle().getExtension() ); + + if ( existingFileExt !== ourFileExt ) { + delete warnings[ 'exists-normalized' ]; + ignoreTheseWarnings = true; + } + } + if ( warnings && warnings[ 'was-deleted' ] ) { + delete warnings[ 'was-deleted' ]; + ignoreTheseWarnings = true; + } + for ( wx in warnings ) { + if ( warnings.hasOwnProperty( wx ) ) { + // if there are other warnings, deal with those first + ignoreTheseWarnings = false; + } + } + if ( result && result.upload && result.upload.imageinfo ) { + return $.Deferred().resolve( result ); + } else if ( ignoreTheseWarnings ) { + params.ignorewarnings = 1; + return this.submitWikiTextInternal( params ); + } else if ( result && result.upload && result.upload.warnings ) { + if ( warnings.thumb || warnings[ 'thumb-name' ] ) { + code = 'error-title-thumbnail'; + message = mw.message( 'mwe-upwiz-error-title-thumbnail' ).parse(); + } else if ( warnings.badfilename ) { + code = 'title-invalid'; + message = mw.message( 'mwe-upwiz-error-title-invalid' ).parse(); + } else if ( warnings[ 'bad-prefix' ] ) { + code = 'title-senselessimagename'; + message = mw.message( 'mwe-upwiz-error-title-senselessimagename' ).parse(); + } else if ( existingFile ) { + existingFileUrl = mw.config.get( 'wgServer' ) + mw.Title.makeTitle( NS_FILE, existingFile ).getUrl(); + code = 'api-warning-exists'; + message = mw.message( 'mwe-upwiz-api-warning-exists', existingFileUrl ).parse(); + } else if ( warnings.duplicate ) { + code = 'upload-error-duplicate'; + message = mw.message( 'mwe-upwiz-upload-error-duplicate' ).parse(); + } else if ( warnings[ 'duplicate-archive' ] !== undefined ) { + // warnings[ 'duplicate-archive' ] may be '' (empty string) for revdeleted files + if ( this.upload.handler.isIgnoredWarning( 'duplicate-archive' ) ) { + // We already told the interface to ignore this warning, so + // let's steamroll over it and re-call this handler. + params.ignorewarnings = true; + return this.submitWikiTextInternal( params ); + } else { + // This should _never_ happen, but just in case.... + code = 'upload-error-duplicate-archive'; + message = mw.message( 'mwe-upwiz-upload-error-duplicate-archive' ).parse(); + } + } else { + warningsKeys = []; + $.each( warnings, function ( key ) { + warningsKeys.push( key ); + } ); + code = 'unknown-warning'; + message = mw.message( 'api-error-unknown-warning', warningsKeys.join( ', ' ) ).parse(); + } + + return $.Deferred().reject( code, { errors: [ { html: message } ] } ); + } else { + return $.Deferred().reject( 'this-info-missing', result ); + } + }, + + /** + * Create a recoverable error -- show the form again, and highlight the problematic field. + * + * @param {string} code + * @param {string} html Error message to show. + */ + recoverFromError: function ( code, html ) { + uw.eventFlowLogger.logError( 'details', { code: code, message: html } ); + this.upload.state = 'recoverable-error'; + this.dataDiv.morphCrossfade( '.detailsForm' ); + this.titleDetailsField.setErrors( [ { code: code, html: html } ] ); + }, + + /** + * Show error state, possibly using a recoverable error form + * + * @param {string} code Error code + * @param {string} html Error message + */ + showError: function ( code, html ) { + uw.eventFlowLogger.logError( 'details', { code: code, message: html } ); + this.showIndicator( 'error' ); + this.setStatus( html ); + }, + + /** + * Decide how to treat various errors + * + * @param {string} code Error code + * @param {Object} result Result from ajax call + */ + processError: function ( code, result ) { + var recoverable = [ + 'abusefilter-disallowed', + 'abusefilter-warning', + 'spamblacklist', + 'fileexists-shared-forbidden', + 'protectedpage', + 'titleblacklist-forbidden', + + // below are not actual API errors, but recoverable warnings that have + // been discovered in validateWikiTextSubmitResult and fabricated to resemble + // API errors and end up here to be dealt with + 'error-title-thumbnail', + 'title-invalid', + 'title-senselessimagename', + 'api-warning-exists', + 'upload-error-duplicate', + 'upload-error-duplicate', + 'upload-error-duplicate-archive', + 'unknown-warning' + ]; + + if ( code === 'badtoken' ) { + this.api.badToken( 'csrf' ); + // TODO Automatically try again instead of requiring the user to bonk the button + } + + if ( code === 'ratelimited' ) { + // None of the remaining uploads is going to succeed, and every failed one is going to + // ping the rate limiter again. + this.upload.wizard.steps.details.queue.abortExecuting(); + } else if ( code === 'http' && result && result.exception === 'abort' ) { + // This upload has just been aborted because an earlier one got the 'ratelimited' error. + // This could potentially also come up when an upload is removed by the user, but in that + // case the UI is invisible anyway, so whatever. + code = 'ratelimited'; + } + + if ( recoverable.indexOf( code ) > -1 ) { + this.recoverFromError( code, result.errors[ 0 ].html ); + return; + } + + this.showError( code, result.errors[ 0 ].html ); + }, + + setStatus: function ( s ) { + this.div.find( '.mwe-upwiz-file-status-line' ).html( s ).show(); + }, + + showIndicator: function ( statusStr ) { + this.div.find( '.mwe-upwiz-file-indicator' ) + .show() + .removeClass( 'mwe-upwiz-status-progress mwe-upwiz-status-error mwe-upwiz-status-uploaded' ) + .addClass( 'mwe-upwiz-status-' + statusStr ); + }, + + setVisibleTitle: function ( s ) { + $( this.submittingDiv ) + .find( '.mwe-upwiz-visible-file-filename-text' ) + .text( s ); + } + }; + +}( mediaWiki, mediaWiki.uploadWizard, jQuery, OO ) ); |