drag-uploader.ts 31.6 KB
Newer Older
1
2
3
4
5
6
7
8
9
10
11
12
13
/*
 * This file is part of the TYPO3 CMS project.
 *
 * It is free software; you can redistribute it and/or modify it under
 * the terms of the GNU General Public License, either version 2
 * of the License, or any later version.
 *
 * For the full copyright and license information, please read the
 * LICENSE.txt file that was distributed with this source code.
 *
 * The TYPO3 project - inspiring people to share!
 */

14
15
import $ from 'jquery';
import moment from 'moment';
16
17
18
import {AjaxResponse} from '@typo3/core/ajax/ajax-response';
import {SeverityEnum} from './enum/severity';
import {MessageUtility} from './utility/message-utility';
19
import NProgress from 'nprogress';
20
21
22
23
24
import AjaxRequest from '@typo3/core/ajax/ajax-request';
import Modal from './modal';
import Notification from './notification';
import ImmediateAction from '@typo3/backend/action-button/immediate-action';
import Md5 from '@typo3/backend/hashing/md5';
25
26
27
28
29
30
31
32

/**
 * Possible actions for conflicts w/ existing files
 */
enum Action {
  OVERRIDE = 'replace',
  RENAME = 'rename',
  SKIP = 'cancel',
33
  USE_EXISTING = 'useExisting',
34
35
36
37
38
39
40
41
42
43
44
45
}

/**
 * Properties of a file as returned from the AJAX action; essential, this is a serialized instance of
 * \TYPO3\CMS\Core\Resource\File plus some extra properties (see FileController::flattenResultDataValue())
 */
interface UploadedFile {
  name: string;
  id: number;
  uid: number;
  icon: string;
  extension: string;
46
  permissions: { read: boolean; write: boolean };
47
48
49
50
51
52
53
  size: number;
  // formatted as ddmmyy
  date: string;

  mtime: Date;
  thumbUrl: string;
  type: string;
54
  path: string;
55
56
}

57
58
interface InternalFile extends File {
  lastModified: any;
59
  lastModifiedDate: any;
60
61
}

62
63
64
65
66
67
68
69
70
71
72
interface DragUploaderOptions {
  /**
   * CSS selector for the element where generated messages are inserted. (required)
   */
  outputSelector: string;
  /**
   * Color of the message text. (optional)
   */
  outputColor?: string;
}

73
74
75
76
77
78
interface FileConflict {
  original: UploadedFile;
  uploaded: InternalFile;
  action: Action;
}

79
80
81
82
83
84
interface FlashMessage {
  title: string,
  message: string,
  severity: number
}

85
86
87
88
89
90
91
92
93
class DragUploaderPlugin {
  public irreObjectUid: number;
  public $fileList: JQuery;
  public fileListColumnCount: number;
  public filesExtensionsAllowed: string;
  public fileDenyPattern: RegExp | null;
  public maxFileSize: number;
  public $trigger: JQuery;
  public target: string;
94
95
  public reloadUrl: string;
  public manualTable: boolean;
96
97
98
99

  /**
   * Array of files which are asked for being overridden
   */
100
  private askForOverride: Array<FileConflict> = [];
101
102
103
104

  private percentagePerFile: number = 1;

  private $body: JQuery;
105
106
107
108
  private readonly $element: JQuery;
  private readonly $dropzone: JQuery;
  private readonly $dropzoneMask: JQuery;
  private readonly fileInput: HTMLInputElement;
109
  private browserCapabilities: { fileReader: boolean; DnD: boolean; Progress: boolean };
110
  private readonly dropZoneInsertBefore: boolean;
111
  private queueLength: number;
112
  private readonly defaultAction: Action;
113
  private manuallyTriggered: boolean;
114
115
116
117
118
119

