Greasy Fork

Greasy Fork is available in English.

FR:Reborn - Agents extension

Upload QCs from your favorite agent to Imgur + QC server

当前为 2022-01-18 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         FR:Reborn - Agents extension
// @namespace    https://www.reddit.com/user/RobotOilInc
// @version      1.8.5
// @description  Upload QCs from your favorite agent to Imgur + QC server
// @author       RobotOilInc
// @match        https://www.basetao.com/index/myhome/myorder/*
// @match        https://basetao.com/index/myhome/myorder/*
// @match        https://www.cssbuy.com/*name=orderlist*
// @match        https://cssbuy.com/*name=orderlist*
// @match        https://superbuy.com/order*
// @match        https://www.superbuy.com/order*
// @match        https://wegobuy.com/order*
// @match        https://www.wegobuy.com/order*
// @grant        GM_addStyle
// @grant        GM_getResourceText
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_openInTab
// @grant        GM_registerMenuCommand
// @license      MIT
// @homepageURL  https://www.fashionreps.page/
// @supportURL   http://greasyfork.icu/en/scripts/426977-fr-reborn-agents-extension
// @include      https://www.basetao.com/index/orderphoto/itemimg/*
// @include      https://basetao.com/index/orderphoto/itemimg/*
// @require      https://unpkg.com/sweetalert2@11/dist/sweetalert2.js
// @require      https://unpkg.com/[email protected]/src/logger.js
// @require      https://unpkg.com/[email protected]/spark-md5.js
// @require      https://unpkg.com/@zip.js/[email protected]/dist/zip-full.js
// @require      https://unpkg.com/[email protected]/dist/FileSaver.js
// @require      https://unpkg.com/[email protected]/dist/jquery.js
// @require      https://unpkg.com/[email protected]/src/jquery.ajax-retry.js
// @require      https://unpkg.com/@sentry/[email protected]/build/bundle.js
// @require      https://unpkg.com/@sentry/[email protected]/build/bundle.tracing.js
// @require      https://unpkg.com/[email protected]/dist/swagger-client.browser.js
// @require      http://greasyfork.icu/scripts/11562-gm-config-8/code/GM_config%208+.js?version=66657
// @resource     sweetalert2 https://unpkg.com/[email protected]/dist/sweetalert2.min.css
// @run-at       document-end
// @icon         https://i.imgur.com/mYBHjAg.png
// ==/UserScript==

// Define default toast
const Toast = Swal.mixin({
  showConfirmButton: false,
  timerProgressBar: true,
  position: 'top-end',
  timer: 4000,
  toast: true,
  didOpen: (toast) => {
    toast.addEventListener('mouseenter', Swal.stopTimer);
    toast.addEventListener('mouseleave', Swal.resumeTimer);
  },
});

/**
 * @param text {string}
 * @param type {null|('success'|'error'|'warning'|'info')}
 */
const Snackbar = function (text, type = null) {
  Toast.fire({ title: text, icon: type != null ? type : 'info' });
};

/**
 * @return {Promise<boolean>}
 */
const ConfirmDialog = async function () {
  return new Promise((resolve) => {
    Swal.fire({
      title: 'Are you sure?',
      icon: 'warning',
      showCancelButton: true,
      confirmButtonColor: '#3085d6',
      cancelButtonColor: '#d33',
      confirmButtonText: 'Yes',
    }).then((result) => resolve(result.isConfirmed));
  });
};

class ImgurError extends Error {
  /**
   * @param message {string}
   * @param previous {Error}
   */
  constructor(message, previous) {
    super(message);
    this.name = 'ImgurError';
    this.previous = previous;
  }
}

class ImgurSlowdownError extends ImgurError {
  constructor(message, previous) {
    super(`Imgur is telling us to slow down:\n${message}`, previous);
  }
}

// Possible sources
const WEBSITE_TAOBAO = 'taobao';
const WEBSITE_YUPOO = 'yupoo';
const WEBSITE_WEIDIAN = 'weidian';
const WEBSITE_1688 = '1688';
const WEBSITE_UNKNOWN = 'unknown';

/**
 * @param url {string}
 * @returns {boolean}
 */
const isUrl = (url) => { try { return Boolean(new URL(url)); } catch (e) { return false; } };

/**
 * @param originalUrl {string}
 * @param website {string}
 * @returns {string}
 */
const cleanPurchaseUrl = (originalUrl, website) => {
  const idMatches = originalUrl.match(/[?&]id=(\d+)|[?&]itemID=(\d+)|\/?[albums]\/(\d+)|offer\/(\d+)/i);
  const authorMatches = originalUrl.match(/https?:\/\/(\w+)\.x\.yupoo\.com/);

  if (website === WEBSITE_TAOBAO && idMatches[1].length !== 0) {
    return `https://item.taobao.com/item.htm?id=${idMatches[1]}`;
  }

  if (website === WEBSITE_WEIDIAN && idMatches[2].length !== 0) {
    return `https://weidian.com/item.html?itemID=${idMatches[2]}`;
  }

  if (website === WEBSITE_YUPOO && idMatches[3].length !== 0 && authorMatches[1].length !== 0) {
    return `https://${authorMatches[1]}.x.yupoo.com/albums/${idMatches[3]}`;
  }

  if (website === WEBSITE_1688 && idMatches[4].length !== 0) {
    return `https://detail.1688.com/offer/${idMatches[4]}.html`;
  }

  // Just return the original URL with some clean up
  return originalUrl.replace('http://', 'https://').replace('?uid=1', '').trim();
};

/**
 * @param originalUrl {string}
 * @returns {string}
 */
const determineWebsite = (originalUrl) => {
  if (originalUrl.indexOf('taobao.com') !== -1 || originalUrl.indexOf('detail.tmall.com') !== -1) {
    return WEBSITE_TAOBAO;
  }

  if (originalUrl.indexOf('yupoo.com') !== -1) {
    return WEBSITE_YUPOO;
  }

  if (originalUrl.indexOf('weidian.com') !== -1 || originalUrl.indexOf('koudai.com') !== -1) {
    return WEBSITE_WEIDIAN;
  }

  if (originalUrl.indexOf('1688.com') !== -1) {
    return WEBSITE_1688;
  }

  return WEBSITE_UNKNOWN;
};

const removeWhitespaces = (item) => item.trim().replace(/\s(?=\s)/g, '');

/**
 * @param input {string}
 * @param maxLength {number} must be an integer
 * @returns {string}
 */
const truncate = function (input, maxLength) {
  function isHighSurrogate(codePoint) {
    return codePoint >= 0xd800 && codePoint <= 0xdbff;
  }

  function isLowSurrogate(codePoint) {
    return codePoint >= 0xdc00 && codePoint <= 0xdfff;
  }

  function getLength(segment) {
    if (typeof segment !== 'string') {
      throw new Error('Input must be string');
    }

    const charLength = segment.length;
    let byteLength = 0;
    let codePoint = null;
    let prevCodePoint = null;
    for (let i = 0; i < charLength; i++) {
      codePoint = segment.charCodeAt(i);
      // handle 4-byte non-BMP chars
      // low surrogate
      if (isLowSurrogate(codePoint)) {
        // when parsing previous hi-surrogate, 3 is added to byteLength
        if (prevCodePoint != null && isHighSurrogate(prevCodePoint)) {
          byteLength += 1;
        } else {
          byteLength += 3;
        }
      } else if (codePoint <= 0x7f) {
        byteLength += 1;
      } else if (codePoint >= 0x80 && codePoint <= 0x7ff) {
        byteLength += 2;
      } else if (codePoint >= 0x800 && codePoint <= 0xffff) {
        byteLength += 3;
      }
      prevCodePoint = codePoint;
    }

    return byteLength;
  }

  if (typeof input !== 'string') {
    throw new Error('Input must be string');
  }

  const charLength = input.length;
  let curByteLength = 0;
  let codePoint;
  let segment;

  for (let i = 0; i < charLength; i += 1) {
    codePoint = input.charCodeAt(i);
    segment = input[i];

    if (isHighSurrogate(codePoint) && isLowSurrogate(input.charCodeAt(i + 1))) {
      i += 1;
      segment += input[i];
    }

    curByteLength += getLength(segment);

    if (curByteLength === maxLength) {
      return input.slice(0, i + 1);
    }
    if (curByteLength > maxLength) {
      return input.slice(0, i - segment.length + 1);
    }
  }

  return input;
};

