mediawiki.special.upload: Use ES5 .forEach() instead of jQuery
[lhc/web/wiklou.git] / resources / src / mediawiki.special / mediawiki.special.upload.js
1 /**
2 * JavaScript for Special:Upload
3 *
4 * @private
5 * @class mw.special.upload
6 * @singleton
7 */
8
9 /* eslint-disable no-use-before-define */
10 /* global Uint8Array */
11
12 ( function ( mw, $ ) {
13 var uploadWarning, uploadTemplatePreview,
14 ajaxUploadDestCheck = mw.config.get( 'wgAjaxUploadDestCheck' ),
15 $license = $( '#wpLicense' );
16
17 window.wgUploadWarningObj = uploadWarning = {
18 responseCache: { '': ' ' },
19 nameToCheck: '',
20 typing: false,
21 delay: 500, // ms
22 timeoutID: false,
23
24 keypress: function () {
25 if ( !ajaxUploadDestCheck ) {
26 return;
27 }
28
29 // Find file to upload
30 if ( !$( '#wpDestFile' ).length || !$( '#wpDestFile-warning' ).length ) {
31 return;
32 }
33
34 this.nameToCheck = $( '#wpDestFile' ).val();
35
36 // Clear timer
37 if ( this.timeoutID ) {
38 clearTimeout( this.timeoutID );
39 }
40 // Check response cache
41 if ( this.responseCache.hasOwnProperty( this.nameToCheck ) ) {
42 this.setWarning( this.responseCache[ this.nameToCheck ] );
43 return;
44 }
45
46 this.timeoutID = setTimeout( function () {
47 uploadWarning.timeout();
48 }, this.delay );
49 },
50
51 checkNow: function ( fname ) {
52 if ( !ajaxUploadDestCheck ) {
53 return;
54 }
55 if ( this.timeoutID ) {
56 clearTimeout( this.timeoutID );
57 }
58 this.nameToCheck = fname;
59 this.timeout();
60 },
61
62 timeout: function () {
63 var $spinnerDestCheck, title;
64 if ( !ajaxUploadDestCheck || this.nameToCheck === '' ) {
65 return;
66 }
67 $spinnerDestCheck = $.createSpinner().insertAfter( '#wpDestFile' );
68 title = mw.Title.newFromText( this.nameToCheck, mw.config.get( 'wgNamespaceIds' ).file );
69
70 ( new mw.Api() ).get( {
71 formatversion: 2,
72 action: 'query',
73 // If title is empty, user input is invalid, the API call will produce details about why
74 titles: title ? title.getPrefixedText() : this.nameToCheck,
75 prop: 'imageinfo',
76 iiprop: 'uploadwarning'
77 } ).done( function ( result ) {
78 var
79 resultOut = '',
80 page = result.query.pages[ 0 ];
81 if ( page.imageinfo ) {
82 resultOut = page.imageinfo[ 0 ].html;
83 } else if ( page.invalidreason ) {
84 resultOut = mw.html.escape( page.invalidreason );
85 }
86 uploadWarning.processResult( resultOut, uploadWarning.nameToCheck );
87 } ).always( function () {
88 $spinnerDestCheck.remove();
89 } );
90 },
91
92 processResult: function ( result, fileName ) {
93 this.setWarning( result );
94 this.responseCache[ fileName ] = result;
95 },
96
97 setWarning: function ( warning ) {
98 var $warningBox = $( '#wpDestFile-warning' ),
99 $warning = $( $.parseHTML( warning ) );
100 mw.hook( 'wikipage.content' ).fire( $warning );
101 $warningBox.empty().append( $warning );
102
103 // Set a value in the form indicating that the warning is acknowledged and
104 // doesn't need to be redisplayed post-upload
105 if ( !warning ) {
106 $( '#wpDestFileWarningAck' ).val( '' );
107 $warningBox.removeAttr( 'class' );
108 } else {
109 $( '#wpDestFileWarningAck' ).val( '1' );
110 $warningBox.attr( 'class', 'mw-destfile-warning' );
111 }
112
113 }
114 };
115
116 window.wgUploadTemplatePreviewObj = uploadTemplatePreview = {
117
118 responseCache: { '': '' },
119
120 /**
121 * @param {jQuery} $element The element whose .val() will be previewed
122 * @param {jQuery} $previewContainer The container to display the preview in
123 */
124 getPreview: function ( $element, $previewContainer ) {
125 var template = $element.val(),
126 $spinner;
127
128 if ( this.responseCache.hasOwnProperty( template ) ) {
129 this.showPreview( this.responseCache[ template ], $previewContainer );
130 return;
131 }
132
133 $spinner = $.createSpinner().insertAfter( $element );
134
135 ( new mw.Api() ).get( {
136 formatversion: 2,
137 action: 'parse',
138 text: '{{' + template + '}}',
139 title: $( '#wpDestFile' ).val() || 'File:Sample.jpg',
140 prop: 'text',
141 pst: true,
142 uselang: mw.config.get( 'wgUserLanguage' )
143 } ).done( function ( result ) {
144 uploadTemplatePreview.processResult( result, template, $previewContainer );
145 } ).always( function () {
146 $spinner.remove();
147 } );
148 },
149
150 processResult: function ( result, template, $previewContainer ) {
151 this.responseCache[ template ] = result.parse.text;
152 this.showPreview( this.responseCache[ template ], $previewContainer );
153 },
154
155 showPreview: function ( preview, $previewContainer ) {
156 $previewContainer.html( preview );
157 }
158
159 };
160
161 $( function () {
162 // AJAX wpDestFile warnings
163 if ( ajaxUploadDestCheck ) {
164 // Insert an event handler that fetches upload warnings when wpDestFile
165 // has been changed
166 $( '#wpDestFile' ).change( function () {
167 uploadWarning.checkNow( $( this ).val() );
168 } );
169 // Insert a row where the warnings will be displayed just below the
170 // wpDestFile row
171 $( '#mw-htmlform-description tbody' ).append(
172 $( '<tr>' ).append(
173 $( '<td>' )
174 .attr( 'id', 'wpDestFile-warning' )
175 .attr( 'colspan', 2 )
176 )
177 );
178 }
179
180 if ( mw.config.get( 'wgAjaxLicensePreview' ) && $license.length ) {
181 // License selector check
182 $license.change( function () {
183 // We might show a preview
184 uploadTemplatePreview.getPreview( $license, $( '#mw-license-preview' ) );
185 } );
186
187 // License selector table row
188 $license.closest( 'tr' ).after(
189 $( '<tr>' ).append(
190 $( '<td>' ),
191 $( '<td>' ).attr( 'id', 'mw-license-preview' )
192 )
193 );
194 }
195
196 // fillDestFile setup
197 mw.config.get( 'wgUploadSourceIds' ).forEach( function ( sourceId ) {
198 $( '#' + sourceId ).change( function () {
199 var path, slash, backslash, fname;
200 if ( !mw.config.get( 'wgUploadAutoFill' ) ) {
201 return;
202 }
203 // Remove any previously flagged errors
204 $( '#mw-upload-permitted' ).attr( 'class', '' );
205 $( '#mw-upload-prohibited' ).attr( 'class', '' );
206
207 path = $( this ).val();
208 // Find trailing part
209 slash = path.lastIndexOf( '/' );
210 backslash = path.lastIndexOf( '\\' );
211 if ( slash === -1 && backslash === -1 ) {
212 fname = path;
213 } else if ( slash > backslash ) {
214 fname = path.slice( slash + 1 );
215 } else {
216 fname = path.slice( backslash + 1 );
217 }
218
219 // Clear the filename if it does not have a valid extension.
220 // URLs are less likely to have a useful extension, so don't include them in the
221 // extension check.
222 if (
223 mw.config.get( 'wgCheckFileExtensions' ) &&
224 mw.config.get( 'wgStrictFileExtensions' ) &&
225 Array.isArray( mw.config.get( 'wgFileExtensions' ) ) &&
226 $( this ).attr( 'id' ) !== 'wpUploadFileURL'
227 ) {
228 if (
229 fname.lastIndexOf( '.' ) === -1 ||
230 mw.config.get( 'wgFileExtensions' ).map( function ( element ) {
231 return element.toLowerCase();
232 } ).indexOf( fname.slice( fname.lastIndexOf( '.' ) + 1 ).toLowerCase() ) === -1
233 ) {
234 // Not a valid extension
235 // Clear the upload and set mw-upload-permitted to error
236 $( this ).val( '' );
237 $( '#mw-upload-permitted' ).attr( 'class', 'error' );
238 $( '#mw-upload-prohibited' ).attr( 'class', 'error' );
239 // Clear wpDestFile as well
240 $( '#wpDestFile' ).val( '' );
241
242 return false;
243 }
244 }
245
246 // Replace spaces by underscores
247 fname = fname.replace( / /g, '_' );
248 // Capitalise first letter if needed
249 if ( mw.config.get( 'wgCapitalizeUploads' ) ) {
250 fname = fname[ 0 ].toUpperCase() + fname.slice( 1 );
251 }
252
253 // Output result
254 if ( $( '#wpDestFile' ).length ) {
255 // Call decodeURIComponent function to remove possible URL-encoded characters
256 // from the file name (T32390). Especially likely with upload-form-url.
257 // decodeURIComponent can throw an exception if input is invalid utf-8
258 try {
259 $( '#wpDestFile' ).val( decodeURIComponent( fname ) );
260 } catch ( err ) {
261 $( '#wpDestFile' ).val( fname );
262 }
263 uploadWarning.checkNow( fname );
264 }
265 } );
266 } );
267 } );
268
269 // Add a preview to the upload form
270 $( function () {
271 /**
272 * Is the FileAPI available with sufficient functionality?
273 *
274 * @return {boolean}
275 */
276 function hasFileAPI() {
277 return window.FileReader !== undefined;
278 }
279
280 /**
281 * Check if this is a recognizable image type...
282 * Also excludes files over 10M to avoid going insane on memory usage.
283 *
284 * TODO: Is there a way we can ask the browser what's supported in `<img>`s?
285 *
286 * TODO: Put SVG back after working around Firefox 7 bug <https://phabricator.wikimedia.org/T33643>
287 *
288 * @param {File} file
289 * @return {boolean}
290 */
291 function fileIsPreviewable( file ) {
292 var known = [ 'image/png', 'image/gif', 'image/jpeg', 'image/svg+xml' ],
293 tooHuge = 10 * 1024 * 1024;
294 return ( known.indexOf( file.type ) !== -1 ) && file.size > 0 && file.size < tooHuge;
295 }
296
297 /**
298 * Format a file size attractively.
299 *
300 * TODO: Match numeric formatting
301 *
302 * @param {number} s
303 * @return {string}
304 */
305 function prettySize( s ) {
306 var sizeMsgs = [ 'size-bytes', 'size-kilobytes', 'size-megabytes', 'size-gigabytes' ];
307 while ( s >= 1024 && sizeMsgs.length > 1 ) {
308 s /= 1024;
309 sizeMsgs = sizeMsgs.slice( 1 );
310 }
311 return mw.msg( sizeMsgs[ 0 ], Math.round( s ) );
312 }
313
314 /**
315 * Show a thumbnail preview of PNG, JPEG, GIF, and SVG files prior to upload
316 * in browsers supporting HTML5 FileAPI.
317 *
318 * As of this writing, known good:
319 *
320 * - Firefox 3.6+
321 * - Chrome 7.something
322 *
323 * TODO: Check file size limits and warn of likely failures
324 *
325 * @param {File} file
326 */
327 function showPreview( file ) {
328 var $canvas,
329 ctx,
330 meta,
331 previewSize = 180,
332 $spinner = $.createSpinner( { size: 'small', type: 'block' } )
333 .css( { width: previewSize, height: previewSize } ),
334 thumb = mw.template.get( 'mediawiki.special.upload', 'thumbnail.html' ).render();
335
336 thumb
337 .find( '.filename' ).text( file.name ).end()
338 .find( '.fileinfo' ).text( prettySize( file.size ) ).end()
339 .find( '.thumbinner' ).prepend( $spinner ).end();
340
341 $canvas = $( '<canvas>' ).attr( { width: previewSize, height: previewSize } );
342 ctx = $canvas[ 0 ].getContext( '2d' );
343 $( '#mw-htmlform-source' ).parent().prepend( thumb );
344
345 fetchPreview( file, function ( dataURL ) {
346 var img = new Image(),
347 rotation = 0;
348
349 if ( meta && meta.tiff && meta.tiff.Orientation ) {
350 rotation = ( 360 - ( function () {
351 // See includes/media/Bitmap.php
352 switch ( meta.tiff.Orientation.value ) {
353 case 8:
354 return 90;
355 case 3:
356 return 180;
357 case 6:
358 return 270;
359 default:
360 return 0;
361 }
362 }() ) ) % 360;
363 }
364
365 img.onload = function () {
366 var info, width, height, x, y, dx, dy, logicalWidth, logicalHeight;
367
368 // Fit the image within the previewSizexpreviewSize box
369 if ( img.width > img.height ) {
370 width = previewSize;
371 height = img.height / img.width * previewSize;
372 } else {
373 height = previewSize;
374 width = img.width / img.height * previewSize;
375 }
376 // Determine the offset required to center the image
377 dx = ( 180 - width ) / 2;
378 dy = ( 180 - height ) / 2;
379 switch ( rotation ) {
380 // If a rotation is applied, the direction of the axis
381 // changes as well. You can derive the values below by
382 // drawing on paper an axis system, rotate it and see
383 // where the positive axis direction is
384 case 0:
385 x = dx;
386 y = dy;
387 logicalWidth = img.width;
388 logicalHeight = img.height;
389 break;
390 case 90:
391
392 x = dx;
393 y = dy - previewSize;
394 logicalWidth = img.height;
395 logicalHeight = img.width;
396 break;
397 case 180:
398 x = dx - previewSize;
399 y = dy - previewSize;
400 logicalWidth = img.width;
401 logicalHeight = img.height;
402 break;
403 case 270:
404 x = dx - previewSize;
405 y = dy;
406 logicalWidth = img.height;
407 logicalHeight = img.width;
408 break;
409 }
410
411 ctx.clearRect( 0, 0, 180, 180 );
412 ctx.rotate( rotation / 180 * Math.PI );
413 ctx.drawImage( img, x, y, width, height );
414 $spinner.replaceWith( $canvas );
415
416 // Image size
417 info = mw.msg( 'widthheight', logicalWidth, logicalHeight ) +
418 ', ' + prettySize( file.size );
419
420 $( '#mw-upload-thumbnail .fileinfo' ).text( info );
421 };
422 img.onerror = function () {
423 // Can happen for example for invalid SVG files
424 clearPreview();
425 };
426 img.src = dataURL;
427 }, mw.config.get( 'wgFileCanRotate' ) ? function ( data ) {
428 try {
429 meta = mw.libs.jpegmeta( data, file.fileName );
430 // eslint-disable-next-line no-underscore-dangle, camelcase
431 meta._binary_data = null;
432 } catch ( e ) {
433 meta = null;
434 }
435 } : null );
436 }
437
438 /**
439 * Start loading a file into memory; when complete, pass it as a
440 * data URL to the callback function. If the callbackBinary is set it will
441 * first be read as binary and afterwards as data URL. Useful if you want
442 * to do preprocessing on the binary data first.
443 *
444 * @param {File} file
445 * @param {Function} callback
446 * @param {Function} callbackBinary
447 */
448 function fetchPreview( file, callback, callbackBinary ) {
449 var reader = new FileReader();
450 if ( callbackBinary && 'readAsBinaryString' in reader ) {
451 // To fetch JPEG metadata we need a binary string; start there.
452 // TODO
453 reader.onload = function () {
454 callbackBinary( reader.result );
455
456 // Now run back through the regular code path.
457 fetchPreview( file, callback );
458 };
459 reader.readAsBinaryString( file );
460 } else if ( callbackBinary && 'readAsArrayBuffer' in reader ) {
461 // readAsArrayBuffer replaces readAsBinaryString
462 // However, our JPEG metadata library wants a string.
463 // So, this is going to be an ugly conversion.
464 reader.onload = function () {
465 var i,
466 buffer = new Uint8Array( reader.result ),
467 string = '';
468 for ( i = 0; i < buffer.byteLength; i++ ) {
469 string += String.fromCharCode( buffer[ i ] );
470 }
471 callbackBinary( string );
472
473 // Now run back through the regular code path.
474 fetchPreview( file, callback );
475 };
476 reader.readAsArrayBuffer( file );
477 } else if ( 'URL' in window && 'createObjectURL' in window.URL ) {
478 // Supported in Firefox 4.0 and above <https://developer.mozilla.org/en/DOM/window.URL.createObjectURL>
479 // WebKit has it in a namespace for now but that's ok. ;)
480 //
481 // Lifetime of this URL is until document close, which is fine
482 // for Special:Upload -- if this code gets used on longer-running
483 // pages, add a revokeObjectURL() when it's no longer needed.
484 //
485 // Prefer this over readAsDataURL for Firefox 7 due to bug reading
486 // some SVG files from data URIs <https://bugzilla.mozilla.org/show_bug.cgi?id=694165>
487 callback( window.URL.createObjectURL( file ) );
488 } else {
489 // This ends up decoding the file to base-64 and back again, which
490 // feels horribly inefficient.
491 reader.onload = function () {
492 callback( reader.result );
493 };
494 reader.readAsDataURL( file );
495 }
496 }
497
498 /**
499 * Clear the file upload preview area.
500 */
501 function clearPreview() {
502 $( '#mw-upload-thumbnail' ).remove();
503 }
504
505 /**
506 * Check if the file does not exceed the maximum size
507 *
508 * @param {File} file
509 * @return {boolean}
510 */
511 function checkMaxUploadSize( file ) {
512 var maxSize, $error;
513
514 function getMaxUploadSize( type ) {
515 var sizes = mw.config.get( 'wgMaxUploadSize' );
516
517 if ( sizes[ type ] !== undefined ) {
518 return sizes[ type ];
519 }
520 return sizes[ '*' ];
521 }
522
523 $( '.mw-upload-source-error' ).remove();
524
525 maxSize = getMaxUploadSize( 'file' );
526 if ( file.size > maxSize ) {
527 $error = $( '<p class="error mw-upload-source-error" id="wpSourceTypeFile-error">' +
528 mw.message( 'largefileserver', file.size, maxSize ).escaped() + '</p>' );
529
530 $( '#wpUploadFile' ).after( $error );
531
532 return false;
533 }
534
535 return true;
536 }
537
538 /* Initialization */
539 if ( hasFileAPI() ) {
540 // Update thumbnail when the file selection control is updated.
541 $( '#wpUploadFile' ).change( function () {
542 var file;
543 clearPreview();
544 if ( this.files && this.files.length ) {
545 // Note: would need to be updated to handle multiple files.
546 file = this.files[ 0 ];
547
548 if ( !checkMaxUploadSize( file ) ) {
549 return;
550 }
551
552 if ( fileIsPreviewable( file ) ) {
553 showPreview( file );
554 }
555 }
556 } );
557 }
558 } );
559
560 // Disable all upload source fields except the selected one
561 $( function () {
562 var $rows = $( '.mw-htmlform-field-UploadSourceField' );
563
564 $rows.on( 'change', 'input[type="radio"]', function ( e ) {
565 var currentRow = e.delegateTarget;
566
567 if ( !this.checked ) {
568 return;
569 }
570
571 $( '.mw-upload-source-error' ).remove();
572
573 // Enable selected upload method
574 $( currentRow ).find( 'input' ).prop( 'disabled', false );
575
576 // Disable inputs of other upload methods
577 // (except for the radio button to re-enable it)
578 $rows
579 .not( currentRow )
580 .find( 'input[type!="radio"]' )
581 .prop( 'disabled', true );
582 } );
583
584 // Set initial state
585 if ( !$( '#wpSourceTypeurl' ).prop( 'checked' ) ) {
586 $( '#wpUploadFileURL' ).prop( 'disabled', true );
587 }
588 } );
589
590 $( function () {
591 // Prevent losing work
592 var allowCloseWindow,
593 $uploadForm = $( '#mw-upload-form' );
594
595 if ( !mw.user.options.get( 'useeditwarning' ) ) {
596 // If the user doesn't want edit warnings, don't set things up.
597 return;
598 }
599
600 $uploadForm.data( 'origtext', $uploadForm.serialize() );
601
602 allowCloseWindow = mw.confirmCloseWindow( {
603 test: function () {
604 return $( '#wpUploadFile' ).get( 0 ).files.length !== 0 ||
605 $uploadForm.data( 'origtext' ) !== $uploadForm.serialize();
606 },
607
608 message: mw.msg( 'editwarning-warning' ),
609 namespace: 'uploadwarning'
610 } );
611
612 $uploadForm.submit( function () {
613 allowCloseWindow.release();
614 } );
615 } );
616
617 // Add tabindex to mw-editTools
618 $( function () {
619 // Function to change tabindex for all links within mw-editTools
620 function setEditTabindex( $val ) {
621 $( '.mw-editTools' ).find( 'a' ).each( function () {
622 $( this ).attr( 'tabindex', $val );
623 } );
624 }
625
626 // Change tabindex to 0 if user pressed spaced or enter while focused
627 $( '.mw-editTools' ).on( 'keypress', function ( e ) {
628 // Don't continue if pressed key was not enter or spacebar
629 if ( e.which !== 13 && e.which !== 32 ) {
630 return;
631 }
632
633 // Change tabindex only when main div has focus
634 if ( $( this ).is( ':focus' ) ) {
635 $( this ).find( 'a' ).first().focus();
636 setEditTabindex( '0' );
637 }
638 } );
639
640 // Reset tabindex for elements when user focused out mw-editTools
641 $( '.mw-editTools' ).on( 'focusout', function ( e ) {
642 // Don't continue if relatedTarget is within mw-editTools
643 if ( e.relatedTarget !== null && $( e.relatedTarget ).closest( '.mw-editTools' ).length > 0 ) {
644 return;
645 }
646
647 // Reset tabindex back to -1
648 setEditTabindex( '-1' );
649 } );
650
651 // Set initial tabindex for mw-editTools to 0 and to -1 for all links
652 $( '.mw-editTools' ).attr( 'tabindex', '0' );
653 setEditTabindex( '-1' );
654 } );
655 }( mediaWiki, jQuery ) );