Greasy Fork

Greasy Fork is available in English.

FR:Reborn - Agents extension

Upload QCs from your favorite agent to Imgur + QC server

当前为 2021-06-15 提交的版本,查看 最新版本

// ==UserScript==
// @name         FR:Reborn - Agents extension
// @namespace    https://www.reddit.com/user/RobotOilInc
// @version      1.2.4
// @description  Upload QCs from your favorite agent to Imgur + QC server
// @author       RobotOilInc
// @match        https://www.basetao.net/index/myhome/myorder/*
// @match        https://basetao.net/index/myhome/myorder/*
// @match        https://www.basetao.com/index/myhome/myorder/*
// @match        https://basetao.com/index/myhome/myorder/*
// @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.qc-server.cf/
// @supportURL   http://greasyfork.icu/en/scripts/426977-fr-reborn-agents-extension
// @include      https://www.basetao.net/index/orderphoto/itemimg/*
// @include      https://basetao.net/index/orderphoto/itemimg/*
// @include      https://www.basetao.com/index/orderphoto/itemimg/*
// @include      https://basetao.com/index/orderphoto/itemimg/*
// @require      https://unpkg.com/sweetalert2@11/dist/sweetalert2.min.js
// @require      https://unpkg.com/[email protected]/src/logger.min.js
// @require      https://unpkg.com/[email protected]/spark-md5.min.js
// @require      https://unpkg.com/[email protected]/dist/jquery.min.js
// @require      https://unpkg.com/@phamthaibaoduy/[email protected]/dist/jquery.ajax-retry.min.js
// @require      https://unpkg.com/@sentry/[email protected]/build/bundle.min.js
// @require      https://unpkg.com/@sentry/[email protected]/build/bundle.tracing.min.js
// @require      https://unpkg.com/[email protected]/dist/swagger-client.browser.min.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==

/**
 * @param text {string}
 * @param type {null|('success'|'error'|'warning'|'info')}
 */
const Snackbar = function (text, type = null) {
  if (typeof type !== 'undefined' && type != null) {
    Swal.fire({
      title: text,
      icon: type,
      position: 'bottom-end',
      showConfirmButton: false,
      allowOutsideClick: false,
      backdrop: false,
      timer: 3000,
    });

    return;
  }

  Swal.fire({
    title: text,
    position: 'bottom-end',
    showConfirmButton: false,
    allowOutsideClick: false,
    backdrop: false,
    timer: 1500,
  });
};

