FR:Reborn - Agents extension

Upload QCs from your favorite agent to Imgur + QC server

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

ستحتاج إلى تثبيت إضافة مثل Stylus لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتتمكن من تثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

(لدي بالفعل مثبت أنماط للمستخدم، دعني أقم بتثبيته!)

// ==UserScript==
// @name         FR:Reborn - Agents extension
// @namespace    https://www.reddit.com/user/RobotOilInc
// @version      2.3.4
// @description  Upload QCs from your favorite agent to Imgur + QC server
// @author       RobotOilInc
// @match        https://www.basetao.com/*my_account/order/*
// @match        https://basetao.com/*my_account/order/*
// @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   https://greatest.deepsurf.us/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/[email protected]/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      https://greatest.deepsurf.us/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 websites
const WEBSITE_1688 = '1688';
const WEBSITE_TAOBAO = 'taobao';
const WEBSITE_TMALL = 'tmall';
const WEBSITE_YUPOO = 'yupoo';
const WEBSITE_WEIDIAN = 'weidian';
const WEBSITE_XIANYU = 'xianyu';
const WEBSITE_UNKNOWN = 'unknown';

/**
 * @internal
 * @param url {string}
 * @returns {string}
 */
const ensureNonEncodedURL = (url) => {
  if (url === decodeURIComponent(url || '')) {
    return url;
  }

  // Grab the encoded URL
  const encodedURL = new URL(url).searchParams.get('url') || '';
  if (encodedURL.length === 0) {
    return url;
  }

  // Decode said encoded URL
  const decodedURL = decodeURIComponent(encodedURL);
  if (decodedURL.length === 0) {
    return url;
  }

  return decodedURL;
};

/**
 * @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 url = ensureNonEncodedURL(originalUrl);

  const idMatches = url.match(/[?&]id=(\d+)|[?&]itemID=(\d+)|\/?[albums]\/(\d+)|offer\/(\d+)/i);
  const authorMatches = url.match(/https?:\/\/(.+)\.x\.yupoo\.com/);

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

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

  if (website === WEBSITE_XIANYU && idMatches[1].length !== 0) {
    return `https://2.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('1688.com') !== -1) {
    return WEBSITE_1688;
  }

  // Check more specific taobao first
  if (originalUrl.indexOf('market.m.taobao.com') !== -1 || originalUrl.indexOf('2.taobao.com') !== -1) {
    return WEBSITE_XIANYU;
  }

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

  if (originalUrl.indexOf('detail.tmall.com') !== -1) {
    return WEBSITE_TMALL;
  }

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

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

  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'));
    };
  });
};

/**
 * Waits for an element satisfying selector to exist, then resolves promise with the element.
 * Useful for resolving race conditions.
 */
/**
 * @param selector {string}
 * @returns {Promise<Element>}
 */
const elementReady = function (selector) {
  return new Promise((resolve) => {
    // Check if the element already exists
    const element = document.querySelector(selector);
    if (element) {
      resolve(element);
    }

    // It doesn't so, so let's make a mutation observer and wait
    new MutationObserver((mutationRecords, observer) => {
      // Query for elements matching the specified selector
      Array.from(document.querySelectorAll(selector)).forEach((foundElement) => {
        // Resolve the element that we found
        resolve(foundElement);

        // Once we have resolved we don't need the observer anymore.
        observer.disconnect();
      });
    }).observe(document.documentElement, { childList: true, subtree: true });
  });
};