  constructor(element: HTMLElement) {
    this.$body = $('body');
    this.$element = $(element);
    const hasTrigger = this.$element.data('dropzoneTrigger') !== undefined;
    this.$trigger = $(this.$element.data('dropzoneTrigger'));
120
    this.defaultAction = this.$element.data('defaultAction') || Action.SKIP;
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
    this.$dropzone = $('<div />').addClass('dropzone').hide();
    this.irreObjectUid = this.$element.data('fileIrreObject');

    const dropZoneEscapedTarget = this.$element.data('dropzoneTarget');
    if (this.irreObjectUid && this.$element.nextAll(dropZoneEscapedTarget).length !== 0) {
      this.dropZoneInsertBefore = true;
      this.$dropzone.insertBefore(dropZoneEscapedTarget);
    } else {
      this.dropZoneInsertBefore = false;
      this.$dropzone.insertAfter(dropZoneEscapedTarget);
    }
    this.$dropzoneMask = $('<div />').addClass('dropzone-mask').appendTo(this.$dropzone);
    this.fileInput = <HTMLInputElement>document.createElement('input');
    this.fileInput.setAttribute('type', 'file');
    this.fileInput.setAttribute('multiple', 'multiple');
    this.fileInput.setAttribute('name', 'files[]');
    this.fileInput.classList.add('upload-file-picker');
    this.$body.append(this.fileInput);

    this.$fileList = $(this.$element.data('progress-container'));
141
    this.fileListColumnCount = $('thead tr:first th', this.$fileList).length + 1;
142
143
144
145
    this.filesExtensionsAllowed = this.$element.data('file-allowed');
    this.fileDenyPattern = this.$element.data('file-deny-pattern') ? new RegExp(this.$element.data('file-deny-pattern'), 'i') : null;
    this.maxFileSize = parseInt(this.$element.data('max-file-size'), 10);
    this.target = this.$element.data('target-folder');
146
    this.reloadUrl = this.$element.data('reload-url');
147
148
149
150

    this.browserCapabilities = {
      fileReader: typeof FileReader !== 'undefined',
      DnD: 'draggable' in document.createElement('span'),
151
      Progress: 'upload' in new XMLHttpRequest,
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
    };


    if (!this.browserCapabilities.DnD) {
      console.warn('Browser has no Drag and drop capabilities; cannot initialize DragUploader');
      return;
    }

    this.$body.on('dragover', this.dragFileIntoDocument);
    this.$body.on('dragend', this.dragAborted);
    this.$body.on('drop', this.ignoreDrop);

    this.$dropzone.on('dragenter', this.fileInDropzone);
    this.$dropzoneMask.on('dragenter', this.fileInDropzone);
    this.$dropzoneMask.on('dragleave', this.fileOutOfDropzone);
    this.$dropzoneMask.on('drop', (ev: JQueryEventObject) => this.handleDrop(<JQueryTypedEvent<DragEvent>>ev));

    this.$dropzone.prepend(
170
      '<button type="button" class="dropzone-hint" aria-labelledby="dropzone-title">' +
171
172
173
174
      '<div class="dropzone-hint-media">' +
      '<div class="dropzone-hint-icon"></div>' +
      '</div>' +
      '<div class="dropzone-hint-body">' +
175
      '<h3 id="dropzone-title" class="dropzone-hint-title">' +
176
177
178
179
180
181
      TYPO3.lang['file_upload.dropzonehint.title'] +
      '</h3>' +
      '<p class="dropzone-hint-message">' +
      TYPO3.lang['file_upload.dropzonehint.message'] +
      '</p>' +
      '</div>' +
182
      '</div>',
183
    ).on('click', () => {
184
185
      this.fileInput.click();
    });
186
187
188
189
    $('<button type="button" />')
      .addClass('dropzone-close')
      .attr('aria-label', TYPO3.lang['file_upload.dropzone.close'])
      .on('click', this.hideDropzone).appendTo(this.$dropzone);
190
191
192
193
194
195
196
197
198
199
200
201
202

    // no filelist then create own progress table
    if (this.$fileList.length === 0) {
      this.$fileList = $('<table />')
        .attr('id', 'typo3-filelist')
        .addClass('table table-striped table-hover upload-queue')
        .html('<tbody></tbody>').hide();

      if (this.dropZoneInsertBefore) {
        this.$fileList.insertAfter(this.$dropzone);
      } else {
        this.$fileList.insertBefore(this.$dropzone);
      }
203
      this.fileListColumnCount = 8;
204
      this.manualTable = true;
205
206
    }

207
208
    this.fileInput.addEventListener('change', (event: Event) => {
      this.hideDropzone(event);
209
210
211
      this.processFiles(Array.apply(null, this.fileInput.files));
    });

212
213
214
215
216
217
218
219
    // Allow the user to hide the dropzone with the "Escape" key
    // @todo Enable this also for manual (fake) tables when DragUploader can handle multiple full sized dropzones
    document.addEventListener('keydown', (event: KeyboardEvent): void => {
      if (event.code === 'Escape' && this.$dropzone.is(':visible') && !this.manualTable) {
        this.hideDropzone(event);
      }
    });

220
221
222
223
224
225
226
227
228
229
230
    this.bindUploadButton(hasTrigger === true ? this.$trigger : this.$element);
  }

