summaryrefslogtreecommitdiff
path: root/www/wiki/extensions/UploadWizard/resources/mw.UploadWizardDetails.js
diff options
context:
space:
mode:
Diffstat (limited to 'www/wiki/extensions/UploadWizard/resources/mw.UploadWizardDetails.js')
-rw-r--r--www/wiki/extensions/UploadWizard/resources/mw.UploadWizardDetails.js1308
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( /&amp;/g, '&' ).replace( /&quot;/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 ) );