src/UploadDialog.js

/* global moment */

import Button from './Button';
import CdError from './CdError';
import ProcessDialog from './ProcessDialog';
import cd from './cd';
import controller from './controller';
import { canonicalUrlToPageName, defined, generateFixedPosTimestamp, getDbnameForHostname, zeroPad } from './utils-general';
import { createCheckboxField, createRadioField, createTextField, mixinUserOoUiClass, tweakUserOoUiClass } from './utils-oojs';
import { wrapHtml } from './utils-window';

/**
 * Button that inserts text in an input by click and looks like a link with dashed underline.
 *
 * @private
 */
class PseudoLink extends Button {
  /**
   * Create a pseudolink.
   *
   * @param {object} config
   */
  constructor(config) {
    super({
      classes: ['cd-pseudolink'],
      tooltip: cd.s('pseudolink-tooltip'),
      label: config.label,
      action: () => {
        config.input.setValue(config.text || config.label).focus();
      },
    });
  }
}

/**
 * @class Upload
 * @memberof external:mw
 * @see https://doc.wikimedia.org/mediawiki-core/master/js/mw.Upload.html
 */

/**
 * @class Dialog
 * @memberof external:mw.Upload
 * @see https://doc.wikimedia.org/mediawiki-core/master/js/mw.Upload.Dialog.html
 */

/**
 * Class that extends {@link external:mw.Upload.Dialog} and adds some logic we need. Uses
 * {@link ForeignStructuredUploadBookletLayout}, which in turn uses {@link ForeignStructuredUpload}.
 */
class UploadDialog extends mw.Upload.Dialog {
  /**
   * Create an upload dialog.
   *
   * @param {object} [config={}]
   */
  constructor(config = {}) {
    super(Object.assign({
      bookletClass: ForeignStructuredUploadBookletLayout,
      booklet: {
        target: mw.config.get('wgServerName') === 'commons.wikimedia.org' ? 'local' : 'shared',
      },
      classes: ['cd-uploadDialog'],
    }, config));
  }

  /**
   * OOUI native method that returns a "setup" process which is used to set up a window for use in a
   * particular context, based on the `data` argument.
   *
   * We load some stuff in here and modify the booklet's behavior (we can't do that in
   * {@link ForeignStructuredUploadBookletLayout#initialize}, because we need some data loaded
   * first).
   *
   * @param {object} data Dialog opening data
   * @returns {external:OO.ui.Process}
   * @see https://doc.wikimedia.org/oojs-ui/master/js/OO.ui.ProcessDialog.html#getSetupProcess
   * @see https://www.mediawiki.org/wiki/OOUI/Windows#Window_lifecycle
   * @ignore
   */
  getSetupProcess(data) {
    // This script is optional and used to improve description fields by using correct project
    // names and prefixes. With it, `wikt:fr:` will translate into "French Wiktionary" and
    // `fr.wiktionary.org` will translate into `wikt:fr:`.
    mw.loader.load('https://en.wikipedia.org/w/index.php?title=User:Jack_who_built_the_house/getUrlFromInterwikiLink.js&action=raw&ctype=text/javascript');

    const projectNameMsgName = 'project-localized-name-' + mw.config.get('wgDBname');
    const messagesPromise = controller.getApi().loadMessagesIfMissing([
      projectNameMsgName,

      // "I agree to irrevocably release this file under CC BY-SA 4.0"
      'upload-form-label-own-work-message-commons',

      // "Must contain a valid copyright tag"
      'mwe-upwiz-license-custom-explain',
      'mwe-upwiz-license-custom-url',
    ]);
    const enProjectNamePromise = cd.g.userLanguage === 'en' ?
      undefined :
      controller.getApi().getMessages(projectNameMsgName, { amlang: 'en' });

    return super.getSetupProcess(data)
      .next(async () => {
        let enProjectName;
        try {
          await messagesPromise;
          enProjectName = (
            (await enProjectNamePromise)?.[projectNameMsgName] ||
            cd.mws(projectNameMsgName)
          );
        } catch {
          // Empty
        }

        data.commentForm?.popPending();

        // For some reason there is no handling of network errors; the dialog just outputs "http".
        if (
          messagesPromise.state() === 'rejected' ||
          this.uploadBooklet.upload.getApi().state() === 'rejected'
        ) {
          this.handleError(new CdError(), 'cf-error-uploadimage', false);
          return;
        }

        this.uploadBooklet
          .on('changeSteps', this.updateActionLabels.bind(this))
          .on('submitUpload', this.executeAction.bind(this, 'upload'));
        this.uploadBooklet.setup(data.file, enProjectName);
      });
  }