  public showDropzone(): void {
    this.$dropzone.show();
  }

  /**
   *
   * @param {Event} event
   */
231
  public hideDropzone = (event: Event): void => {
232
233
234
    event.stopPropagation();
    event.preventDefault();
    this.$dropzone.hide();
235
236
237
    this.$dropzone.removeClass('drop-status-ok');
    // User manually hides the dropzone, so we can reset the flag
    this.manuallyTriggered = false;
238
239
240
241
242
243
  }

  /**
   * @param {Event} event
   * @returns {boolean}
   */
244
  public dragFileIntoDocument = (event: JQueryTypedEvent<DragEvent>): boolean => {
245
246
247
    event.stopPropagation();
    event.preventDefault();
    $(event.currentTarget).addClass('drop-in-progress');
248
249
250
251
252
    // Only show dropzone in case $element is currently visible. This prevents
    // use cases, such as opening the dropzone in a non visible tab in FormEngine.
    if (this.$element.get(0)?.offsetParent) {
      this.showDropzone();
    }
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
    return false;
  }

  /**
   *
   * @param {Event} event
   * @returns {Boolean}
   */
  public dragAborted = (event: Event): boolean => {
    event.stopPropagation();
    event.preventDefault();
    $(event.currentTarget).removeClass('drop-in-progress');
    return false;
  }

  public ignoreDrop = (event: Event): boolean => {
    // stops the browser from redirecting.
    event.stopPropagation();
    event.preventDefault();
    this.dragAborted(event);
    return false;
  }

  public handleDrop = (event: JQueryTypedEvent<DragEvent>): void => {
    this.ignoreDrop(event);
278
    this.hideDropzone(event);
279
280
281
282
283
284
285
286
287
288
    this.processFiles(event.originalEvent.dataTransfer.files);
  }

  /**
   * @param {FileList} files
   */
  public processFiles(files: FileList): void {
    this.queueLength = files.length;

    if (!this.$fileList.is(':visible')) {
289
      // Show the filelist (table)
290
      this.$fileList.show();
291
292
293
294
      // Remove hidden state from table container (also makes column selection etc. visible)
      this.$fileList.closest('.t3-filelist-table-container')?.removeClass('hidden');
      // Hide the information container
      this.$fileList.closest('form')?.find('.t3-filelist-info-container')?.hide();
295
296
297
298
299
300
    }

    NProgress.start();
    this.percentagePerFile = 1 / files.length;

    // Check for each file if is already exist before adding it to the queue
301
302
303
304
305
306
307
308
309
310
311
312
    const ajaxCalls: Promise<void>[] = [];
    Array.from(files).forEach((file: InternalFile) => {
      const request = new AjaxRequest(TYPO3.settings.ajaxUrls.file_exists).withQueryArguments({
        fileName: file.name,
        fileTarget: this.target,
      }).get({cache: 'no-cache'}).then(async (response: AjaxResponse): Promise<void> => {
        const data = await response.resolve();
        const fileExists = typeof data.uid !== 'undefined';
        if (fileExists) {
          this.askForOverride.push({
            original: data,
            uploaded: file,
313
            action: this.irreObjectUid ? Action.USE_EXISTING : this.defaultAction,
314
315
316
317
318
          });
          NProgress.inc(this.percentagePerFile);
        } else {
          new FileQueueItem(this, file, Action.SKIP);
        }
319
      });
320
      ajaxCalls.push(request);
321
322
    });

323
    Promise.all(ajaxCalls).then((): void => {
324
325
326
327
328
329
330
331
332
333
334
335
336
      this.drawOverrideModal();
      NProgress.done();
    });

    this.fileInput.value = '';
  }

  public fileInDropzone = (): void => {
    this.$dropzone.addClass('drop-status-ok');
  }

  public fileOutOfDropzone = (): void => {
    this.$dropzone.removeClass('drop-status-ok');
337
338
339
340
341
342
    // In case dropzone was not manually triggered and this is no manual (fake) table, hide it when leaving
    // @todo This is disabled for manual tables since this will currently lead to a flicker effect.
    //        Should be enabled when DragUploader is capable of dealing with multiple full sized dropzones.
    if (!this.manuallyTriggered && !this.manualTable) {
      this.$dropzone.hide();
    }
343
344
345
346
347
348
349
350
  }

  /**
   * Bind file picker to default upload button
   *
   * @param {Object} button
   */
  public bindUploadButton(button: JQuery): void {
351
    button.on('click', (event: Event) => {
352
353
354
      event.preventDefault();
      this.fileInput.click();
      this.showDropzone();
355
356
      // In case user manually triggers the dropzone, we add a flag
      this.manuallyTriggered = true;
357
358
359
360
361
362
    });
  }