/**
 * @param url {string}
 * @returns {Promise<string>}
 */
const toDataURL = (url) => fetch(url)
  .then((response) => response.blob())
  .then((blob) => new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onloadend = () => resolve(reader.result);
    reader.onerror = reject;
    reader.readAsDataURL(blob);
  }));

/**
 * @param base64Data {string}
 * @returns {Promise<string>}
 */
const WebpToJpg = function (base64Data) {
  return new Promise((resolve) => {
    const image = new Image();
    image.src = base64Data;

    image.onload = () => {
      const canvas = document.createElement('canvas');
      const context = canvas.getContext('2d');

      canvas.width = image.width;
      canvas.height = image.height;
      context.drawImage(image, 0, 0);

      resolve(canvas.toDataURL('image/jpeg'));
    };
  });
};

class BaseTaoElement {
  constructor($element) {
    this.element = $element;
    this.baseElement = $element.parents('tr').find('td[colspan=\'2\']').first();

    this.imageUrls = [];

    this.qcImagesUrl = $element.parent().attr('href').trim();
    this.url = this.baseElement.find('.goodsname_color').first().attr('href').trim();

    this.website = determineWebsite(this.url);
    this.title = truncate(removeWhitespaces(this.baseElement.find('.goodsname_color').first().text()), 255);

    // Item sizing (if any)
    let sizing = removeWhitespaces(this.baseElement.find('.size_color_color:nth-child(2) > u').text());
    sizing = (sizing !== '-' && sizing.toLowerCase() !== 'no') ? truncate(sizing, 255) : '';
    this.sizing = sizing.length !== 0 ? sizing : null;

    // Item color (if any)
    let color = removeWhitespaces(this.baseElement.find('.size_color_color:nth-child(1) > u').text());
    color = (color !== '-' && color.toLowerCase() !== 'no') ? truncate(color, 255) : '';
    this.color = color.length !== 0 ? color : null;

    // Item and shipping prices
    this.itemPrice = `CNY ${removeWhitespaces($element.parents('tr').find('td:nth-child(2) > span').first().text())}`;
    this.freightPrice = `CNY ${removeWhitespaces($element.parents('tr').find('td:nth-child(3) > span').first().text())}`;

    // Item weight
    const weight = removeWhitespaces($element.parents('tr').find('td:nth-child(5) > span').first().text());
    this.weight = weight.length !== 0 ? `${weight} gram` : null;

    // eslint-disable-next-line prefer-destructuring
    this.orderId = this.qcImagesUrl.match(/itemimg\/(\d+)\.html/)[1];

    // Set at a later date, if ever
    this.albumId = null;
  }

  /**
   * @return {string}
   */
  get albumUrl() {
    return `https://imgur.com/a/${this.albumId}`;
  }

  /**
   * @param imageUrls {string[]}
   */
  set images(imageUrls) {
    this.imageUrls = imageUrls;
  }

  /**
   * @returns {string}
   */
  get purchaseUrl() {
    return cleanPurchaseUrl(this.url, this.website);
  }
}

const ImgurIcon = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAA7EAAAOxAGVKw4bAAACl0lEQVQ4jX2TS0jUURSHv3v/d9TRMh0tzYrQiF7qpgijaFERRhlkxSwyiggXKdEiCjLBCi10UVirau0mCgzMVQQ96EHv1GxRWWBoKTbK2H+c+2gx6ZRIZ3U238c59/yuAFjQebQSqAeKcSj+VwINdAHn+re13hJ/4Ju4/2IziQB2K6B+JlgKQSglg0wVJF2lEhAeFseAH2HQj4ADBPUKKJ6C4nBxdRVrQ0VkBjL4aWOMuTi/nEHjkEKyVGWy42EzX8eHAYrV5M7Ot7jLETa1r+TE4F0ej39DegrpKYSnkCrRN4fWsSiYkxA4lJyEo40DzPmeQnjPWc5kb2RlaghrNNZonNFYneits0ghkqs63xK7MITpjVFVtZneD1958eA9+7JLEuA0SQoS30wkBbp5hKZD+ykpLaSxqY0zDQdIX59LXV9nEvxLMksEGNX+lEB5Hw3OOYJpqVxurWVBxWJqP9xAS4n0EpGQgP0DZMkURuLR5ATRqM/J49ep2FlGwfaFHOluI6ZjOBPHGo2whtPzk28SkmkMx8b+OrcTOOeYm5tF11g/Ezo2NbK0hguLy5n7RnMpdyvr0gsYjUcxLhkcCYJdlRtoPNdGub+McP4anNEIa2lZspPYvSGuXuvgYPg8zTmbeT7y+d9AZgUr40IKZZwhLz+b9o4Gbqtuls+ez8T9CLU1V/D9CYy1hMpyEMeyMJ6bjLOWONFVUlqIcIL8vGzKt9RR9DKDwfZvVB++REtLNaWlRQRWBDE1s5Nworq8oFr1Y3BgJJyaFmBveCOPH/Vwp+MZkUiUL33fef3qE32BYdJP5SHS5PTPVOP5uud9UK16Z7Rd9vRJb64xTlrjcM4xOjrOr4WWYN28JCzQCN4CNf3bWm/9BkZ7QCBmf01HAAAAAElFTkSuQmCC';
const Loading = 'data:image/gif;base64,R0lGODlhEAAQAPMAAP////r6+paWlr6+vnx8fIyMjOjo6NDQ0ISEhLa2tq6urvDw8MjIyODg4J6enqampiH/C05FVFNDQVBFMi4wAwEAAAAh+QQJCgAAACwAAAAAEAAQAAAETBDISau9NQjCiUxDYGmdhBCFkRUlcLCFOA3oNgXsQG2HRh0EAYWDIU6MGSSAR1G4ghRa7KjIUXCog6QzpRhYiC1HILsOEuJxGcNuTyIAIfkECQoAAAAsAAAAABAAEAAABGIQSGkQmzjLQkTTWDAgRGmAgMGVhAIESxZwBUMgSyAUATYQPIBg8OIQJwLCQbJkdjAlUCA6KfU0VEmyGWgenpNfcCAoEo6SmWtBYtCukxhAwQKeQAYWYgAHNZIFKBoMCHcTEQAh+QQJCgAAACwAAAAAEAAQAAAEWhDIOZejGDNysgyDQBAIGWRGMa7jgAVq0TUj0lEDUZxArvAU0a1nAAQOrsnIA1gqCZ6AUzI4nAxJwIEgyAQUhCQsjDmUCI1jDEhlrQrFV+ksGLApWwYz41jsIwAh+QQJCgAAACwAAAAAEAAQAAAEThDISau9IIQahiCEMGhCQxkFqBLFZ0pBWhzSkYIvMLAb/OGTBII2+QExSEBjuexhVgrKAZGgqKKTGGFgBc00Np71cVsVDJVo5ydyJt/wCAAh+QQJCgAAACwAAAAAEAAQAAAEWhDISau9OAxBiBjBtRRdSRTGpRRHeJBFOKWALAXkAKQNoSwWBgFRQAA4Q5DkgOwwhCXBYTJAdAQAopVhWSgIjR1gcLLVQrQbrBV4CcwSA8l0Alo0yA8cw+9TIgAh+QQJCgAAACwAAAAAEAAQAAAEWhDISau9WA5CxAhWMDDAwXGFQR0IgQRgWRBF7JyEQgXzIC2MFkc1MQkonMbAhyQ0Y5pBg0MREA4UwwnBWGhoUIAC55DwaAcQrIXATgyzE/bwCQ2sBGZmz7dEAAA7';

class Imgur {
  /**
   * @param version {string}
   * @param config {GM_config}
   * @param agent {string}
   * @constructor
   */
  constructor(version, config, agent) {
    this.version = version;
    this.agent = agent;

    if (config.get('imgurApi') === 'imgur') {
      this.headers = { authorization: `Client-ID ${config.get('imgurClientId')}` };
      this.host = config.get('imgurApiHost');

      return;
    }

    if (config.get('imgurApi') === 'rapidApi') {
      this.headers = {
        authorization: `Bearer ${config.get('rapidApiBearer')}`,
        'x-rapidapi-key': config.get('rapidApiKey'),
        'x-rapidapi-host': config.get('rapidApiHost'),
      };
      this.host = config.get('rapidApiHost');

      return;
    }

    throw new Error('Invalid Imgur API has been chosen');
  }