  /**
   * OOUI native method that returns a "ready" process which is used to ready a window for use in a
   * particular context.
   *
   * We focus the title input here.
   *
   * @returns {external:OO.ui.Process}
   * @see https://doc.wikimedia.org/oojs-ui/master/js/OO.ui.ProcessDialog.html#getReadyProcess
   * @see https://www.mediawiki.org/wiki/OOUI/Windows#Window_lifecycle
   * @ignore
   */
  getReadyProcess() {
    return super.getReadyProcess().next(() => {
      this.uploadBooklet.controls?.title.input.focus();
    });
  }

  /**
   * OOUI native method that returns a process for taking action.
   *
   * We alter the handling of the `'upload'` and `'cancelupload'` actions.
   *
   * @param {string} action Symbolic name of the action.
   * @returns {external:OO.ui.Process}
   * @see https://doc.wikimedia.org/oojs-ui/master/js/OO.ui.ProcessDialog.html#getActionProcess
   * @ignore
   */
  getActionProcess(action) {
    if (action === 'upload') {
      let process = new OO.ui.Process(this.uploadBooklet.uploadFile());
      if (this.autosave) {
        process = process.next(() => {
          this.actions.setAbilities({ save: false });
          return this.executeAction('save').fail(() => {
            // Reset the ability
            this.uploadBooklet.onInfoFormChange();
          });
        });
      }
      return process;
    } else if (action === 'cancelupload') {
      // The upstream dialog calls `.initialize()` here which clears all inputs including the file.
      // We don't want that.
      return new OO.ui.Process(this.uploadBooklet.cancelUpload());
    }

    return super.getActionProcess(action);
  }

  /**
   * OOUI native method to get the height of the window body.
   *
   * @returns {number}
   * @see https://doc.wikimedia.org/oojs-ui/master/js/OO.ui.ProcessDialog.html#getBodyHeight
   * @ignore
   */
  getBodyHeight() {
    return 620;
  }

  /**
   * Update the labels of actions.
   *
   * @param {boolean} autosave Whether to save the upload when clicking the main button.
   * @protected
   */
  updateActionLabels(autosave) {
    this.autosave = autosave;
    if (this.autosave) {
      this.actions.get({ actions: ['upload', 'save'] }).forEach((action) => {
        action.setLabel(cd.s('ud-uploadandsave'));
      });
    } else {
      this.actions.get({ actions: ['upload', 'save'] }).forEach((action) => {
        action.setLabel(cd.mws(`upload-dialog-button-${action.getAction()}`));
      });
    }
  }

  /**
   * @class Error
   * @memberof external:OO.ui
   * @see https://doc.wikimedia.org/oojs-ui/master/js/OO.ui.Error.html
   */

  /**
   * OOUI native method.
   *
   * Here we use a hack to hide the second identical error message that can appear since we execute
   * two actions, not one ("Upload and save").
   *
   * @param {external:OO.ui.Error} errors
   * @protected
   */
  showErrors(errors) {
    this.hideErrors();

    super.showErrors(errors);
  }
}

/**
 * @class BookletLayout
 * @memberof external:mw.ForeignStructuredUpload
 * @see https://doc.wikimedia.org/mediawiki-core/master/js/mw.ForeignStructuredUpload.BookletLayout.html
 */

/**
 * Class extending
 * {@link external:mw.ForeignStructuredUpload.BookletLayout mw.ForeignStructuredUpload.BookletLayout}
 * and adding more details to the process of uploading a file using the
 * {@link ForeignStructuredUpload} model. See {@link UploadDialog} for the dialog itself.
 *
 * @augments external:mw.ForeignStructuredUpload.BookletLayout
 */
class ForeignStructuredUploadBookletLayout extends mw.ForeignStructuredUpload.BookletLayout {
  /**
   * Create a booklet layout for foreign structured upload.
   *
   * @param  {...any} args
   */
  constructor(...args) {
    super(...args);
  }