  /**
   * Decrements the queue and renders a flash message if queue is empty
   */
363
  public decrementQueueLength(messages?: FlashMessage[]): void {
364
365
366
    if (this.queueLength > 0) {
      this.queueLength--;
      if (this.queueLength === 0) {
367
368
369
        const timeout: number = messages && messages.length ? 5000 : 0;
        if (timeout) {
          for (let flashMessage of messages) {
370
371
            Notification.showMessage(flashMessage.title, flashMessage.message, flashMessage.severity);
          }
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
        }
        if (this.reloadUrl && !this.manualTable) {
          // After 5 seconds (when flash messages have disappeared), provide the user the option to reload the module
          setTimeout(() => {
            Notification.info(
              TYPO3.lang['file_upload.reload.filelist'],
              TYPO3.lang['file_upload.reload.filelist.message'],
              10,
              [
                {
                  label: TYPO3.lang['file_upload.reload.filelist.actions.dismiss'],
                },
                {
                  label: TYPO3.lang['file_upload.reload.filelist.actions.reload'],
                  action: new ImmediateAction( (): void => {
                    top.list_frame.document.location.href = this.reloadUrl
                  })
                }
              ]
            );
          }, timeout)
        }
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
      }
    }
  }

  /**
   * Renders the modal for existing files
   */
  public drawOverrideModal(): void {
    const amountOfItems = Object.keys(this.askForOverride).length;
    if (amountOfItems === 0) {
      return;
    }
    const $modalContent = $('<div/>').append(
      $('<p/>').text(TYPO3.lang['file_upload.existingfiles.description']),
      $('<table/>', {class: 'table'}).append(
        $('<thead/>').append(
          $('<tr />').append(
            $('<th/>'),
            $('<th/>').text(TYPO3.lang['file_upload.header.originalFile']),
            $('<th/>').text(TYPO3.lang['file_upload.header.uploadedFile']),
414
415
416
417
            $('<th/>').text(TYPO3.lang['file_upload.header.action']),
          ),
        ),
      ),
418
419
420
421
422
    );
    for (let i = 0; i < amountOfItems; ++i) {
      const $record = $('<tr />').append(
        $('<td />').append(
          (this.askForOverride[i].original.thumbUrl !== ''
423
424
            ? $('<img />', {src: this.askForOverride[i].original.thumbUrl, height: 40})
            : $(this.askForOverride[i].original.icon)
425
          ),
426
427
        ),
        $('<td />').html(
428
          this.askForOverride[i].original.name + ' (' + (DragUploader.fileSizeAsString(this.askForOverride[i].original.size)) + ')' +
429
          '<br>' + moment(this.askForOverride[i].original.mtime).format('YYYY-MM-DD HH:mm'),
430
431
        ),
        $('<td />').html(
432
          this.askForOverride[i].uploaded.name + ' (' + (DragUploader.fileSizeAsString(this.askForOverride[i].uploaded.size)) + ')' +
433
434
435
436
437
438
          '<br>' +
          moment(
            this.askForOverride[i].uploaded.lastModified
              ? this.askForOverride[i].uploaded.lastModified
              : this.askForOverride[i].uploaded.lastModifiedDate,
          ).format('YYYY-MM-DD HH:mm'),
439
440
        ),
        $('<td />').append(
441
          $('<select />', {class: 'form-select t3js-actions', 'data-override': i}).append(
442
            (this.irreObjectUid ? $('<option/>').val(Action.USE_EXISTING).text(TYPO3.lang['file_upload.actions.use_existing']) : ''),
443
444
445
446
            $('<option />', {'selected': this.defaultAction === Action.SKIP})
              .val(Action.SKIP).text(TYPO3.lang['file_upload.actions.skip']),
            $('<option />', {'selected': this.defaultAction === Action.RENAME})
              .val(Action.RENAME).text(TYPO3.lang['file_upload.actions.rename']),
447
            $('<option />', {'selected': this.defaultAction === Action.OVERRIDE})
448
              .val(Action.OVERRIDE).text(TYPO3.lang['file_upload.actions.override']),
449
450
          ),
        ),
451
452
453
454
455
      );
      $modalContent.find('table').append('<tbody />').append($record);
    }

    const $modal = Modal.confirm(
456
      TYPO3.lang['file_upload.existingfiles.title'], $modalContent, SeverityEnum.warning,
457
458
459
460
461
      [
        {
          text: $(this).data('button-close-text') || TYPO3.lang['file_upload.button.cancel'] || 'Cancel',
          active: true,
          btnClass: 'btn-default',
462
          name: 'cancel',
463
464
465
466
        },
        {
          text: $(this).data('button-ok-text') || TYPO3.lang['file_upload.button.continue'] || 'Continue with selected actions',
          btnClass: 'btn-warning',
467
468
          name: 'continue',
        },
469
      ],
470
      ['modal-inner-scroll'],
471
472
473
474
475
476
    );
    $modal.find('.modal-dialog').addClass('modal-lg');

    $modal.find('.modal-footer').prepend(
      $('<span/>').addClass('form-inline').append(
        $('<label/>').text(TYPO3.lang['file_upload.actions.all.label']),
477
        $('<select/>', {class: 'form-select t3js-actions-all'}).append(
478
479
          $('<option/>').val('').text(TYPO3.lang['file_upload.actions.all.empty']),
          (this.irreObjectUid ? $('<option/>').val(Action.USE_EXISTING).text(TYPO3.lang['file_upload.actions.all.use_existing']) : ''),
480
481
482
483
484
485
          $('<option/>', {'selected': this.defaultAction === Action.SKIP})
            .val(Action.SKIP).text(TYPO3.lang['file_upload.actions.all.skip']),
          $('<option/>', {'selected': this.defaultAction === Action.RENAME})
            .val(Action.RENAME).text(TYPO3.lang['file_upload.actions.all.rename']),
          $('<option/>', {'selected': this.defaultAction === Action.OVERRIDE})
            .val(Action.OVERRIDE).text(TYPO3.lang['file_upload.actions.all.override']),
486
487
        ),
      ),
488
489
490
    );

    const uploader = this;
491
    $modal.on('change', '.t3js-actions-all', function (this: HTMLInputElement): void {
492
493
494
495
496
      const $this = $(this),
        value = $this.val();

      if (value !== '') {
        // mass action was selected, apply action to every file
497
        $modal.find('.t3js-actions').each((i: number, select: HTMLSelectElement) => {
498
499
500
501
502
503
504
505
          const $select = $(select),
            index = parseInt($select.data('override'), 10);
          $select.val(value).prop('disabled', 'disabled');
          uploader.askForOverride[index].action = <Action>$select.val();
        });
      } else {
        $modal.find('.t3js-actions').removeProp('disabled');
      }
506
    }).on('change', '.t3js-actions', function (this: HTMLInputElement): void {
507
508
509
      const $this = $(this),
        index = parseInt($this.data('override'), 10);
      uploader.askForOverride[index].action = <Action>$this.val();
510
    }).on('button.clicked', function (this: HTMLInputElement, e: Event): void {
511
512
513
514
      if ((<HTMLInputElement>(e.target)).name === 'cancel') {
        uploader.askForOverride = [];
        Modal.dismiss();
      } else if ((<HTMLInputElement>(e.target)).name === 'continue') {
515
        $.each(uploader.askForOverride, (key: number, fileInfo: FileConflict) => {
516
517
518
          if (fileInfo.action === Action.USE_EXISTING) {
            DragUploader.addFileToIrre(
              uploader.irreObjectUid,
519
              fileInfo.original,
520
521
            );
          } else if (fileInfo.action !== Action.SKIP) {
522
            new FileQueueItem(uploader, fileInfo.uploaded, fileInfo.action);
523
524
525
526
527
528
529
530
531
532
533
534
          }
        });
        uploader.askForOverride = [];
        Modal.dismiss();
      }
    }).on('hidden.bs.modal', () => {
      this.askForOverride = [];
    });
  }
}