  /**
   * @param options
   * @returns {Promise<*|null>}
   */
  async CreateAlbum(options) {
    return $.ajax({
      url: `https://${this.host}/3/album`,
      type: 'POST',
      headers: this.headers,
      data: {
        title: options.title,
        privacy: 'hidden',
        description: `Auto uploaded using FR:Reborn - ${this.agent} ${this.version}`,
      },
    }).retry({ times: 3 }).catch((err) => {
      // Check if Imgur is being a bitch
      if (typeof err.responseJSON === 'undefined') {
        // Store request so we know what was asked
        this._storeRequestError(err);

        throw new ImgurError('Could not make an album, because Imgur is returning empty responses. Please try again later...', err);
      }

      this._handleImgurError(err);
    });
  }

  /**
   * @param base64Image {string}
   * @param deleteHash {string}
   * @param purchaseUrl {string}
   * @returns {Promise<*|null>}
   */
  async AddBase64ImageToAlbum(base64Image, deleteHash, purchaseUrl) {
    return $.ajax({
      url: `https://${this.host}/3/image`,
      headers: this.headers,
      type: 'POST',
      data: {
        album: deleteHash,
        type: 'base64',
        image: base64Image,
        description: `W2C: ${purchaseUrl}`,
      },
    }).retry({ times: 3 }).then(() => true).catch((err) => {
      // Check if Imgur is being a bitch
      if (typeof err.responseJSON === 'undefined') {
        // Store request so we know what was asked
        this._storeRequestError(err);

        throw new ImgurError('Could not upload image, as Imgur is returning empty responses. Please try again later...', err);
      }

      this._handleImgurError(err);
    });
  }

  /**
   * @param imageUrl {string}
   * @param deleteHash {string}
   * @param purchaseUrl {string}
   * @returns {Promise<*|null>}
   */
  async AddImageToAlbum(imageUrl, deleteHash, purchaseUrl) {
    return $.ajax({
      url: `https://${this.host}/3/image`,
      headers: this.headers,
      type: 'POST',
      data: {
        album: deleteHash,
        image: imageUrl,
        description: `W2C: ${purchaseUrl}`,
      },
    }).retry({ times: 3 }).then(() => true).catch((err) => {
      // Check if Imgur is being a bitch
      if (typeof err.responseJSON === 'undefined') {
        // Store request so we know what was asked
        this._storeRequestError(err);

        throw new ImgurError('Could not upload image, as Imgur is returning empty responses. Please try again later...', err);
      }

      this._handleImgurError(err);
    });
  }

  /**
   * @param deleteHash {string}
   */
  RemoveAlbum(deleteHash) {
    $.ajax({ url: `https://${this.host}/3/album/${deleteHash}`, headers: this.headers, type: 'DELETE' })
      .retry({ times: 3 }).catch(() => {});
  }

  /**
   * @private
   * @param err {Error}
   */
  _storeRequestError(err) {
    Sentry.addBreadcrumb({
      category: 'Imgur',
      message: `Imgur returned: '${err.statusText}'`,
      data: err,
      level: Sentry.Severity.Error,
    });
  }

  /**
   * @private
   * @param err {Error}
   */
  _handleImgurError(err) {
    // If we uploaded too many files, re-throw as proper error
    if (err.responseJSON.status === 429 || (err.responseJSON.data && err.responseJSON.data.error && err.responseJSON.data.error.code === 429)) {
      throw new ImgurSlowdownError(err.responseJSON.data.error.message, err);
    }

    // Store request so we know what was asked
    this._storeRequestError(err);

    // If we have error data from Imgur, throw it
    if (err.responseJSON.data && err.responseJSON.data.error) {
      throw new ImgurError(`An error happened when uploading the image:\n${err.responseJSON.data.error.message}`, err);
    }

    // If not, just show the full JSON
    throw new ImgurError(`An error happened when uploading the image:\n${err.responseJSON}`, err);
  }
}

const buildSwaggerHTTPError = function (response) {
  // Build basic error (and use response as extra)
  const error = new Error(`${response.body.detail}: ${response.url}`);

  // Add status and status code
  error.status = response.body.status;
  error.statusCode = response.body.status;

  return error;
};

class QC {
  /**
   * @param version {string}
   * @param client {SwaggerClient}
   * @param userHash {string}
   * @param agent {string}
   */
  constructor(version, client, userHash, agent) {
    this.version = version;
    this.client = client;
    this.userHash = userHash;
    this.agent = agent;
  }

  /**
   * @param element {BaseTaoElement|CSSBuyElement|WeGoBuyElement}
   * @returns {Promise<null|string>}
   */
  existingAlbumByOrderId(element) {
    const request = { usernameHash: this.userHash, orderId: element.orderId };

    return this.client.apis.QualityControl.hasUploaded(request).then((response) => {
      if (typeof response.body === 'undefined') {
        return null;
      }

      if (!response.body.success) {
        return null;
      }

      // Force add the album ID to the element
      element.albumId = response.body.albumId; // eslint-disable-line no-param-reassign

      return response.body.albumId;
    }).catch((reason) => {
      Logger.error(`Could not check if the album for order '${element.orderId}' exists on the QC server`, reason);

      // For some reason we couldn't fetch information, just return, server probably down or something
      if (reason.message.includes('Failed to fetch') || reason.message.includes('NetworkError when attempting to fetch resource')) {
        return '-1';
      }

      // Add breadcrumb with actual request we did
      Sentry.addBreadcrumb({
        category: 'Swagger',
        message: 'existingAlbumByOrderId',
        data: { request },
        level: Sentry.Severity.Debug,
      });

      // Add breadcrumb with the error
      Sentry.addBreadcrumb({
        category: 'Swagger - Error',
        message: 'existingAlbumByOrderId',
        data: { error: reason },
        level: Sentry.Severity.Error,
      });

      // Swagger HTTP error
      if (typeof reason.response !== 'undefined') {
        Sentry.captureException(buildSwaggerHTTPError(reason));

        return '-1';
      }

      Sentry.captureException(new Error(`Could not check if the album for order '${element.orderId}' exists on the QC server`));

      return '-1';
    });
  }

  /**
   * @param url {string}
   * @returns {Promise<boolean>}
   */
  exists(url) {
    const request = { url };

    return this.client.apis.QualityControl.exists(request).then((response) => {
      if (typeof response.body === 'undefined') {
        return null;
      }

      if (!response.body.success) {
        return null;
      }

      return response.body.exists;
    }).catch((reason) => {
      Logger.error(`Could not check if any album exists on the QC server, URL: '${url}'`, reason);

      // For some reason we couldn't fetch information, just return, server probably down or something
      if (reason.message.includes('Failed to fetch') || reason.message.includes('NetworkError when attempting to fetch resource')) {
        return '-1';
      }

      // Add breadcrumb with actual request we did
      Sentry.addBreadcrumb({
        category: 'Swagger',
        message: 'exists',
        data: { request },
        level: Sentry.Severity.Debug,
      });

      // Add breadcrumb with the error
      Sentry.addBreadcrumb({
        category: 'Swagger - Error',
        message: 'exists',
        data: { error: reason },
        level: Sentry.Severity.Error,
      });

      // Swagger HTTP error
      if (typeof reason.response !== 'undefined') {
        Sentry.captureException(buildSwaggerHTTPError(reason));

        return false;
      }

      Sentry.captureException(new Error(`Could not check if any album exists on the QC server, URL: '${url}'`));

      return false;
    });
  }