  /**
   * Setup the booklet with some data. (This method is not in the parent class - it's our own.)
   *
   * @param {File} file
   * @param {string} enProjectName
   */
  setup(file, enProjectName) {
    this.modifyUploadForm();
    this.modifyInfoForm();

    if (file) {
      this.setFile(file);
    }
    this.enProjectName = enProjectName;
    this
      .on('fileSaved', () => {
        // Pretend that the page hasn't changed to 'insert'
        this.setPage('info');
      });
    this.onPresetChange();
  }

  /**
   * Add content and logic to the upload form.
   *
   * @protected
   */
  modifyUploadForm() {
    // We hide that checkbox, replacing it with a radio select
    this.ownWorkCheckbox.setSelected(true);

    this.controls = {};

    const fieldset = this.uploadForm.items[0];

    // Hide everything related to the "own work" checkbox
    fieldset.items.slice(1).forEach((layout) => {
      layout.toggle(false);
    });

    this.controls.preset = createRadioField({
      label: cd.s('ud-preset'),
      options: [
        {
          data: 'projectScreenshot',
          label: cd.s(
            'ud-preset-projectscreenshot',
            cd.mws('project-localized-name-' + mw.config.get('wgDBname'))
          ),
          help: cd.s('ud-preset-projectscreenshot-help'),
          selected: true,
        },
        {
          data: 'mediawikiScreenshot',
          label: cd.s('ud-preset-mediawikiscreenshot'),
        },
        {
          data: 'ownWork',
          label: cd.s('ud-preset-ownwork'),
          help: wrapHtml(cd.mws('upload-form-label-own-work-message-commons')),
        },
        {
          data: 'no',
          label: cd.s('ud-preset-no'),
        },
      ],
    });

    this.controls.title = {};
    this.controls.title.input = new mw.widgets.TitleInputWidget({
      $overlay: this.$overlay,
      showMissing: false,
      showSuggestionsOnFocus: false,
      value: '',
    });
    this.insertSubjectPageButton = new PseudoLink({
      label: cd.page.mwTitle.getSubjectPage().getPrefixedText(),
      input: this.controls.title.input,
    });
    if (cd.page.mwTitle.isTalkPage()) {
      this.insertTalkPageButton = new PseudoLink({
        label: cd.page.name,
        input: this.controls.title.input,
      });
    }
    this.controls.title.field = new OO.ui.FieldLayout(this.controls.title.input, {
      label: cd.s('ud-preset-projectscreenshot-title'),
      help: $.cdMerge(
        $('<div>').append(this.insertSubjectPageButton.element),
        this.insertTalkPageButton ? $('<div>').append(this.insertTalkPageButton.element) : undefined,
        $('<div>').html(cd.sParse('ud-preset-projectscreenshot-title-help')),
      ),
      align: 'top',
      helpInline: true,
      classes: ['cd-uploadDialog-fieldLayout-internal'],
    });
    const projectScreenshotItem = this.controls.preset.select.findItemFromData('projectScreenshot');
    projectScreenshotItem.$label.append(this.controls.title.field.$element);

    this.controls.configure = createCheckboxField({
      value: 'configure',
      label: cd.s('ud-configure'),
    });
    fieldset.addItems([this.controls.preset.field, this.controls.configure.field]);

    this.controls.preset.select
      .on('select', this.onPresetChange.bind(this));
    projectScreenshotItem.radio.$input
      .on('focus', () => {
        this.controls.title.input.focus();
      });
    this.configureManuallySelected = false;
    this.controls.configure.input
      .on('change', this.onPresetChange.bind(this))
      .on('manualChange', (selected) => {
        this.configureManuallySelected = selected;
      });
    this.controls.title.input
      .on('change', this.onUploadFormChange.bind(this))
      .on('enter', this.emit.bind(this, 'submitUpload'));
  }

  /**
   * Handle change events to the upload form.
   *
   * @see https://doc.wikimedia.org/mediawiki-core/master/js/mw.ForeignStructuredUpload.BookletLayout.html#onUploadFormChange
   * @protected
   */
  async onUploadFormChange() {
    let valid = true;
    if (this.controls) {
      await this.controls.title.input.getValidity().catch(() => {
        valid = false;
      });
    }
    this.emit('uploadValid', this.selectFileWidget.getValue() && valid);
  }