class FileQueueItem {
535
536
537
538
539
  private readonly $row: JQuery;
  private readonly $progress: JQuery;
  private readonly $progressContainer: JQuery;
  private readonly file: InternalFile;
  private readonly override: Action;
540
  private readonly $selector: JQuery;
541
542
543
544
545
546
547
  private $iconCol: JQuery;
  private $fileName: JQuery;
  private $progressBar: JQuery;
  private $progressPercentage: JQuery;
  private $progressMessage: JQuery;
  private dragUploader: DragUploaderPlugin;

548
  constructor(dragUploader: DragUploaderPlugin, file: InternalFile, override: Action) {
549
550
551
552
553
    this.dragUploader = dragUploader;
    this.file = file;
    this.override = override;

    this.$row = $('<tr />').addClass('upload-queue-item uploading');
554
555
556
557
    if (!this.dragUploader.manualTable) {
      // Add selector cell, if this is a real table (e.g. not in FormEngine)
      this.$selector = $('<td />').addClass('col-selector').appendTo(this.$row);
    }
558
559
    this.$iconCol = $('<td />').addClass('col-icon').appendTo(this.$row);
    this.$fileName = $('<td />').text(file.name).appendTo(this.$row);
560
    this.$progress = $('<td />').attr('colspan', this.dragUploader.fileListColumnCount - this.$row.find('td').length).appendTo(this.$row);
561
562
563
564
565
566
567
568
569
570
571
572
573
574
    this.$progressContainer = $('<div />').addClass('upload-queue-progress').appendTo(this.$progress);
    this.$progressBar = $('<div />').addClass('upload-queue-progress-bar').appendTo(this.$progressContainer);
    this.$progressPercentage = $('<span />').addClass('upload-queue-progress-percentage').appendTo(this.$progressContainer);
    this.$progressMessage = $('<span />').addClass('upload-queue-progress-message').appendTo(this.$progressContainer);


    // position queue item in filelist
    if ($('tbody tr.upload-queue-item', this.dragUploader.$fileList).length === 0) {
      this.$row.prependTo($('tbody', this.dragUploader.$fileList));
      this.$row.addClass('last');
    } else {
      this.$row.insertBefore($('tbody tr.upload-queue-item:first', this.dragUploader.$fileList));
    }

575
576
577
578
579
580
581
582
    // Set a disabled checkbox to the selector column, if available
    if (this.$selector) {
      this.$selector.html(
        '<span class="form-check form-toggle">' +
        '<input type="checkbox" class="form-check-input t3js-multi-record-selection-check" disabled/>' +
        '</span>'
      );
    }
583

584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
    // set dummy file icon
    this.$iconCol.html('<span class="t3-icon t3-icon-mimetypes t3-icon-other-other">&nbsp;</span>');

    // check file size
    if (this.dragUploader.maxFileSize > 0 && this.file.size > this.dragUploader.maxFileSize) {
      this.updateMessage(TYPO3.lang['file_upload.maxFileSizeExceeded']
        .replace(/\{0\}/g, this.file.name)
        .replace(/\{1\}/g, DragUploader.fileSizeAsString(this.dragUploader.maxFileSize)));
      this.$row.addClass('error');

      // check filename/extension against deny pattern
    } else if (this.dragUploader.fileDenyPattern && this.file.name.match(this.dragUploader.fileDenyPattern)) {
      this.updateMessage(TYPO3.lang['file_upload.fileNotAllowed'].replace(/\{0\}/g, this.file.name));
      this.$row.addClass('error');

    } else if (!this.checkAllowedExtensions()) {
      this.updateMessage(TYPO3.lang['file_upload.fileExtensionExpected']
601
        .replace(/\{0\}/g, this.dragUploader.filesExtensionsAllowed),
602
603
604
605
606
607
608
609
610
611
612
613
      );
      this.$row.addClass('error');
    } else {
      this.updateMessage('- ' + DragUploader.fileSizeAsString(this.file.size));

      const formData = new FormData();
      formData.append('data[upload][1][target]', this.dragUploader.target);
      formData.append('data[upload][1][data]', '1');
      formData.append('overwriteExistingFiles', this.override);
      formData.append('redirect', '');
      formData.append('upload_1', this.file);

614
615
616
617
618
      // We use XMLHttpRequest as we need the `progress` event which isn't supported by fetch()
      const xhr = new XMLHttpRequest();
      xhr.onreadystatechange = (): void => {
        if (xhr.readyState === XMLHttpRequest.DONE) {
          if (xhr.status === 200) {
619
            try {
620
621
622
623
624
625
626
627
              const response = JSON.parse(xhr.responseText);
              if (!response.hasErrors) {
                this.uploadSuccess(response);
              } else {
                this.uploadError(xhr);
              }
            }
            catch (e) {
628
629
630
631
              // In case JSON can not be parsed, the upload failed due to server errors,
              // e.g. "POST Content-Length exceeds limit". Just handle as upload error.
              this.uploadError(xhr);
            }
632
633
634
635
          } else {
            this.uploadError(xhr);
          }
        }
636
      };
637
638
639
      xhr.upload.addEventListener('progress', (e: ProgressEvent) => this.updateProgress(e));
      xhr.open('POST', TYPO3.settings.ajaxUrls.file_process);
      xhr.send(formData);
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
    }
  }