  /**
   * @param element {BaseTaoElement|CSSBuyElement|WeGoBuyElement}
   * @param album {string}
   */
  uploadQc(element, album) {
    const request = {
      method: 'post',
      requestContentType: 'application/json',
      requestBody: {
        usernameHash: this.userHash,
        albumId: album,
        color: element.color,
        orderId: element.orderId,
        purchaseUrl: element.purchaseUrl,
        sizing: element.sizing,
        itemPrice: element.itemPrice,
        freightPrice: element.freightPrice,
        weight: element.weight,
        source: `${this.agent} to Imgur ${this.version}`,
        website: element.website,
      },
    };

    Logger.log('Adding new QC to FR: Reborn', request);

    return this.client.apis.QualityControl.postQualityControlCollection({}, request).catch((reason) => {
      Logger.error('Could not upload QC to the QC server', reason);

      // For some reason we couldn't fetch information, just return, server probably down or something
      if (reason.message.includes('Failed to fetch') || reason.message.includes('NetworkError when attempting to fetch resource')) {
        return;
      }

      // If the order already exists, just ignore the error
      if (reason.message.includes('orderId: This value is already used')) {
        return;
      }

      // Add breadcrumb with actual request we did
      Sentry.addBreadcrumb({
        category: 'Swagger',
        message: 'postQualityControlCollection',
        data: { request },
        level: Sentry.Severity.Debug,
      });

      // Add breadcrumb with the error
      Sentry.addBreadcrumb({
        category: 'Swagger - Error',
        message: 'postQualityControlCollection',
        data: { error: reason },
        level: Sentry.Severity.Error,
      });

      // Swagger HTTP error
      if (typeof reason.response !== 'undefined') {
        Sentry.captureException(buildSwaggerHTTPError(reason.response));

        return;
      }

      Sentry.captureException(new Error('Could not upload QC to the QC server'));
    });
  }
}

class BaseTao {
  constructor() {
    this.setup = false;
  }

  /**
   * @param hostname {string}
   * @returns {boolean}
   */
  supports(hostname) {
    return hostname.includes('basetao.com');
  }

  /**
   * @param client {Promise<SwaggerClient>}
   * @returns {Promise<BaseTao>}
   */
  async build(client) {
    // If already build before, just return
    if (this.setup) {
      return this;
    }

    // Ensure the toast looks decent on Basetao
    GM_addStyle('.swal2-popup.swal2-toast .swal2-title, .swal2-actions {font-size: 1.25em; font-weight: bolder}');

    // Get the username
    const username = $('#dropdownMenu1').text();
    if (typeof username === 'undefined' || username == null || username === '') {
      Snackbar('You need to be logged in to use this extension.');

      return this;
    }

    // Ensure we know who triggered the error
    const userHash = SparkMD5.hash(username);
    Sentry.setUser({ id: userHash, username });

    // Build all the clients
    this.imgurClient = new Imgur(GM_info.script.version, GM_config, 'BaseTao');
    this.qcClient = new QC(GM_info.script.version, await client, userHash, 'BaseTao');

    // Mark that this agent has been setup
    this.setup = true;

    return this;
  }

  /**
   * @return {Promise<void>}
   */
  async process() {
    if (this.setup === false) {
      throw new Error('Agent is not setup, so cannot be used');
    }

    // Make copy of the current this, so we can use it later
    const agent = this;

    // Get the container
    const $container = $('.myparcels-ul').first();
    const elementPromises = [];

    // Add button to upload pending items
    const $uploadPending = $('<button type="button" id="pending-haul" class="btn btn-sm btn-info pull-left" disabled="disabled">Upload pending QCs</button>');
    $container.find('.pull-right.seach-button').prepend($uploadPending.prop('title', 'Loading.....').css('cursor', 'wait'));

    // Add icons to all elements
    $container.find('span.glyphicon.glyphicon-picture').each(async function () {
      elementPromises.push(new Promise((resolve) => {
        agent._buildElement($(this)).then((element) => resolve(element));
      }));
    });

    // Wait for all elements to have been loaded and build pending button
    await Promise.all(elementPromises).then((elements) => {
      // Determine all missing QCs
      const missingQcs = elements.filter((element) => element.albumId === null || element.albumId === '-1');

      // Check if there is anything to upload
      if (missingQcs.length === 0) {
        $uploadPending.prop('title', 'Everything has been uploaded already').css('cursor', 'help');

        return;
      }

      // Re-enable button
      $uploadPending.prop('disabled', false).prop('title', 'Upload all pending QCs').css('cursor', 'pointer');

      // Attach click handler
      $uploadPending.on('click', () => {
        this._uploadPending($uploadPending, missingQcs);
      });
    });
  }

  /**
   * @private
   * @param $this
   * @return {Promise<BaseTaoElement>}
   */
  async _buildElement($this) {
    const element = new BaseTaoElement($this);
    const $imageIcon = element.element.parents('td').find('ul > li').first();

    // Append download button if enabled
    if (GM_config.get('showImagesDownloadButton')) {
      const $download = $('<span style="color: rgb(255, 140, 60);cursor: pointer;text-indent: -12px;float: right;" class="glyphicon glyphicon-download-alt"></span>');
      $download.on('click', () => this._downloadHandler($download, element));
      $imageIcon.append($download);
    }

    // This plugin only works for certain websites, so check if element is supported
    if (element.website === WEBSITE_UNKNOWN) {
      const $upload = $(`<ul><li><span style="cursor: pointer;margin-left:-29px;"><img src="${ImgurIcon}" alt="Create a basic album"></span></li></ul>`);
      $upload.find('span').first().after($('<span class="qc-marker" style="cursor:help;margin-left:5px;color:red;font-weight: bold;" title="Not a supported URL, but you can still create an album. The QC\'s are not stored and you\'ll have to create a new album if you lose the link.">✖</span>'));
      $upload.on('click', () => {
        this._uploadHandler(element);
      });

      $this.parents('td').first().append($upload);

      return element;
    }

    const $loading = $(`<ul><li><span style="cursor: wait;margin-left:-29px;"><img src="${Loading}" alt="Loading..."></span></li></ul>`);
    $this.parents('td').first().append($loading);

    // Define upload object
    const $upload = $(`<ul><li><span class="qc-marker" style="cursor: pointer;margin-left:-29px;"><img src="${ImgurIcon}" alt="Upload your QC"></span></li></ul>`);

    // If we couldn't talk to FR:Reborn, assume everything is dead and use the basic uploader.
    const albumId = await this.qcClient.existingAlbumByOrderId(element);
    if (albumId === '-1') {
      $upload.find('span').first().html($('<span class="qc-marker" style="cursor:help;color:red;font-weight: bold;" title="FR:Reborn returned an error or could not load your album.">⚠️</span>'));

      $this.parents('td').first().append($upload);
      $loading.remove();

      return element;
    }

    // Have you ever uploaded a QC? If so, link to that album
    const $image = $upload.find('img');
    if (albumId !== null && albumId !== '-1') {
      $upload.find('span').first().after($('<span class="qc-marker" style="cursor:help;margin-left:5px;color:green;font-weight: bold;" title="You have uploaded a QC">✓</span>'));
      $image.wrap(`<a href='${element.albumUrl}' target='_blank' title='Go to album'></a>`);
      $image.removeAttr('title');

      $this.parents('td').first().append($upload);
      $loading.remove();

      return element;
    }

    // Has anyone ever uploaded a QC, if not, show a red marker
    const exists = await this.qcClient.exists(element.purchaseUrl);
    if (!exists) {
      $upload.find('span').first().after($('<span class="qc-marker" style="cursor:help;margin-left:5px;color:red;font-weight: bold;" title="No QC in database, please upload.">(!)</span>'));
      $upload.on('click', () => {
        this._uploadHandler(element);
      });

      $this.parents('td').first().append($upload);
      $loading.remove();

      return element;
    }

    // A previous QC exists, but you haven't uploaded yours yet, show orange marker
    $upload.find('span').first().after($('<span class="qc-marker" style="cursor:help;margin-left:5px;color:orange;font-weight: bold;" title="Your QC is not yet in the database, please upload.">(!)</span>'));
    $upload.on('click', () => {
      this._uploadHandler(element);
    });

    $this.parents('td').first().append($upload);
    $loading.remove();

    return element;
  }

  /**
   * @private
   * @param $uploadPending
   * @param missingQcs {Array<BaseTaoElement>}
   */
  async _uploadPending($uploadPending, missingQcs) {
    // Disable button with some information
    $uploadPending.prop('disabled', true).prop('title', 'Uploading...').css('cursor', 'progress');

    // Upload all pending elements
    await missingQcs.reduce(async (promise, element) => {
      // This line will wait for the last async function to finish.
      // The first iteration uses an already resolved Promise
      // so, it will immediately continue.
      await promise;

      // If the element was uploaded in the mean time, skip it.
      if (element.albumId !== null && element.albumId !== '-1') {
        Logger.info(`Skipping element with order '${element.orderId}' as uploaded in the meantime`);
        return;
      }

      // Upload item and just wait for it to be done
      Logger.info(`Uploading element with order '${element.orderId}'`);
      await this._uploadHandler(element);
    }, Promise.resolve());
  }