  /**
   * Handle events changing the preset.
   *
   * @param {import('./utils-oojs')~RadioOptionWidget|boolean} itemOrSelected
   * @protected
   */
  onPresetChange(itemOrSelected) {
    const preset = this.controls.preset.select.findSelectedItem().getData();
    const titleInputDisabled = preset !== 'projectScreenshot';
    this.controls.title.input.setDisabled(titleInputDisabled);
    this.insertSubjectPageButton.setDisabled(titleInputDisabled);
    this.insertTalkPageButton?.setDisabled(titleInputDisabled);

    if (typeof itemOrSelected !== 'boolean') {
      // A radio option was selected, not the checkbox.
      if (preset === 'no') {
        this.configureManuallySelected = this.controls.configure.input.isSelected();
        this.controls.configure.input.setDisabled(true).setSelected(true);
      } else {
        this.controls.configure.input.setDisabled(false).setSelected(this.configureManuallySelected);
      }
    }

    this.emit('changeSteps', this.isInfoFormOmitted());
  }

  /**
   * Find out whether the information form should be omitted given the current state of controls.
   *
   * @returns {boolean}
   * @protected
   */
  isInfoFormOmitted() {
    const preset = this.controls.preset.select.findSelectedItem().getData();
    return (
      (preset === 'projectScreenshot' || preset === 'mediawikiScreenshot') &&
      !this.controls.configure.input.isSelected()
    );
  }

  /**
   * Find out whether the inputs we added to the information form should be disabled.
   *
   * @returns {boolean}
   * @protected
   */
  areAddedInputsDisabled() {
    return (
      this.controls.preset.select.findSelectedItem().getData() === 'ownWork' &&
      !this.controls.configure.input.isSelected()
    );
  }

  /**
   * Add content and logic to the information form.
   *
   * @protected
   */
  modifyInfoForm() {
    this.controls.source = createTextField({
      label: cd.s('ud-source'),
      required: true,
    });
    this.controls.author = createTextField({
      label: cd.s('ud-author'),
      required: true,
    });
    this.controls.license = createTextField({
      label: cd.s('ud-license'),
      required: true,
      classes: ['cd-input-monospace'],
      help: wrapHtml(
        cd.mws('mwe-upwiz-license-custom-explain', null, cd.mws('mwe-upwiz-license-custom-url')),
        { targetBlank: true }
      ),
    });

    this.controls.source.input.on('change', this.onInfoFormChange.bind(this));
    this.controls.author.input.on('change', this.onInfoFormChange.bind(this));
    this.controls.license.input.on('change', this.onInfoFormChange.bind(this));

    // Add items to the fieldset
    this.infoForm.items[1].addItems([
      this.controls.source.field,
      this.controls.author.field,
      this.controls.license.field,
    ], 2);
  }

  /**
   * Handle change events to the information form.
   *
   * @see https://doc.wikimedia.org/mediawiki-core/master/js/mw.ForeignStructuredUpload.BookletLayout.html#onInfoFormChange
   * @protected
   */
  async onInfoFormChange() {
    let valid = true;
    await Promise.all(
      [
        this.uploadPromise,
        this.filenameWidget.getValidity(),
        this.descriptionWidget.getValidity(),
        this.controls?.source.input.getValidity(),
        this.controls?.author.input.getValidity(),
        this.controls?.license.input.getValidity(),
      ].filter(defined)
    ).catch(() => {
      valid = false;
    });
    this.emit('infoValid', valid);
  }

  /**
   * Returns a {@link external:mw.ForeignStructuredUpload mw.ForeignStructuredUpload} with the
   * target specified in config.
   *
   * @returns {ForeignStructuredUpload}
   * @protected
   * @override
   */
  createUpload() {
    return new ForeignStructuredUpload(this.target);
  }