class BaseTaoElement {
  constructor($element, data) {
    this.element = $element;
    this.data = data;

    // Set the order id
    this.orderId = data.oid;

    // Item name
    this.title = truncate(removeWhitespaces(data.goodsname), 255);

    // Item and shipping prices
    this.itemPrice = `CNY ${data.goodsprice}`;
    this.freightPrice = `CNY ${data.sendprice}`;

    // URL related stuff
    this.url = data.goodsurl;
    this.website = determineWebsite(this.url);

    // QC images location
    this.qcImagesUrl = `https://www.basetao.com/best-taobao-agent-service/purchase/order_img/${data.oid}.html`;

    // Item sizing (if any)
    let sizing = removeWhitespaces(data.goodssize);
    sizing = (sizing !== '-' && sizing.toLowerCase() !== 'no') ? truncate(sizing, 255) : '';
    this.sizing = sizing.length !== 0 ? sizing : null;

    // Item color (if any)
    let color = removeWhitespaces(data.goodscolor);
    color = (color !== '-' && color.toLowerCase() !== 'no') ? truncate(color, 255) : '';
    this.color = color.length !== 0 ? color : null;

    // Item weight
    const weight = removeWhitespaces(data.orderweight);
    this.weight = weight.length !== 0 ? `${weight} gram` : null;

    // Image url storage, for later
    this.imageUrls = [];

    // 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')}`,
        'Content-Type': 'application/json',
      };
      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) {
    const requestData = {
      url: `https://${this.host}/3/album`,
      type: 'POST',
      headers: this.headers,
      data: JSON.stringify({
        title: options.title,
      }),
    };
    Sentry.addBreadcrumb({
      category: 'Imgur',
      message: 'Creating album',
      data: requestData,
    });

    Logger.debug('Creating album', requestData);

    return $.ajax(requestData).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 albumDeleteHash {string}
   * @param purchaseUrl {string}
   * @returns {Promise<boolean>}
   */
  async AddBase64ImageToAlbum(base64Image, albumDeleteHash, purchaseUrl) {
    // First step, upload the image
    const requestData = {
      url: `https://${this.host}/3/image`,
      headers: this.headers,
      type: 'POST',
      data: JSON.stringify({
        album: albumDeleteHash,
        type: 'base64',
        image: base64Image,
        description: this._getImageDescription(purchaseUrl),
      }),
    };

    Logger.debug('Adding image to album', requestData);
    Sentry.addBreadcrumb({
      category: 'Imgur',
      message: 'Adding image to album',
      data: requestData,
    });

    await $.ajax(requestData).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);
      }

      this._handleImgurError(err);
    });
  }

  /**
   * @param imageUrl {string}
   * @param albumDeleteHash {string}
   * @param purchaseUrl {string}
   * @returns {Promise<*|null>}
   */
  async AddImageToAlbum(imageUrl, albumDeleteHash, purchaseUrl) {
    // First step, upload the image
    const requestData = {
      url: `https://${this.host}/3/image`,
      headers: this.headers,
      type: 'POST',
      data: JSON.stringify({
        album: albumDeleteHash,
        image: imageUrl,
        description: this._getImageDescription(purchaseUrl),
      }),
    };

    Logger.debug('Adding image to album', requestData);
    Sentry.addBreadcrumb({
      category: 'Imgur',
      message: 'Adding image to album',
      data: requestData,
    });

    await $.ajax(requestData).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);
      }

      this._handleImgurError(err);
    });
  }

  /**
   * @param deleteHash {string}
   */
  RemoveAlbum(deleteHash) {
    const requestData = {
      url: `https://${this.host}/3/album/${deleteHash}`,
      headers: this.headers,
      type: 'DELETE',
    };
    Sentry.addBreadcrumb({
      category: 'Imgur',
      message: 'Removing album',
      data: requestData,
    });

    $.ajax(requestData).retry({ times: 3 }).catch(() => {});
  }

  _getAlbumDescription() {
    return `Auto uploaded using FR:Reborn - ${this.agent} ${this.version}`;
  }

  /**
   * @param purchaseUrl {string}
   */
  _getImageDescription(purchaseUrl) {
    return purchaseUrl.length === 0 ? this._getAlbumDescription() : `W2C: ${purchaseUrl}`;
  }

  /**
   * @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 there is a server error, let the user now
    if (err.status === 503 || (err.responseJSON && err.responseJSON.status === 503)) {
      throw new ImgurError('Imgur is either down, over-capacity or you did too many requests. Try again later', err);
    }

    // If we uploaded too many files, re-throw as proper error (checking via old response setup, new response setup and a simple fallback)
    if (err.responseJSON && err.responseJSON.data && err.responseJSON.data.error && err.responseJSON.data.error.code === 429) {
      throw new ImgurSlowdownError(err.responseJSON.data.error.message, err);
    } else if (err.responseJSON && err.responseJSON.errors && err.responseJSON.errors.length === 1 && err.responseJSON.errors[0] && err.responseJSON.errors[0].code === 429) {
      throw new ImgurSlowdownError(err.responseJSON.errors[0].detail, err);
    } else if (err.status === 429 || (err.responseJSON && err.responseJSON.status === 429)) {
      throw new ImgurSlowdownError('Too Many Requests', err);
    }

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

    // If we have error data from Imgur, throw it (checking via the old response setup and the new one)
    if (err.responseJSON && err.responseJSON.data && err.responseJSON.data.error) {
      throw new ImgurError(`An error happened when uploading the image:\n${err.responseJSON.data.error.message}`, err);
    } else if (err.responseJSON && err.responseJSON.errors && err.responseJSON.errors.length !== 0 && err.responseJSON.errors[0].detail) {
      throw new ImgurSlowdownError(`An error happened when uploading the image:\n${err.responseJSON.errors[0].detail}`, 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 identifier {string}
   * @param agent {string}
   */
  constructor(version, client, userHash, identifier, agent) {
    this.version = version;
    this.client = client;
    this.userHash = userHash;
    this.identifier = identifier;
    this.agent = agent;
  }

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

    return this.client.apis.QualityControl.uploaded(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') || reason.message.includes('response status is 200')) {
        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,
        identifier: this.identifier,
        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, element },
        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');
  }

  /**
   * @returns {string}
   */
  name() {
    return 'BaseTao';
  }

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

    // Get the username
    let username = $('[aria-labelledby="profileDropdown"] a:first').text();
    if (typeof username === 'undefined' || username == null || username === '') {
      Snackbar('You need to be logged in to use this extension.');
      throw new Error('You need to be logged in to use this extension.');
    }

    // Trim the username
    username = username.trim();

    // Hash the username (and add an extra space, since this was a bug in the past)
    const userHash = SparkMD5.hash(`${username} `);

    // Ensure we know who triggered errors
    Sentry.setUser({ id: userHash, username });

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

    // Mark that this agent has been set up
    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 (unsafe, because we want the actual jQuery table)
    const $container = window.unsafeWindow.jQuery('#table').first();

    // Start processing once the table has been loaded
    elementReady('.details-tr i.bi.bi-image-fill').then(() => {
      const rowData = $container.bootstrapTable('getData');
      $container.find('i.bi.bi-image-fill').each(function () {
        const $element = $(this);
        const orderId = $element.parents('td').find('[data-row]').data('row');
        agent._buildElement($element, rowData.find(agent._getRowData(orderId)));
      });

      $container.on('load-success.bs.table', (event, data) => {
        $container.find('i.bi.bi-image-fill').each(function () {
          const $element = $(this);
          const orderId = $element.parents('td').find('[data-row]').data('row');
          agent._buildElement($(this), data.rows.find(agent._getRowData(orderId)));
        });
      });

      // Ensure tooltips
      $container.tooltip({ selector: '.qc-tooltip' });
    });
  }

  /**
   * @private
   * @param $this
   * @param {RowData} data
   * @return {Promise<BaseTaoElement>}
   */
  async _buildElement($this, data) {
    const element = new BaseTaoElement($this, data);
    const $imageIcon = element.element.parents('a').first();

    // Append download button if enabled
    if (GM_config.get('showImagesDownloadButton')) {
      const $download = $('<span style="cursor: pointer;padding-left: 5px;" class="bi bi-download text-orange" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Download all photos"></span>');
      $download.on('click', () => this._downloadHandler($download, element));
      $imageIcon.parent().append($download);
    }

    // This plugin only works for certain websites, so check if element is supported
    if (element.website === WEBSITE_UNKNOWN) {
      const $upload = $(`<div><span style="cursor: pointer;"><img src="${ImgurIcon}" alt="Create a basic album"></span></div>`);
      $upload.find('span').first().after($('<span class="qc-marker qc-tooltip" style="cursor:help;margin-left:5px;color:red;font-weight: bold;" data-bs-toggle="tooltip" data-bs-placement="bottom" 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 = $(`<div><span class="qc-tooltip" style="cursor: wait;" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Loading..."><img src="${Loading}" alt="Loading..."></span></div>`);
    $this.parents('td').first().append($loading);

    // Define upload object
    const $upload = $(`<div><span class="qc-marker qc-tooltip" style="cursor: pointer;" style="cursor: wait;" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Upload your QC"><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().html($('<span class="qc-marker qc-tooltip" style="cursor:help;color:red;font-weight: bold;" data-bs-toggle="tooltip" data-bs-placement="bottom" 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 qc-tooltip" style="cursor:help;margin-left:5px;color:green;font-weight: bold;" data-bs-toggle="tooltip" data-bs-placement="bottom" title="You have uploaded a QC">✓</span>'));
      $image.wrap(`<a href='${element.albumUrl}' target='_blank' data-bs-toggle="tooltip" data-bs-placement="bottom" 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 qc-tooltip" style="cursor:help;margin-left:5px;color:red;font-weight: bold;" data-bs-toggle="tooltip" data-bs-placement="bottom" 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 qc-tooltip" style="cursor:help;margin-left:5px;color:orange;font-weight: bold;" data-bs-toggle="tooltip" data-bs-placement="bottom" 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 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 = $(`<span style="cursor: wait;"><img src="${Loading}" alt="Processing..."></span>`);
    const $base = element.element.parents('td').first().find('div').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' data-bs-toggle="tooltip" data-bs-placement="bottom" 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('.card > 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('main div.container.container 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);
    });
  }

  /**
   * @private
   * @param orderId
   */
  _getRowData(orderId) {
    return (item) => Number(item.oid) === Number(orderId);
  }
}

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.tabletd3 > a');
    const splitText = parentTableEntry.find('td.tabletd3 > span:nth-child(3)').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);

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

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

    // Item weight
    const weight = removeWhitespaces(parentTableEntry.find('td:nth-child(7) 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.setup = false;
    this.window = window.unsafeWindow;
  }

  async build() {
    if (this.setup) {
      return this;
    }

    // Try and build the OSS client
    try {
      // Grab the OSS client
      const WindowOSS = await this._waitForValue('OSS');

      // Build the config for the bucket
      const config = {
        region: await this._waitForValue('c_region'),
        accessKeyId: await this._waitForValue('c_accessid'),
        accessKeySecret: await this._waitForValue('c_accesskey'),
        bucket: await this._waitForValue('c_bucket'),
        endpoint: `https://${await this._waitForValue('c_region')}.aliyuncs.com/`,
      };

      // Log the config, for ease of use
      Logger.info('OSS config build', config);

      // Set up the bucket for easy use
      this.window.client = new WindowOSS.Wrapper(config);

      // Mark as ready
      this.setup = true;
    } catch (e) {
      throw new Error(e);
    }

    return this;
  }

  /**
   * @param {string} orderId
   *
   * @return Promise<object>
   */
  async list(orderId) {
    if (this.setup === false) {
      throw new Error('OSS is not setup, so cannot be used');
    }

    return await this.window.client.list({
      'max-keys': 100,
      prefix: `o/${orderId}/`,
    });
  }

  _waitForValue(value) {
    return new Promise((resolve) => {
      // Check if the element already exists
      if (this.window[value]) {
        resolve(this.window[value]);

        return;
      }

      const _waitForGlobal = () => {
        if (this.window[value]) {
          resolve(this.window[value]);

          return;
        }

        // Wait until we have it
        setTimeout(() => { _waitForGlobal(value, resolve); }, 100);
      };

      // It doesn't so, so let's start waiting for it
      _waitForGlobal(value, resolve);
    });
  }
}

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

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

  /**
   * @returns {string}
   */
  name() {
    return 'CSSBuy';
  }

  /**
   * @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('.userxinix > div:nth-child(1) > p').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, this.name());
    this.qcClient = new QC(GM_info.script.version, await client, userHash, username, this.name());

    // Mark that this agent has been set up
    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;

    // If there is nothing to process, just return here (so we don't try to build the OSS client and die)
    const $elements = $(".oss-photo-view-button > a:contains('QC PIC')");
    if ($elements.length === 0) {
      return;
    }

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

    if (this.ossClient.setup === false) {
      Snackbar('Could not build the OSS client, check the console for errors.');
    }

    // Add icons to all elements
    $elements.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"><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"><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"><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"><li style="cursor: wait"><img src="${Loading}" alt="Processing..." style="width: 100%"></li></ul>`);
    const $base = element.element.parents('td').first().find('.badge-lists');
    $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');
  }

  /**
   * @returns {string}
   */
  name() {
    return 'WeGoBuy';
  }

  /**
   * @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, this.name());
    this.qcClient = new QC(GM_info.script.version, await client, userHash, username, this.name());

    // 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}
 *
 * @returns {BaseTao|CSSBuy|WeGoBuy|null}
 */
function getAgent(hostname) {
  const agents = [new BaseTao(), new CSSBuy(), new WeGoBuy()];

  let agent = null;
  Object.values(agents).forEach((value) => {
    if (agent == null && 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;
  }

  Logger.info(`Agent '${agent.name()}' detected`);

  // Finally, try to build the proper agent and process the page
  try {
    await agent.build(new SwaggerClient({ url: GM_config.get('swaggerDocUrl') }));
    await agent.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);
  }
}());