  /**
   * @private
   * @param element {BaseTaoElement}
   * @returns {Promise<void>}
   */
  async _uploadToImgur(element) {
    if (this.setup === false) {
      throw new Error('Agent is not setup, so cannot be used');
    }

    if (element.imageUrls.length === 0) {
      Snackbar(`No pictures found for order ${element.orderId}, skipping,..`);
      return;
    }

    const $processing = $(`<ul><li><span style="cursor: wait;margin-left:-29px;"><img src="${Loading}" alt="Processing..."></span></li></ul>`);
    const $base = element.element.parents('td').first().find('ul:last-child').first();
    $base.after($processing).hide();

    // Start the process
    Snackbar(`Pictures for '${element.orderId}' are being uploaded...`);

    // Temp store deleteHash
    let deleteHash;

    try {
      // Create the album
      const response = await this.imgurClient.CreateAlbum(element);
      if (typeof response === 'undefined' || response == null) {
        return;
      }

      // Extract and build information needed
      deleteHash = response.data.deletehash;
      const albumId = response.data.id;

      // Upload all QC images
      const promises = [];
      $.each(element.imageUrls, (key, imageUrl) => {
        // Convert to base64, since Imgur cannot access our images
        promises.push(toDataURL(imageUrl).then(async (data) => {
          // Store our base64 and if the file is WEBP, convert it to JPG
          let base64Image = data;
          if (base64Image.indexOf('image/webp') !== -1) {
            base64Image = await WebpToJpg(base64Image);
          }

          // Remove the unnecessary `data:` part
          const cleanedData = base64Image.replace(/(data:image\/.*;base64,)/, '');

          // Upload the image to the album
          return this.imgurClient.AddBase64ImageToAlbum(cleanedData, deleteHash, element.purchaseUrl);
        }));
      });

      // Wait until everything has been tried to be uploaded
      await Promise.all(promises);

      // Set albumId in element so we don't upload it again (when doing a pending haul upload)
      element.albumId = albumId; // eslint-disable-line no-param-reassign

      // Tell the user it was uploaded and open the album in the background
      Snackbar('Pictures have been uploaded!', 'success');
      GM_openInTab(element.albumUrl, true);

      // Tell QC Suite about our uploaded QC's (if it's supported)
      if (element.website !== WEBSITE_UNKNOWN) {
        this.qcClient.uploadQc(element, albumId);
      }

      // Wrap the logo in a href to the new album
      const $image = $base.find('img');
      $image.wrap(`<a href='${element.albumUrl}' target='_blank' title='Go to album'></a>`);
      $image.removeAttr('title');

      // Remove processing
      $processing.remove();

      // Update the marker
      const checkMarkMessage = element.website !== WEBSITE_UNKNOWN ? 'You have uploaded a QC' : 'Album has been created';
      const $qcMarker = $base.find('.qc-marker:not(:first-child)').first();
      $qcMarker.attr('title', checkMarkMessage)
        .css('cursor', 'help')
        .css('color', 'green')
        .text('✓');

      // Remove the click handler
      $base.off();

      // Show it again
      $base.show();
    } catch (err) {
      // Remove the created album
      this.imgurClient.RemoveAlbum(deleteHash);

      // Reset the button
      $processing.remove();
      $base.show();

      // Show the error
      Snackbar(err.message, 'error');

      // If it's the slow down error, don't log it
      if (err instanceof ImgurSlowdownError) {
        return;
      }

      // Log the error
      Sentry.captureException(err);
      Logger.error(err);
    }
  }

  /**
   * @private
   * @param $download
   * @param element {BaseTaoElement}
   */
  async _downloadHandler($download, element) {
    if (this.setup === false) {
      throw new Error('Agent is not setup, so cannot be used');
    }

    if (!await ConfirmDialog()) {
      return;
    }

    // Remove button so people don't do dumb shit
    $download.remove();

    // Go to the QC pictures URL, grab all image sources and upload the element
    await $.get(element.qcImagesUrl).then(async (data) => {
      if (data.indexOf('long time no operation ,please sign in again') !== -1) {
        Snackbar('You are no longer logged in, reloading page....', 'warning');
        Logger.info('No longer logged in, reloading page for user...');
        window.location.reload();

        return null;
      }

      Snackbar('Zipping images, this might take a while....', 'info');

      // Create a zip file writer
      const zipWriter = new zip.ZipWriter(new zip.Data64URIWriter('application/zip'));

      // Download all the images and add to the zip
      const promises = [];
      $('<div/>').html(data).find('div.container.container-top60 > img').each(function () {
        const src = $(this).attr('src');
        promises.push(new Promise((resolve) => toDataURL(src)
          .then((dataURI) => zipWriter.add(src.substring(src.lastIndexOf('/') + 1), new zip.Data64URIReader(dataURI)))
          .then(() => resolve())));
      });

      // Wait for all images to be added to the ZIP
      await Promise.all(promises);

      // Close the ZipWriter object and download to computer
      saveAs(await zipWriter.close(), `${element.orderId}.zip`);

      Snackbar(`Downloading ${element.orderId}.zip`, 'success');

      return null;
    }).catch((err) => {
      Snackbar(`Could not get all images for order ${element.orderId}`);
      Logger.error(`Could not get all images for order ${element.orderId}`, err);
    });
  }

  /**
   * @private
   * @param element {BaseTaoElement}
   * @returns {Promise<void>}
   */
  async _uploadHandler(element) {
    if (this.setup === false) {
      throw new Error('Agent is not setup, so cannot be used');
    }

    // Go to the QC pictures URL, grab all image sources and upload the element
    await $.get(element.qcImagesUrl).then(async (data) => {
      if (data.indexOf('long time no operation ,please sign in again') !== -1) {
        Snackbar('You are no longer logged in, reloading page....', 'warning');
        Logger.info('No longer logged in, reloading page for user...');
        window.location.reload();

        return null;
      }

      // Add all image urls to the element
      $('<div/>').html(data).find('div.container.container-top60 > img').each(function () {
        element.imageUrls.push($(this).attr('src'));
      });

      // Finally go and upload the order
      return this._uploadToImgur(element);
    }).catch((err) => {
      Snackbar(`Could not get all images for order ${element.orderId}`);
      Logger.error(`Could not get all images for order ${element.orderId}`, err);
    });
  }
}

class CSSBuyElement {
  constructor($element) {
    this.element = $element;

    // Create empty array for images
    this.imageUrls = [];

    // Temporary items
    const parentTableEntry = $element.parentsUntil('tbody');
    const itemLink = parentTableEntry.find('td:nth-child(2) a');
    const splitText = parentTableEntry.find('td:nth-child(2)').find('span:eq(1)').html().split('<br>');

    // Order details
    this.orderId = this.element.parent().attr('data-id');

    // Item name
    this.title = truncate(removeWhitespaces(itemLink.text()), 255);

    // Purchase details
    this.website = determineWebsite(itemLink.attr('href'));
    this.purchaseUrl = cleanPurchaseUrl(itemLink.attr('href'), this.website);

    // Freight price
    this.freightPrice = `CNY ${removeWhitespaces(parentTableEntry.find('td:nth-child(4) span').text())}`;

    // Item price
    this.itemPrice = `CNY ${removeWhitespaces(parentTableEntry.find('td:nth-child(5) span').text())}`;

    // Item weight
    const weight = removeWhitespaces(parentTableEntry.find('td:nth-child(6) span').text());
    this.weight = weight.length !== 0 ? `${weight} gram` : null;

    // Item sizing and color (if any)
    this.color = null;
    this.sizing = null;

    try {
      if (splitText.length === 1) {
        let color = splitText[0].split(' : ')[1];
        color = (typeof color !== 'undefined' && color !== '-' && color.toLowerCase() !== 'no') ? truncate(color, 255) : '';
        this.color = color.length !== 0 ? color : null;
      } else if (splitText.length === 2) {
        let sizing = (splitText[0].split(' : ')[1]);
        sizing = (typeof sizing !== 'undefined' && sizing !== '-' && sizing.toLowerCase() !== 'no') ? truncate(sizing, 255) : '';
        this.sizing = sizing.length !== 0 ? sizing : null;

        let color = (splitText[1].split(' : ')[1]);
        color = (typeof color !== 'undefined' && color !== '-' && color.toLowerCase() !== 'no') ? truncate(color, 255) : '';
        this.color = color.length !== 0 ? color : null;
      } else if (splitText.length !== 0) {
        this.sizing = splitText.join('\n');
      }
    } catch (e) {
      Logger.info('Could not figure out sizing/color', e);
    }

    // Set at a later date, if ever
    this.albumId = null;
  }