  /**
   * Native methods that uploads the file that was added in the upload form. Uses
   * {@link external:mw.Upload.BookletLayout#getFile getFile} to get the HTML5 file object.
   *
   * We add logic that changes the information form according to the user input in the upload form.
   *
   * @see
   * https://doc.wikimedia.org/mediawiki-core/master/js/mw.ForeignStructuredUpload.BookletLayout.html#uploadFile
   * @returns {external:jQueryPromise}
   * @protected
   */
  uploadFile() {
    const preset = this.controls.preset.select.findSelectedItem().getData();

    // Keep the inputs if the user pressed "Back" and didn't choose another preset.
    if (this.preset && preset !== this.preset) {
      this.clear();
    }
    this.preset = preset;

    let deferred = super.uploadFile();

    // Use UTC date to make it precise and avoid leaking the user's timezone
    const date = moment().utc().locale('en');

    // Use +2 days as the max date to avoid getting the user confused if they want to set the local
    // date nevertheless
    this.dateWidget.mustBeBefore = moment(date.clone().add(2, 'day').format('YYYY-MM-DD'));

    let pageName = '';
    let historyText = '';
    let hasIwPrefix;
    let filenameDate;
    if (this.preset === 'projectScreenshot' || this.preset === 'mediawikiScreenshot') {
      filenameDate = (
        this.getExactDateFromLastModified(this.getFile()) ||
        date.format('YYYY-MM-DD HH-mm-ss')
      );

      const title = this.controls.title.input.getMWTitle();
      if (title) {
        pageName = title.getPrefixedText();

        // Rough check, because we don't need to know for sure (that's just to make the description
        // more human-readable, with a project name instead of a domain).
        hasIwPrefix = /:[^ ]/.test(title.getMainText());

        if (hasIwPrefix) {
          // Avoid uppercasing the first character; that would make interwiki prefixes look weird.
          pageName = this.controls.title.input.getValue();
        }

        historyText = this.constructor.generateHistoryText(cd.g.serverName, pageName);
      }
    }

    switch (this.preset) {
      case 'projectScreenshot': {
        // * If the page name has an interwiki prefix, we don't know the project name.
        // * If the page name does not have an interwiki prefix, we don't know the interwiki prefix.
        // So, we use what is availble to us while trying to get/load missing parts if we can.

        const projectName = hasIwPrefix ? '' : this.enProjectName;
        const filenameMainPart = `${projectName} ${pageName}`
          .trim()
          .replace(new RegExp('[' + mw.config.get('wgIllegalFileChars', '') + ']', 'g'), '-');
        const pageNameOrProjectName = projectName || `[[${pageName}]]`;
        let projectNameOrPageName;
        if (!hasIwPrefix && pageName && getInterwikiPrefixForHostnameSync) {
          const prefix = getInterwikiPrefixForHostnameSync(
            cd.g.serverName,
            'commons.wikimedia.org'
          );
          projectNameOrPageName = `[[${prefix}${pageName}]]`;
        } else {
          projectNameOrPageName = pageNameOrProjectName;
        }
        const language = mw.config.get('wgContentLanguage');

        this.filenameWidget.setValue(`${filenameMainPart} ${filenameDate}`);
        this.descriptionWidget.setValue(`Screenshot of ${projectNameOrPageName}`);
        this.controls.source.input.setValue('Screenshot');
        this.controls.author.input.setValue(`${pageNameOrProjectName} authors${historyText}`);
        this.controls.license.input.setValue(
          cd.g.serverName.endsWith('.wikipedia.org') && !hasIwPrefix ?
            `{{Wikipedia-screenshot|1=${language}}}` :
            '{{Wikimedia-screenshot}}'
        );

        // Load the English project name for the file name if we can
        if (hasIwPrefix) {
          deferred = deferred
            .then(
              () => getUrlFromInterwikiLink?.(pageName),
              (error) => {
                throw ['badUpload', error];
              }
            )
            .then(
              (url) => {
                if (!url) {
                  throw [];
                }
                const hostname = new URL(url, cd.g.server).hostname;
                const dbname = getDbnameForHostname(hostname);
                return Promise.all([
                  controller.getApi().getMessages(`project-localized-name-${dbname}`, {
                    amlang: 'en',
                  }),
                  canonicalUrlToPageName(url),
                  hostname,
                ]);
              })
            .then(
              ([messages, unprefixedPageName, hostname]) => {
                if (!messages) return;
                const projectName = Object.values(messages)[0];
                const historyText = this.constructor.generateHistoryText(
                  hostname,
                  unprefixedPageName
                );
                this.filenameWidget.setValue(`${projectName} ${unprefixedPageName} ${filenameDate}`);
                this.controls.author.input.setValue(`${projectName} authors${historyText}`);
              },
              (error) => {
                // Unless there is something wrong with uploading, always resolve - this
                // functionality is non-essential.
                if (error[0] === 'badUpload') {
                  throw error[1];
                }
              }
            );
        }
        break;
      }

      case 'mediawikiScreenshot': {
        this.filenameWidget.setValue(`MediaWiki ${filenameDate}`);
        this.descriptionWidget.setValue(`Screenshot of MediaWiki`);
        this.controls.source.input.setValue('Screenshot');
        this.controls.author.input.setValue(`[[Special:Version|MediaWiki contributors]]`);
        this.controls.license.input.setValue('{{MediaWiki-screenshot}}');
        this.categoriesWidget.addTag('MediaWiki screenshots');
        break;
      }

      case 'ownWork': {
        this.controls.source.input.setValue(this.upload.config.format.ownwork);
        this.controls.author.input.setValue(this.upload.getDefaultUser());
        this.controls.license.input.setValue(this.upload.config.format.license);
        break;
      }
    }

    const omitted = this.isInfoFormOmitted();
    const addedInputsDisabled = this.areAddedInputsDisabled();
    this.filenameWidget.setDisabled(omitted);
    this.descriptionWidget.setDisabled(omitted);
    this.categoriesWidget.setDisabled(omitted);
    this.dateWidget.setDisabled(omitted);
    this.controls.source.input.setDisabled(omitted || addedInputsDisabled);
    this.controls.author.input.setDisabled(omitted || addedInputsDisabled);
    this.controls.license.input.setDisabled(omitted || addedInputsDisabled);

    deferred.catch(() => {
      // Hack to reenable the upload action after an error
      this.onUploadFormChange();
    });

    // If the promise failed, return the failed promise, not catched
    return deferred;
  }