// Possible sources
const SOURCE_TAOBAO = 'taobao';
const SOURCE_YUPOO = 'yupoo';
const SOURCE_WEIDIAN = 'weidian';
const SOURCE_1688 = '1688';
const SOURCE_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 === SOURCE_TAOBAO && idMatches[1].length !== 0) {
    return `https://item.taobao.com/item.htm?id=${idMatches[1]}`;
  }

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

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

  if (website === SOURCE_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('item.taobao.com') !== -1 || originalUrl.indexOf('detail.tmall.com') !== -1) {
    return SOURCE_TAOBAO;
  }

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

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

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

  return SOURCE_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) {
    const $baseElement = $element.parents('tr').find('td[colspan=\'2\']').first();

    this.object = $element;
    this.imageUrls = [];

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

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

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

    // Item color (if any)
    let color = removeWhitespaces($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;

    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())}`;

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

  /**
  * @returns {{color, size}}
  */
  get sizingInfo() {
    return { color: this.color, size: this.size };
  }

  /**
  * @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 ImgurLoading = '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>}
   */
  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') {
        Snackbar('Could not make an album, because Imgur is returning empty responses. Please try again later...', 'error');
        return;
      }

      // If we uploaded too fast, tell the user
      if (err.responseJSON.data.error.code === 429) {
        Snackbar(`Imgur is telling us to slow down: ${err.responseJSON.data.error.message}`, 'error');
        return;
      }

      // Tell the user that "something" is wrong
      Snackbar('Could not make an album, please try again later...', 'error');

      // Log the error
      Sentry.captureException(err);
      Logger.error(`Could not make an album: ${err.statusText}`, 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') {
        return false;
      }

      // If we uploaded too many files, tell the user
      if (err.responseJSON.data.error.code === 429) {
        return false;
      }

      // Log the error otherwise
      Sentry.captureException(err);
      Logger.error('An error happened when uploading the image', err);

      return false;
    });
  }

  /**
   * @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') {
        return false;
      }

      // If we uploaded too many files, tell the user
      if (err.responseJSON.data.error.code === 429) {
        return false;
      }

      // Log the error otherwise
      Sentry.captureException(err);
      Logger.error('An error happened when uploading the image', err);

      return false;
    });
  }

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

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}
     */
  constructor(version, client, userHash) {
    this.version = version;
    this.client = client;
    this.userHash = userHash;
  }

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

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

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

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

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

      // 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 };

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

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

      // 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|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.size,
        itemPrice: element.itemPrice,
        freightPrice: element.freightPrice,
        source: `BaseTao to Imgur ${this.version}`,
        website: element.website,
      },
    };

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

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

      // 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 {SwaggerClient}
   * @returns {Promise<BaseTao>}
   */
  async build(client) {
    // If already build before, just return
    if (this.setup) {
      return this;
    }

    this.client = client;
    this.imgurClient = new Imgur(GM_info.script.version, GM_config, 'BaseTao');

    // 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, but use the username hash
    const userHash = SparkMD5.hash(username);
    Sentry.setUser({ id: userHash });

    // Create QC client with user hash
    this.qcClient = new QC(GM_info.script.version, this.client, userHash);

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

    return this;
  }

  /**
   * @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="${ImgurLoading}" alt="Processing..."></span></li></ul>`);
    const $base = element.object.parents('td').first().find('ul:last-child').first();
    $base.after($processing).hide();

    Snackbar('Pictures are being uploaded....');

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

    const deleteHash = response.data.deletehash;
    const albumId = response.data.id;

    const AlbumLink = `https://imgur.com/a/${albumId}`;

    // Upload all QC images
    let uploadedImages = 0;
    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);
      }).then((uploaded) => {
        if (uploaded === false) {
          return;
        }

        uploadedImages++;
      }));
    });

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

    // If not all images have been uploaded, abort everything
    if (uploadedImages !== element.imageUrls.length) {
      Snackbar('Imgur is either rate-limiting you or just fucking up in general, please try again later.', 'error');
      this.imgurClient.RemoveAlbum(deleteHash);

      $processing.remove();
      $base.show();

      return;
    }

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

    // Tell QC Suite about our uploaded QC's (if it's supported)
    if (element.website !== SOURCE_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='${AlbumLink}' target='_blank' title='Go to album'></a>`);
    $image.removeAttr('title');

    // Remove processing
    $processing.remove();

    // Update the marker
    const $qcMarker = $base.find('.qc-marker:not(:first-child)').first();
    $qcMarker.attr('title', 'You have uploaded your QC')
      .css('cursor', 'help')
      .css('color', 'green')
      .text('✓');

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

    // Show it again
    $base.show();
  }

  /**
   * @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 and grab all image src's
    $.get(element.qcImagesUrl, (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;
      }

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

  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;

    $('.myparcels-ul').first().find('span.glyphicon.glyphicon-picture').each(async function () {
      const $this = $(this);
      const element = new BaseTaoElement($this);

      // This plugin only works for certain websites, so check if element is supported
      if (element.website === SOURCE_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', () => { agent.uploadHandler(element); });

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

        return;
      }

      const $loading = $(`<ul><li><span style="cursor: wait;margin-left:-29px;"><img src="${ImgurLoading}" 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 agent.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;
      }

      // 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'></a>`);
        $image.removeAttr('title');

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

        return;
      }

      // Has anyone ever uploaded a QC, if not, show a red marker
      const exists = await agent.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', () => { agent.uploadHandler(element); });

        $this.parents('td').first().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', () => { agent.uploadHandler(element); });

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

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

    // Ordere details
    this.orderId = removeWhitespaces($element.find('table > tbody > tr:nth-child(1) > 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').first().text()), 255);

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

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

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

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

  /**
  * @returns {{color, size}}
  */
  get sizingInfo() {
    return { size: this.size };
  }

  /**
  * @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 {SwaggerClient}
   * @returns {Promise<WeGoBuy>}
   */
  async build(client) {
    // If already build before, just return
    if (this.setup) {
      return this;
    }

    this.client = client;
    this.imgurClient = new Imgur(GM_info.script.version, GM_config, 'WeGoBuy');

    // 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, but use the username hash
    const userHash = SparkMD5.hash(username);
    Sentry.setUser({ id: userHash });

    // Create QC client
    this.qcClient = new QC(GM_info.script.version, this.client, userHash);

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

    return this;
  }

  /**
   * @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><p>FR:Reborn:</p><span style="cursor: wait;"><img src="${ImgurLoading}" alt="Processing..."></span></div>`);
    const $options = element.object.find('tbody > tr > td:nth-child(7)').first();
    const $base = $options.find('div').first();
    $base.after($processing).hide();

    Snackbar('Pictures are being uploaded....');

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

    const deleteHash = response.data.deletehash;
    const albumId = response.data.id;

    const AlbumLink = `https://imgur.com/a/${albumId}`;

    // Upload all QC images
    let uploadedImages = 0;
    const promises = [];
    $.each(element.imageUrls, (key, imageUrl) => {
      promises.push(this.imgurClient.AddImageToAlbum(imageUrl, deleteHash, element.purchaseUrl).then((uploaded) => {
        if (uploaded === false) {
          return;
        }

        uploadedImages++;
      }));
    });

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

    // If not all images have been uploaded, abort everything
    if (uploadedImages !== element.imageUrls.length) {
      Snackbar('Imgur is either rate-limiting you or just fucking up in general, please try again later.', 'error');
      this.imgurClient.RemoveAlbum(deleteHash);

      $processing.remove();
      $base.show();

      return;
    }

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

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

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

    // Add new buttons
    $options.append($('<div><p>FR:Reborn:</p>'
        + `<span class="qc-marker" style="cursor:pointer;"><a href='https://imgur.com/a/${albumId}' target='_blank' title='Go to album'><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="You have uploaded a QC">✓</span>'
        + '</div>'));

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

    // Show it again
    $base.show();
  }

  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;

    $('#table_list > div').each(async function () {
      const $this = $(this);
      const element = new WeGoBuyElement($this);

      // No pictures (like rehearsal orders), or no URL like service fees, no QC options
      if (element.imageUrls.length === 0 || element.url.length === 0) {
        return;
      }

      // This plugin only works for certain websites, so check if element is supported
      if (element.website === SOURCE_UNKNOWN) {
        const $upload = $(`<div><p>FR:Reborn:</p><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', () => { agent.UploadToImgur(element); });

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

        return;
      }

      // Define column in which to show buttons
      const $other = $this.find('tbody > tr > td:nth-child(7)').first();

      // Show simple loading animation
      const $loading = $(`<div><p>FR:Reborn:</p><span style="cursor: wait;"><img src="${ImgurLoading}" alt="Loading..."></span></div>`);
      $other.append($loading);

      // Define upload object
      const $upload = $(`<div><p>FR:Reborn:</p><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 agent.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', () => { agent.UploadToImgur(element); });

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

        return;
      }

      // Has anyone ever uploaded a QC, if not, show a red marker
      const exists = await agent.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', () => { agent.UploadToImgur(element); });

        $other.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'></a>`);
        $image.removeAttr('title');

        $other.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', () => { agent.UploadToImgur(element); });

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

/**
 * @param hostname {string}
 */
function getAgent(hostname) {
  const agents = [new BaseTao(), 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.qc-server.cf/api/doc.json',
  },
  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://qc-server.cf/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, version ${GM_info.script.version}`);

  /** @type {SwaggerClient} */
  let client;

  // Try to create Swagger client from our own documentation
  try {
    client = await new SwaggerClient({ url: GM_config.get('swaggerDocUrl') });
  } catch (error) {
    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;
  }

  // Get the proper source view, if any
  const agent = getAgent(window.location.hostname);
  if (agent !== null) {
    // Build proper agent and process page
    (await agent.build(client)).process();

    return;
  }

  Sentry.captureMessage(`Unsupported website ${window.location.hostname}`);
  Logger.error('Unsupported website');
}());