  /**
   * @return {string}
   */
  get albumUrl() {
    return `https://imgur.com/a/${this.albumId}`;
  }
}

/* eslint-disable no-return-await */
class OSS {
  constructor() {
    this.window = window.unsafeWindow;

    // Set up the bucket for easy use
    this.window.client = new this.window.OSS.Wrapper({
      region: this.window.c_region,
      accessKeyId: this.window.c_accessid,
      accessKeySecret: this.window.c_accesskey,
      bucket: this.window.c_bucket,
      endpoint: `https://${this.window.c_region_show}.aliyuncs.com/`,
    });
  }

  /**
   * @param {string} orderId
   *
   * @return Promise<object>
   */
  async list(orderId) {
    return await this.window.client.list({
      'max-keys': 100,
      prefix: `o/${orderId}/`,
    });
  }
}

class CSSBuy {
  constructor() {
    this.setup = false;
  }

  /**
  * @param hostname {string}
  * @returns {boolean}
  */
  supports(hostname) {
    return hostname.includes('cssbuy.com');
  }

  /**
   * @param client {Promise<SwaggerClient>}
   * @returns {Promise<CSSBuy>}
   */
  async build(client) {
    // If already build before, just return
    if (this.setup) {
      return this;
    }

    // Get the username
    const username = removeWhitespaces($(await $.get('/?go=m')).find('.servive-block-default div:nth-child(3) h3').text());
    if (typeof username === 'undefined' || username == null || username === '') {
      Snackbar('You need to be logged in to use this extension.');

      return this;
    }

    // Ensure we know who triggered the error
    const userHash = SparkMD5.hash(username);
    Sentry.setUser({ id: userHash, username });

    // Build all the clients
    this.imgurClient = new Imgur(GM_info.script.version, GM_config, 'CSSBuy');
    this.qcClient = new QC(GM_info.script.version, await client, userHash, 'CSSBuy');

    // Mark that this agent has been setup
    this.setup = true;

    return this;
  }

  async process() {
    if (this.setup === false) {
      throw new Error('Agent is not setup, so cannot be used');
    }

    // Make copy of the current this, so we can use it later
    const agent = this;

    // Build OSS client
    this.ossClient = new OSS();

    // Add icons to all elements
    $(".oss-photo-view-button > a:contains('QC PIC')").each(function () { agent._buildElement($(this)); });
  }

  /**
   * @private
   * @param $this
   * @return {Promise<void>}
   */
  async _buildElement($this) {
    const element = new CSSBuyElement($this);

    // Check if it has any images to begin with
    const result = await this.ossClient.list(element.orderId);
    if (typeof result.objects === 'undefined') {
      return;
    }

    // This plugin only works for certain websites, so check if element is supported
    if (element.website === WEBSITE_UNKNOWN) {
      const $upload = $(`<ul class="badge-lists btn btn-xs btn-default"><li style="cursor: pointer"><img src="${ImgurIcon}" alt="Create a basic album" style="width: 100%"></li></ul>`);
      $upload.find('li').first().after($('<li class="btn btn-xs qc-marker" style="cursor:help;color:red;font-weight: bold;" title="Not a supported URL, but you can still create an album. The QC\'s are not stored and you\'ll have to create a new album if you lose the link.">✖</li>'));
      $upload.on('click', () => { this._uploadToImgur(element); });

      $this.parents('ul').first().after($upload);

      return;
    }

    // Define column in which to show buttons
    const $other = $this.parents('ul').first();

    // Show simple loading animation
    const $loading = $(`<ul class="badge-lists btn btn-xs btn-default"><li style="cursor: wait"><img src="${Loading}" alt="Loading..." style="width: 100%"></li></ul>`);
    $other.after($loading);

    // Define upload object
    const $upload = $(`<ul class="badge-lists btn btn-xs btn-default"><li class="btn btn-xs qc-marker" style="cursor: pointer"><img src="${ImgurIcon}" alt="Upload your QC"  style="width: 100%"></li></ul>`);

    // If we couldn't talk to FR:Reborn, assume everything is dead and use the basic uploader.
    const albumId = await this.qcClient.existingAlbumByOrderId(element);
    if (albumId === '-1') {
      $upload.find('li').first().after($('<li class="btn btn-xs qc-marker" style="cursor:help;color:red;font-weight: bold;" title="FR:Reborn returned an error, but you can still create an album. The QC\'s are not stored and you\'ll have to create a new album if you lose the link.">⚠️</li>'));
      $upload.on('click', () => { this._uploadToImgur(element); });

      $other.after($upload);
      $loading.remove();

      return;
    }

    // Have you ever uploaded a QC? If so, link to that album
    const $image = $upload.find('img');
    if (albumId !== null && albumId !== '-1') {
      $upload.find('li').first().after($('<li class="btn btn-xs qc-marker" style="cursor:help;color:green;font-weight: bold;" title="You have uploaded a QC">✓</li>'));
      $image.wrap(`<a href='https://imgur.com/a/${albumId}' target='_blank' title='Go to album'></a>`);
      $image.removeAttr('title');

      $other.after($upload);
      $loading.remove();

      return;
    }

    // Has anyone ever uploaded a QC, if not, show a red marker
    const exists = await this.qcClient.exists(element.purchaseUrl);
    if (!exists) {
      $upload.find('li').first().after($('<li class="btn btn-xs qc-marker" style="cursor:help;color:red;font-weight: bold;" title="No QC in database, please upload.">(!)</li>'));
      $upload.on('click', () => { this._uploadToImgur(element); });

      $other.after($upload);
      $loading.remove();

      return;
    }

    // A previous QC exists, but you haven't uploaded yours yet, show orange marker
    $upload.find('li').first().after($('<li class="btn btn-xs qc-marker" style="cursor:help;color:orange;font-weight: bold;" title="Your QC is not yet in the database, please upload.">(!)</li>'));
    $upload.on('click', () => { this._uploadToImgur(element); });

    $other.after($upload);
    $loading.remove();
  }

  /**
   * @param element {CSSBuyElement}
   * @returns {Promise<void>}
   */
  async _uploadToImgur(element) {
    if (this.setup === false) {
      throw new Error('Agent is not setup, so cannot be used');
    }

    const $processing = $(`<ul class="badge-lists btn btn-xs btn-default"><li style="cursor: wait"><img src="${Loading}" alt="Processing..." style="width: 100%"></li></ul>`);
    const $base = element.element.parents('td').first().find('ul:last-child').first();
    $base.after($processing).hide();

    const result = await this.ossClient.list(element.orderId);
    if (typeof result.objects === 'undefined') {
      Snackbar(`No pictures found for order ${element.orderId}, skipping,..`);
      return;
    }

    result.objects.forEach((item) => {
      element.imageUrls.push((item.url));
    });

    if (element.imageUrls.length === 0) {
      Snackbar(`No pictures found for order ${element.orderId}, skipping,..`);
      return;
    }

    // Start the process
    Snackbar(`Pictures for '${element.orderId}' are being uploaded...`);

    // Temp store deleteHash
    let deleteHash;

    try {
      // Create the album
      const response = await this.imgurClient.CreateAlbum(element);
      if (typeof response === 'undefined' || response == null) {
        return;
      }

      // Extract and build information needed
      deleteHash = response.data.deletehash;
      const albumId = response.data.id;

      // Upload all QC images
      const promises = [];
      $.each(element.imageUrls, (key, imageUrl) => {
        promises.push(this.imgurClient.AddImageToAlbum(imageUrl, deleteHash, element.purchaseUrl));
      });

      // Wait until everything has been tried to be uploaded
      await Promise.all(promises);

      // Set albumId in element, so we don't upload it again (when doing a pending haul upload)
      element.albumId = albumId; // eslint-disable-line no-param-reassign

      // Tell the user it was uploaded and open the album in the background
      Snackbar('Pictures have been uploaded!', 'success');
      GM_openInTab(element.albumUrl, true);

      // Tell QC Suite about our uploaded QC's (if it's supported)
      if (element.website !== WEBSITE_UNKNOWN) {
        this.qcClient.uploadQc(element, albumId);
      }

      // Wrap the logo in a href to the new album
      const $image = $base.find('img');
      $image.wrap(`<a href='${element.albumUrl}' target='_blank' title='Go to album'></a>`);
      $image.removeAttr('title');

      // Remove processing
      $processing.remove();

      // Update the marker
      const checkMarkMessage = element.website !== WEBSITE_UNKNOWN ? 'You have uploaded a QC' : 'Album has been created';
      const $qcMarker = $base.find('.qc-marker:not(:first-child)').first();
      $qcMarker.attr('title', checkMarkMessage)
        .css('cursor', 'help')
        .css('color', 'green')
        .text('✓');

      // Remove the click handler
      $base.off();

      // Show it again
      $base.show();
    } catch (err) {
      // Remove the created album
      this.imgurClient.RemoveAlbum(deleteHash);

      // Reset the button
      $processing.remove();
      $base.show();

      // Show the error
      Snackbar(err.message, 'error');

      // If it's the slow down error, don't log it
      if (err instanceof ImgurSlowdownError) {
        return;
      }

      // Log the error
      Sentry.captureException(err);
      Logger.error(err);
    }
  }
}