  /**
   * Native method that gets last modified date from file.
   *
   * We make the last modified date to use UTC to make it precise and avoid leaking the user's
   * timezone. This is for screenshots and files on the computer; we keep the original behavior for
   * EXIF.
   *
   * See also
   * {@link ForeignStructuredUploadBookletLayout#getExactDateFromLastModified getExactDateFromLastModified}.
   *
   * @param {File} [file]
   * @returns {string|undefined} Last modified date from file
   * @see
   *   https://doc.wikimedia.org/mediawiki-core/master/js/mw.ForeignStructuredUpload.BookletLayout.html#getDateFromLastModified
   * @protected
   */
  getDateFromLastModified(file) {
    if (file?.lastModified) {
      return moment(file.lastModified).utc().format('YYYY-MM-DD');
    }
  }

  /**
   * Get the last modified date from file in UTC, including hours, minutes, and seconds.
   *
   * See also
   * {@link ForeignStructuredUploadBookletLayout#getDateFromLastModified getDateFromLastModified}.
   *
   * @param {File} file
   * @returns {string|undefined} Last modified date from file
   * @protected
   */
  getExactDateFromLastModified(file) {
    if (file.lastModified) {
      return moment(file.lastModified).utc().format('YYYY-MM-DD HH-mm-ss');
    }
  }

  /**
   * Native method that gets the page text from the
   * {@link external:mw.ForeignStructuredUpload.BookletLayout#infoForm information form}.
   *
   * @returns {string}
   * @see
   *   https://doc.wikimedia.org/mediawiki-core/master/js/mw.ForeignStructuredUpload.BookletLayout.html#getText
   * @protected
   */
  getText() {
    this.upload.setSource(this.controls.source.input.getValue());
    this.upload.setUser(this.controls.author.input.getValue());
    this.upload.setLicense(this.controls.license.input.getValue());
    return super.getText();
  }

  /**
   * Native method that saves the stash finalizes upload. Uses
   * {@link mw.Upload.ForeignStructuredUpload#getFilename getFilename}, and
   * {@link ForeignStructuredUpload#getText getText} to get details from the form.
   *
   * @returns {external:jQueryPromise}
   * @see
   *   https://doc.wikimedia.org/mediawiki-core/master/js/mw.ForeignStructuredUpload.BookletLayout.html#saveFile
   * @protected
   */
  saveFile() {
    this.categoriesWidget.addTag('Uploaded with Convenient Discussions');

    const promise = super.saveFile();
    promise.catch(() => {
      if (this.isInfoFormOmitted()) {
        this.cancelUpload();
      }
    });

    // If the promise failed, return the failed promise, not catched
    return promise;
  }