  /**
   * @param {string} message
   */
  public updateMessage(message: string): void {
    this.$progressMessage.text(message);
  }

  /**
   * Remove the progress bar
   */
  public removeProgress(): void {
    if (this.$progress) {
      this.$progress.remove();
    }
  }

  public uploadStart(): void {
    this.$progressPercentage.text('(0%)');
    this.$progressBar.width('1%');
    this.dragUploader.$trigger.trigger('uploadStart', [this]);
  }

  /**
   * @param {XMLHttpRequest} response
   */
  public uploadError(response: XMLHttpRequest): void {
669
670
671
672
673
    const errorText = TYPO3.lang['file_upload.uploadFailed'].replace(/\{0\}/g, this.file.name);
    this.updateMessage(errorText);
    try {
      const jsonResponse = JSON.parse(response.responseText) as any;
      const messages = jsonResponse.messages as FlashMessage[];
674
      this.$progressPercentage.text('');
675
676
677
678
679
680
681
      if (messages && messages.length) {
        for (let flashMessage of messages) {
          Notification.showMessage(flashMessage.title, flashMessage.message, flashMessage.severity, 10);
        }
      }
    } catch (e) {
      // do nothing in case JSON could not be parsed
682
    }
683

684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
    this.$row.addClass('error');
    this.dragUploader.decrementQueueLength();
    this.dragUploader.$trigger.trigger('uploadError', [this, response]);
  }