class WeGoBuyElement {
  constructor($element) {
    this.element = $element;

    // Order details
    this.orderId = removeWhitespaces($element.find('td:nth-child(1) > p').text());
    this.imageUrls = $element.find('.lookPic').map((key, value) => $(value).attr('href')).get();

    // Item name
    this.title = truncate(removeWhitespaces($element.find('.js-item-title').text()), 255);

    // Item sizing (if any)
    const sizing = removeWhitespaces($element.find('.user_orderlist_txt').text());
    this.sizing = sizing.length !== 0 ? truncate(sizing, 255) : null;

    // Item color (WeGoBuy doesn't support separation of color, so just null)
    this.color = null;

    // Item price
    const itemPriceMatches = truncate(removeWhitespaces($element.parents('tbody').find('tr > td:nth-child(2)').text()), 255).match(/([A-Z]{2})\W+([.,0-9]+)/);
    this.itemPrice = `${itemPriceMatches[1]} ${itemPriceMatches[2]}`;

    // Freight price
    const freightPriceMatches = truncate(removeWhitespaces($element.parents('tbody').find('tr:nth-child(1) > td:nth-child(8) > p:nth-child(2)').text()), 255).match(/([A-Z]{2})\W+([.,0-9]+)/);
    this.freightPrice = `${freightPriceMatches[1]} ${freightPriceMatches[2]}`;

    // Item weight
    this.weight = null;

    // Purchase details
    const possibleUrl = removeWhitespaces($element.find('.js-item-title').attr('href')).trim();
    this.url = isUrl(possibleUrl) ? possibleUrl : '';
    this.website = determineWebsite(this.url);

    // Set at a later date, if ever
    this.albumId = null;
  }

  /**
   * @return {string}
   */
  get albumUrl() {
    return `https://imgur.com/a/${this.albumId}`;
  }

  /**
   * @param imageUrls {string[]}
   */
  set images(imageUrls) {
    this.imageUrls = imageUrls;
  }

  /**
   * @returns {string}
   */
  get purchaseUrl() {
    return cleanPurchaseUrl(this.url, this.website);
  }
}

class WeGoBuy {
  constructor() {
    this.setup = false;
  }

  /**
   * @param hostname {string}
   * @returns {boolean}
   */
  supports(hostname) {
    return hostname.includes('wegobuy.com')
      || hostname.includes('superbuy.com');
  }

  /**
   * @param client {Promise<SwaggerClient>}
   * @returns {Promise<WeGoBuy>}
   */
  async build(client) {
    // If already build before, just return
    if (this.setup) {
      return this;
    }

    // Ensure the toast looks decent on SB/WGB
    GM_addStyle('.swal2-popup.swal2-toast .swal2-title {font-size: 1em; font-weight: bolder}');

    // Get the username
    const username = (await $.get('/ajax/user-info')).data.user_name;
    if (typeof username === 'undefined' || username == null || username === '') {
      Snackbar('You need to be logged in to use this extension.');

      return this;
    }

    // Ensure we know who triggered the error
    const userHash = SparkMD5.hash(username);
    Sentry.setUser({ id: userHash, username });

    // Build all the clients
    this.imgurClient = new Imgur(GM_info.script.version, GM_config, 'WeGoBuy');
    this.qcClient = new QC(GM_info.script.version, await client, userHash, 'WeGoBuy');

    // Mark that this agent has been setup
    this.setup = true;

    return this;
  }

  async process() {
    if (this.setup === false) {
      throw new Error('Agent is not setup, so cannot be used');
    }

    // Make copy of the current this, so we can use it later
    const agent = this;

    // Add icons to all elements
    $('.pic-list.j_picList').each(function () {
      agent._buildElement($(this).parents('tr'));
    });
  }

  /**
   * @private
   * @param $this
   * @return {Promise<void>}
   */
  async _buildElement($this) {
    const element = new WeGoBuyElement($this);

    // No pictures (like rehearsal orders), no QC options
    if (element.imageUrls.length === 0) {
      return;
    }

    // Define column in which to download button
    const $inspection = $this.find('td:nth-child(6)').first();

    // Append download button if enabled
    if (GM_config.get('showImagesDownloadButton')) {
      const $download = $('<div style="padding:5px;"><p><span style="color: rgb(255, 140, 60);cursor: pointer;margin-top: 5px;">Download</span></p></div>');
      $download.on('click', () => this._downloadHandler($download, element));
      $inspection.append($download);
    }

    // This plugin only works for certain websites, so check if element is supported
    if (element.website === WEBSITE_UNKNOWN || element.url.length === 0) {
      const $upload = $(`<div style="padding:5px;"><span style="cursor: pointer;"><img src="${ImgurIcon}" alt="Create a basic album"></span></div>`);
      $upload.find('span').first().after($('<span class="qc-marker" style="cursor:help;margin-left:5px;color:red;font-weight: bold;" title="Not a supported URL, but you can still create an album. The QC\'s are not stored and you\'ll have to create a new album if you lose the link.">✖</span>'));
      $upload.on('click', () => {
        this._uploadToImgur(element);
      });

      $inspection.append($upload);

      return;
    }

    // Show simple loading animation
    const $loading = $(`<div style="padding:5px;"><span style="cursor: wait;"><img src="${Loading}" alt="Loading..."></span></div>`);
    $inspection.append($loading);

    // Define upload object
    const $upload = $(`<div style="padding:5px;"><span class="qc-marker" style="cursor: pointer;"><img src="${ImgurIcon}" alt="Upload your QC"></span></div>`);

    // If we couldn't talk to FR:Reborn, assume everything is dead and use the basic uploader.
    const albumId = await this.qcClient.existingAlbumByOrderId(element);
    if (albumId === '-1') {
      $upload.find('span').first().after($('<span class="qc-marker" style="cursor:help;margin-left:5px;color:red;font-weight: bold;" title="FR:Reborn returned an error, but you can still create an album. The QC\'s are not stored and you\'ll have to create a new album if you lose the link.">⚠️</span>'));
      $upload.on('click', () => {
        this._uploadToImgur(element);
      });

      $inspection.append($upload);
      $loading.remove();

      return;
    }

    // Have you ever uploaded a QC? If so, link to that album
    const $image = $upload.find('img');
    if (albumId !== null && albumId !== '-1') {
      $upload.find('span').first().after($('<span class="qc-marker" style="cursor:help;margin-left:5px;color:green;font-weight: bold;" title="You have uploaded a QC">✓</span>'));
      $image.wrap(`<a href='https://imgur.com/a/${albumId}' target='_blank' title='Go to album' style="display: initial;"></a>`);
      $image.removeAttr('title');

      $inspection.append($upload);
      $loading.remove();

      return;
    }

    // Has anyone ever uploaded a QC, if not, show a red marker
    const exists = await this.qcClient.exists(element.purchaseUrl);
    if (!exists) {
      $upload.find('span').first().after($('<span class="qc-marker" style="cursor:help;margin-left:5px;color:red;font-weight: bold;" title="No QC in database, please upload.">(!)</span>'));
      $upload.on('click', () => {
        this._uploadToImgur(element);
      });

      $inspection.append($upload);
      $loading.remove();

      return;
    }

    // A previous QC exists, but you haven't uploaded yours yet, show orange marker
    $upload.find('span').first().after($('<span class="qc-marker" style="cursor:help;margin-left:5px;color:orange;font-weight: bold;" title="Your QC is not yet in the database, please upload.">(!)</span>'));
    $upload.on('click', () => {
      this._uploadToImgur(element);
    });

    $inspection.append($upload);
    $loading.remove();
  }