  /**
   * Cancel the upload. (This method is not in the parent class - it's our own.)
   *
   * @protected
   */
  cancelUpload() {
    this.onUploadFormChange();
    this.setPage('upload');
  }

  /**
   * Clear the values of the information form fields. Unlike the original dialog, we don't clear the
   * upload form, including the file input, when the user presses "Back". We don't clear the date
   * too. In the original dialog, there is not much to select on the first page apart from the file,
   * so clearing the file input would make sense there.
   *
   * @see
   *   https://doc.wikimedia.org/mediawiki-core/master/js/mw.ForeignStructuredUpload.BookletLayout.html#clear
   * @protected
   */
  clear() {
    // No idea how `.setValidityFlag(true)` is helpful; borrowed it from
    // `mw.Upload.BookletLayout.prototype.clear`. When we clear the fields that were filled in (e.g.
    // by choosing the "Own work" preset, pressing "Upload", then pressing "Back", then choosing "No
    // preset", then pressing "Upload" again), they end up invalid anyway.
    this.progressBarWidget.setProgress(0);
    this.filenameWidget.setValue(null).setValidityFlag(true);
    this.descriptionWidget.setValue(null).setValidityFlag(true);
    this.categoriesWidget.setValue([]);
    if (!this.dateWidget.getValue()) {
      this.dateWidget.setValidityFlag(true);
    }

    // Clear the fields we added as well. We add them on the "setup" step, so they aren't there
    // when `.clear()` initially runs.
    this.controls?.source.input.setValue(null).setValidityFlag(true);
    this.controls?.author.input.setValue(null).setValidityFlag(true);
    this.controls?.license.input.setValue(null).setValidityFlag(true);
  }

  /**
   * Generate the end part of text for the author input, linking page history.
   *
   * @param {string} hostname
   * @param {string} pageName
   * @returns {string}
   * @protected
   */
  static generateHistoryText(hostname, pageName) {
    if (!pageName) {
      return '';
    }
    const path = mw.util.getUrl(pageName, {
      action: 'history',
      offset: generateFixedPosTimestamp(new Date(), zeroPad(new Date().getUTCSeconds(), 2)),
    });
    const link = `https://${hostname}${path}`;
    return `, see the [${link} history]`;
  }
}

/**
 * @class ForeignStructuredUpload
 * @memberof external:mw
 * @see https://doc.wikimedia.org/mediawiki-core/master/js/mw.ForeignStructuredUpload.html
 */

/**
 * Class extending {@link external:mw.ForeignStructuredUpload mw.ForeignStructuredUpload} and
 * allowing to get and set additional fields. See {@link UploadDialog} for the dialog.
 *
 * @augments external:mw.ForeignStructuredUpload
 */
class ForeignStructuredUpload extends mw.ForeignStructuredUpload {
  /**
   * Create a foreign structured upload.
   *
   * @param {string} target
   */
  constructor(target) {
    super(target, {
      ...cd.getApiConfig(),
      ...cd.g.apiErrorFormatHtml,
    });
  }

  /**
   * Set the source.
   *
   * @param {string} source
   */
  setSource(source) {
    this.source = source;
  }

  /**
   * Set the author.
   *
   * @param {string} user
   */
  setUser(user) {
    this.user = user;
  }

  /**
   * Set the license.
   *
   * @param {string} license
   */
  setLicense(license) {
    this.license = license;
  }

  /**
   * Get the source.
   *
   * @returns {string}
   */
  getSource() {
    return this.source;
  }

  /**
   * Get the author.
   *
   * @returns {string}
   */
  getUser() {
    return this.user || this.getDefaultUser();
  }

  /**
   * Get the author as the parent method returns it.
   *
   * @returns {string}
   */
  getDefaultUser() {
    return super.getUser();
  }

  /**
   * Get the license.
   *
   * @returns {string}
   */
  getLicense() {
    return this.license;
  }
}

tweakUserOoUiClass(UploadDialog);
tweakUserOoUiClass(ForeignStructuredUploadBookletLayout)
tweakUserOoUiClass(ForeignStructuredUpload);
mixinUserOoUiClass(UploadDialog, ProcessDialog);

export default UploadDialog;