  /**
   * @param {ProgressEvent} event
   */
  public updateProgress(event: ProgressEvent): void {
    const percentage = Math.round((event.loaded / event.total) * 100) + '%';
    this.$progressBar.outerWidth(percentage);
    this.$progressPercentage.text(percentage);
    this.dragUploader.$trigger.trigger('updateProgress', [this, percentage, event]);
  }

  /**
   * @param {{upload?: UploadedFile[]}} data
   */
702
  public uploadSuccess(data: { upload?: UploadedFile[], messages?: FlashMessage[] }): void {
703
    if (data.upload) {
704
      this.dragUploader.decrementQueueLength(data.messages);
705
      this.$row.removeClass('uploading');
706
707
      this.$row.prop('data-type', 'file');
      this.$row.prop('data-file-uid', data.upload[0].uid);
708
709
710
711
712
      this.$fileName.text(data.upload[0].name);
      this.$progressPercentage.text('');
      this.$progressMessage.text('100%');
      this.$progressBar.outerWidth('100%');

713
714
715
716
717
718
719
720
721
722
723
724
      const combinedIdentifier: string = String(data.upload[0].id);

      // Enable checkbox, if available
      if (this.$selector) {
        const checkbox: HTMLInputElement = <HTMLInputElement>this.$selector.find('input')?.get(0);
        if (checkbox) {
          checkbox.removeAttribute('disabled');
          checkbox.setAttribute('name', 'CBC[_FILE|' + Md5.hash(combinedIdentifier) + ']');
          checkbox.setAttribute('value', combinedIdentifier)
        }
      }

725
726
727
728
729
      // replace file icon
      if (data.upload[0].icon) {
        this.$iconCol
          .html(
            '<a href="#" class="t3js-contextmenutrigger" data-uid="'
730
            + combinedIdentifier + '" data-table="sys_file">'
731
            + data.upload[0].icon + '&nbsp;</span></a>',
732
733
734
735
736
737
          );
      }

      if (this.dragUploader.irreObjectUid) {
        DragUploader.addFileToIrre(
          this.dragUploader.irreObjectUid,
738
          data.upload[0],
739
740
741
742
743
744
        );
        setTimeout(
          () => {
            this.$row.remove();
            if ($('tr', this.dragUploader.$fileList).length === 0) {
              this.dragUploader.$fileList.hide();
745
              this.dragUploader.$fileList.closest('.t3-filelist-table-container')?.addClass('hidden');
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
              this.dragUploader.$trigger.trigger('uploadSuccess', [this, data]);
            }
          },
          3000);
      } else {
        setTimeout(
          () => {
            this.showFileInfo(data.upload[0]);
            this.dragUploader.$trigger.trigger('uploadSuccess', [this, data]);
          },
          3000);
      }
    }
  }