  /**
   * @private
   * @param $download
   * @param element {WeGoBuyElement}
   */
  async _downloadHandler($download, element) {
    if (this.setup === false) {
      throw new Error('Agent is not setup, so cannot be used');
    }

    if (!await ConfirmDialog()) {
      return;
    }

    // Remove button so people don't do dumb shit
    $download.remove();

    Snackbar('Zipping images, this might take a while....', 'info');

    // Create a zip file writer
    const zipWriter = new zip.ZipWriter(new zip.BlobWriter('application/zip'));

    // Download all the images and add to the zip
    const promises = [];
    $.each(element.imageUrls, (key, imageUrl) => {
      promises.push(toDataURL(imageUrl.replace('http://', 'https://'))
        .then((dataURI) => zipWriter.add(imageUrl.substring(imageUrl.lastIndexOf('/') + 1), new zip.Data64URIReader(dataURI))));
    });

    // Wait for all images to be added to the ZIP
    await Promise.all(promises);

    // Close the ZipWriter object and download to computer
    saveAs(await zipWriter.close(), `${element.orderId}.zip`);

    Snackbar(`Downloading ${element.orderId}.zip`, 'success');
  }

  /**
   * @param element {WeGoBuyElement}
   * @returns {Promise<void>}
   */
  async _uploadToImgur(element) {
    if (this.setup === false) {
      throw new Error('Agent is not setup, so cannot be used');
    }

    const $processing = $(`<div style="padding:5px;"><span style="cursor: wait;"><img src="${Loading}" alt="Processing..."></span></div>`);
    const $options = element.element.find('td:nth-child(6)').first();
    const $base = $options.find('div').last();
    $base.after($processing).hide();

    // Start the process
    Snackbar(`Pictures for '${element.orderId}' are being uploaded...`);

    // Temp store deleteHash
    let deleteHash;

    try {
      // Create the album
      const response = await this.imgurClient.CreateAlbum(element);
      if (typeof response === 'undefined' || response == null) {
        return;
      }

      // Extract and build information needed
      deleteHash = response.data.deletehash;
      const albumId = response.data.id;

      // Upload all QC images
      const promises = [];
      $.each(element.imageUrls, (key, imageUrl) => {
        promises.push(this.imgurClient.AddImageToAlbum(imageUrl, deleteHash, element.purchaseUrl));
      });

      // Wait until everything has been tried to be uploaded
      await Promise.all(promises);

      // Set albumId in element, so we don't upload it again (when doing a pending haul upload)
      element.albumId = albumId; // eslint-disable-line no-param-reassign

      // Tell the user it was uploaded and open the album in the background
      Snackbar('Pictures have been uploaded!', 'success');
      GM_openInTab(element.albumUrl, true);

      // Tell QC Suite about our uploaded QC's (if it's supported)
      if (element.website !== WEBSITE_UNKNOWN) {
        this.qcClient.uploadQc(element, albumId);
      }

      // Remove processing
      $processing.remove();
      $base.remove();

      // Add new buttons
      const checkMarkMessage = element.website !== WEBSITE_UNKNOWN ? 'You have uploaded a QC' : 'Album has been created';
      $options.append($('<div style="padding:5px;">'
        + `<span class="qc-marker" style="cursor:pointer;"><a href='${element.albumUrl}' target='_blank' title='Go to album' style="display: initial;"><img src="${ImgurIcon}" alt="Go to album"></a></span>`
        + `<span class="qc-marker" style="cursor:help;margin-left:5px;color:green;font-weight: bold;" title="${checkMarkMessage}">✓</span>`
        + '</div>'));

      // Remove the click handler
      $base.off();

      // Show it again
      $base.show();
    } catch (err) {
      // Remove the created album
      this.imgurClient.RemoveAlbum(deleteHash);

      // Reset the button
      $processing.remove();
      $base.show();

      // Show the error
      Snackbar(err.message, 'error');

      // If it's the slow down error, don't log it
      if (err instanceof ImgurSlowdownError) {
        return;
      }

      // Log the error
      Sentry.captureException(err);
      Logger.error(err);
    }
  }
}

/**
 * @param hostname {string}
 */
function getAgent(hostname) {
  const agents = [new BaseTao(), new CSSBuy(), new WeGoBuy()];

  let agent = null;
  Object.values(agents).forEach((value) => {
    if (value.supports(hostname)) {
      agent = value;
    }
  });

  return agent;
}

// Inject snackbar css style
GM_addStyle(GM_getResourceText('sweetalert2'));

// Setup proper settings menu
GM_config.init('Settings', {
  serverSection: {
    label: 'QC Server settings',
    type: 'section',
  },
  swaggerDocUrl: {
    label: 'Swagger documentation URL',
    type: 'text',
    default: 'https://www.fashionreps.page/api/doc.json',
  },
  generalSection: {
    label: 'General options',
    type: 'section',
  },
  showImagesDownloadButton: {
    label: 'Show the images download button/text',
    type: 'checkbox',
    default: 'true',
  },
  uploadSection: {
    label: 'Upload API Options',
    type: 'section',
  },
  imgurApi: {
    label: 'Select your Imgur API',
    type: 'radio',
    default: 'imgur',
    options: {
      imgur: 'Imgur API (Free)',
      rapidApi: 'RapidAPI (Freemium)',
    },
  },
  imgurSection: {
    label: 'Imgur Options',
    type: 'section',
  },
  imgurApiHost: {
    label: 'Imgur host',
    type: 'text',
    default: 'api.imgur.com',
  },
  imgurClientId: {
    label: 'Imgur Client-ID',
    type: 'text',
    default: 'e4e18b5ab582b4c',
  },
  rapidApiSection: {
    label: 'RadidAPI Options',
    type: 'section',
  },
  rapidApiHost: {
    label: 'RapidAPI host',
    type: 'text',
    default: 'imgur-apiv3.p.rapidapi.com',
  },
  rapidApiKey: {
    label: 'RapidAPI key (only needed if RapidApi select above)',
    type: 'text',
    default: '',
  },
  rapidApiBearer: {
    label: 'RapidAPI access token (only needed if RapidApi select above)',
    type: 'text',
    default: '',
  },
});

// Reload page if config changed
GM_config.onclose = (saveFlag) => {
  if (saveFlag) {
    window.location.reload();
  }
};

// Register menu within GM
GM_registerMenuCommand('Settings', GM_config.open);

// Setup Sentry for proper error logging, which will make my lives 20x easier, use XHR since fetch dies.
Sentry.init({
  dsn: 'https://[email protected]/5802425',
  tunnel: 'https://www.fashionreps.page/sentry/tunnel',
  transport: Sentry.Transports.XHRTransport,
  release: GM_info.script.version,
  defaultIntegrations: false,
  integrations: [
    new Sentry.Integrations.InboundFilters(),
    new Sentry.Integrations.FunctionToString(),
    new Sentry.Integrations.LinkedErrors(),
    new Sentry.Integrations.UserAgent(),
  ],
  environment: 'production',
  normalizeDepth: 5,
});

// eslint-disable-next-line func-names
(async function () {
  // Setup the logger.
  Logger.useDefaults();

  // Log the start of the script.
  Logger.info(`Starting extension '${GM_info.script.name}', version ${GM_info.script.version}`);

  // Get the proper agent, if any
  const agent = getAgent(window.location.hostname);
  if (agent === null) {
    Sentry.captureMessage(`Unsupported website ${window.location.hostname}`);
    Logger.error('Unsupported website');

    return;
  }

  // Finally, try to build the proper agent and process the page
  try {
    (await agent.build(new SwaggerClient({ url: GM_config.get('swaggerDocUrl') }))).process();
  } catch (error) {
    if (error.message.includes('Failed to fetch') || error.message.includes('attempting to fetch resource')) {
      Snackbar('We are unable to connect to FR:Reborn, features will be disabled.');
      Logger.error(`We are unable to connect to FR:Reborn: ${GM_config.get('swaggerDocUrl')}`, error);

      return;
    }

    Snackbar(`An unknown issue has occurred when trying to setup the agent extension: ${error.message}`);
    Logger.error('An unknown issue has occurred', error);
  }
}());