  /**
   * @param {UploadedFile} fileInfo
   */
  public showFileInfo(fileInfo: UploadedFile): void {
    this.removeProgress();
766
767
768
769
770
    if ((document.querySelector('#search_field') as HTMLInputElement)?.value) {
      // When search is active, the PATH column is always present so we add it
      $('<td />').text(fileInfo.path).appendTo(this.$row);
    }
    // Controls column is deliberately empty
771
772
    $('<td />').text('').appendTo(this.$row);
    $('<td />').text(TYPO3.lang['type.file'] + ' (' + fileInfo.extension.toUpperCase() + ')').appendTo(this.$row);
773
774
775
776
777
778
779
780
781
782
    $('<td />').text(DragUploader.fileSizeAsString(fileInfo.size)).appendTo(this.$row);
    let permissions = '';
    if (fileInfo.permissions.read) {
      permissions += '<strong class="text-danger">' + TYPO3.lang['permissions.read'] + '</strong>';
    }
    if (fileInfo.permissions.write) {
      permissions += '<strong class="text-danger">' + TYPO3.lang['permissions.write'] + '</strong>';
    }
    $('<td />').html(permissions).appendTo(this.$row);
    $('<td />').text('-').appendTo(this.$row);
783
784
785
786
787

    // add spacing cells when more columns are displayed (column selector)
    for (let i = this.$row.find('td').length; i < this.dragUploader.fileListColumnCount; i++) {
      $('<td />').text('').appendTo(this.$row);
    }
788
789
790
791
792
793
794
795
796
797
798
799
800
801
  }

  public checkAllowedExtensions(): boolean {
    if (!this.dragUploader.filesExtensionsAllowed) {
      return true;
    }
    const extension = this.file.name.split('.').pop();
    const allowed = this.dragUploader.filesExtensionsAllowed.split(',');

    return $.inArray(extension.toLowerCase(), allowed) !== -1;
  }
}

class DragUploader {
802
803
804
  public fileListColumnCount: number;
  public filesExtensionsAllowed: string;
  public fileDenyPattern: string;
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
  private static options: DragUploaderOptions;

  public static fileSizeAsString(size: number): string {
    const sizeKB: number = size / 1024;
    let str = '';

    if (sizeKB > 1024) {
      str = (sizeKB / 1024).toFixed(1) + ' MB';
    } else {
      str = sizeKB.toFixed(1) + ' KB';
    }
    return str;
  }

  /**
   * @param {number} irre_object
   * @param {UploadedFile} file
   */
  public static addFileToIrre(irre_object: number, file: UploadedFile): void {
824
    const message = {
825
      actionName: 'typo3:foreignRelation:insert',
826
827
828
829
830
      objectGroup: irre_object,
      table: 'sys_file',
      uid: file.uid,
    };
    MessageUtility.send(message);
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
  }

  public static init(): void {
    const me = this;
    const opts = me.options;

    // register the jQuery plugin "DragUploaderPlugin"
    $.fn.extend({
      dragUploader: function (options?: DragUploaderOptions | string): JQuery {
        return this.each((index: number, elem: HTMLElement): void => {
          const $this = $(elem);
          let data = $this.data('DragUploaderPlugin');
          if (!data) {
            $this.data('DragUploaderPlugin', (data = new DragUploaderPlugin(elem)));
          }
          if (typeof options === 'string') {
            data[options]();
          }
        });
850
      },
851
852
853
854
855
    });

    $(() => {
      $('.t3js-drag-uploader').dragUploader(opts);
    });
856

857
    // @todo Refactor the FormEngine integration of the uploader to instance new uploaders via event handlers
858
859
860
861
    const observer = new MutationObserver((): void => {
      $('.t3js-drag-uploader').dragUploader(opts);
    });
    observer.observe(document, {childList: true, subtree: true});
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
  }
}

/**
 * Function to apply the example plugin to the selected elements of a jQuery result.
 */
interface DragUploaderFunction {
  /**
   * Apply the example plugin to the elements selected in the jQuery result.
   *
   * @param options Options to use for this application of the example plugin.
   * @returns jQuery result.
   */
  (options: DragUploaderOptions): JQuery;
}

878
export const initialize = function (): void {
879
880
881
882
883
884
885
886
887
888
  DragUploader.init();

  // load required modules to hook in the post initialize function
  if (
    'undefined' !== typeof TYPO3.settings
    && 'undefined' !== typeof TYPO3.settings.RequireJS
    && 'undefined' !== typeof TYPO3.settings.RequireJS.PostInitializationModules
    && 'undefined' !== typeof TYPO3.settings.RequireJS.PostInitializationModules['TYPO3/CMS/Backend/DragUploader']
  ) {
    $.each(
889
      TYPO3.settings.RequireJS.PostInitializationModules['TYPO3/CMS/Backend/DragUploader'], (pos: number, moduleName: string) => {
890
        window.require([moduleName]);
891
      },
892
893
894
895
896
    );
  }
};

initialize();