Brandon_R_Beck / BookWalker Cover Page Extractor

// ==UserScript==
// @name        BookWalker Cover Page Extractor
// @description Aids in uploading covers to MD
// @namespace   https://github.com/Brandon-Beck
// @author      Brandon Beck
// @license     MIT
// @icon        https://mangadex.org/favicon-96x96.png
// @version     0.1.44
// @include     /^(?:https?:\/\/)?bookwalker\.jp\/de[a-zA-Z0-9]+-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]+(\/.*)?/
// @include     /^(?:https?:\/\/)?bookwalker\.jp\/series\/\d+(\/.*)?/
// @include     /^(?:https?:\/\/)?mangadex\.org\/title\/\d+(\/.*)?/
// @grant       GM_xmlhttpRequest
// @require     https://gitcdn.xyz/repo/rsmbl/Resemble.js/db6f0b8298b4865c0d28ff68fab842254a249b9d/resemble.js
// ==/UserScript==
// Temporarily using github directly due to issues
// @require     https://gitcdn.xyz/repo/evidentpoint/buffer-image-size/92d014e394c05542c320c94c7d7f2b23ad449330/lib/index.js
// No longer needed
// @grant unsafeWindow
// @require https://gitcdn.xyz/repo/nodeca/pica/5.0.0/dist/pica.min.js
// FIXME: GM4 compatibility
// TODO: Amazon search?
// Manga only search query component (Does this work with Kindel? No?)
// rh=n%3A465392%2Cn%3A466280%2Cp_n_srvg_2374648051%3A86141051
// Search Title
// s?k=${MANGA_TITLE}
// s?k=こちらラスボス
// Title List document.querySelectorAll('[data-component-type="s-search-results"] .s-result-list.s-search-results > div a img')
// Preview Image List document.querySelectorAll('[data-component-type="s-search-results"] .s-result-list.s-search-results > div h2 > a')
// AmazonID = document.querySelectorAll('[data-component-type="s-search-results"] .s-result-list.s-search-results > div').dataset.asin
// Link = `https://www.amazon.co.jp/dp/${AmazonID}`
/* AMAZON Volume Page Cover Search
// Comic version
P.when("ImageBlockATF").execute((b)=>{console.log(b.imageGalleryData[0].mainUrl)}) ;
console.log(document.querySelectorAll('.sims-fbt-image'));
// Kindel version

Object.values(document.querySelectorAll('.a-carousel [data-a-dynamic-image]')).map(e=>e.src.match(/\/I\/([^.]+).*\.([^.]+)$/)).filter(e=>e!=undefined).filter(([,id,ext])=>id.startsWith('91')).map(([,id,ext])=>`https://images-na.ssl-images-amazon.com/images/I/${id}.${ext}`).map(url=>{const img = document.createElement('img')
img.crossOrigin = "Anonymous"
//fetch(url).then((r)=>r.blob()).then(b=>img.src= URL.createObjectURL(b))
img.src=url
return img}).map(img=>{document.body.appendChild(img)
return img})


// Combined
//let comicImg
//P.when("ImageBlockATF").execute((b)=>{comicImg=b.imageGalleryData[0].mainUrl}) ;
Object.values(
//[
//comicImg
//...document.querySelectorAll('img.sims-fbt-image')
//,...document.querySelectorAll('.a-carousel img[data-a-dynamic-image]')
//]
document.querySelectorAll('img')
).map(e=>e.src.match(/\/I\/([^.]+).*\.([^.]+)$/)).filter(e=>e!=undefined).filter(([,id,ext])=>id.match(/^[6789]1/)!=undefined).map(([,id,ext])=>`https://images-na.ssl-images-amazon.com/images/I/${id}.${ext}`).map(url=>{const img = document.createElement('img')
img.crossOrigin = "Anonymous"
//fetch(url).then((r)=>r.blob()).then(b=>img.src= URL.createObjectURL(b))
img.src=url
return img}).map(img=>{document.body.appendChild(img)
return img})

*/
/* eslint no-param-reassign: ["error", { "props": true, "ignorePropertyModificationsFor": ["serialData","serialDataAll","serialDataOrig"] }] */

'use strict'

function compareImages(image1 ,image2 ,options) {
  return new Promise((resolve ,reject) => {
    resemble.compare(image1 ,image2 ,options ,(err ,data) => {
      if (err) {
        reject(err)
      }
      else {
        resolve(data)
      }
    })
  })
}
async function imagePixelsAreComparable(coverP ,previewP ,requiredSimularityPercentage) {
  const misMatchThreashold = 100 - (requiredSimularityPercentage * 100)
  const cover = await coverP
  const preview = await previewP
  return new Promise((res ,rej) => {
    resemble.compare(cover ,preview ,{ misMatchThreashold } ,(err ,data) => {
      if (err) {
        return rej(err)
      }
      console.log(`Comparing Images: ${data.rawMisMatchPercentage} >= ${misMatchThreashold}`)
      return res(data.rawMisMatchPercentage >= misMatchThreashold)
    })
  })
}
// declare function sizeOf(buffer: Buffer): ImageSizeInfo;
const ERROR_IMG = 'https://i.postimg.cc/4NbKcsP6/404.gif'
// const LOADING_IMG = 'https://i.redd.it/ounq1mw5kdxy.gif'
const LOADING_IMG = 'https://media1.tenor.com/images/de4defabd471cd1150534357644aeaf2/tenor.gif?itemid=12569177'
/*
  Error Classes
*/
// FIXME use class
// FIXME add undefined type
// FIXME use status message stack
let statusMessageCallback
function setStatusMessage(status) {
  if (statusMessageCallback !== undefined) statusMessageCallback(status)
}
class BookwalkerErrorBase extends Error {
  constructor(message ,isFatal ,shouldRemoveInterface) {
    super(message)
    this.name = 'BookwalkerErrorBase'
    this.isFatal = false
    this.shouldRemoveInterface = false
    if (isFatal !== undefined) this.isFatal = isFatal
    if (shouldRemoveInterface !== undefined) this.shouldRemoveInterface = shouldRemoveInterface
    setStatusMessage(this)
    console.error(message)
  }
}
class VolumeNameParseError extends BookwalkerErrorBase {
  constructor() {
    super(...arguments)
    this.name = 'VolumeNameParseError'
  }
}
class BookwalkerLinkError extends BookwalkerErrorBase {
  constructor(message ,serialDetails ,isFatal ,shouldRemoveInterface) {
    super(message ,isFatal ,shouldRemoveInterface)
    this.name = 'BookwalkerLinkError'
    this.serialDetails = serialDetails
  }
}
class BookwalkerSearchError extends BookwalkerErrorBase {
  constructor(message ,shouldRemoveInterface) {
    super(message ,true ,shouldRemoveInterface)
    this.name = 'BookwalkerLinkError'
  }
}
class PromiseIteratorEndError extends Error {
  constructor() {
    super(...arguments)
    this.name = 'PromiseIteratorEndError'
  }
}
class PromiseIteratorBreakError extends Error {
  constructor() {
    super(...arguments)
    this.name = 'PromiseIteratorBreakError'
  }
}
class PromiseIteratorContinueError extends Error {
  constructor() {
    super(...arguments)
    this.name = 'PromiseIteratorContinueError'
  }
}
class DebugableError extends Error {
  constructor(message ,object) {
    super(message)
    this.name = 'DebugableError'
    this.object = object
    console.error(object)
  }
}
/*
  Utilities
*/
function copyToClipboard(a) {
  const b = document.createElement('textarea')
  const c = document.getSelection()
  b.textContent = a
  document.body.appendChild(b)
  if (c) c.removeAllRanges()
  b.select()
  document.execCommand('copy')
  if (c) c.removeAllRanges()
  document.body.removeChild(b)
  console.log(`Copied '${a}'`)
}
function isUserscript() {
  if (window.unsafeWindow == null) {
    return false
  }
  return true
}
// taken from https://stackoverflow.com/a/20488304
function toAsciiEquivilent(str) {
  return str.replace(/[\uff01-\uff5e]/g ,ch => String.fromCharCode(ch.charCodeAt(0) - 0xfee0))
}
// Ignore CORS
function fetchNoCORS(url) {
  return new Promise((ret ,err) => {
    GM_xmlhttpRequest({
      method: 'GET'
      ,url
      ,onerror: err
      ,ontimeout: err
      ,onload: (response) => {
        if (response.status >= 200 && response.status <= 299) {
          return ret(response)
        }
        if (response.statusText && response.statusText.length > 0) return err(Error(response.statusText))
        return err(Error(response.status.toString()))
      }
    })
  })
}
function fetchDomNoCORS(url) {
  return fetchNoCORS(url).then((r) => {
    if (r.status >= 200 && r.status <= 299) {
      const parser = new DOMParser()
      const htmlDocument = parser.parseFromString(r.responseText ,'text/html')
      return Promise.resolve(htmlDocument.documentElement)
    }
    if (r.statusText && r.statusText.length !== 0) return Promise.reject(Error(r.statusText))
    return Promise.reject(Error(r.status.toString()))
  })
}
function fetchDom(url) {
  return fetchDomNoCORS(url)
  /* return fetch(url).then((r) => {
      if (r.ok) {
        return r.text().then((html) => {
          const doctype = document.implementation.createDocumentType('html' ,'' ,'')
          const dom = document.implementation.createDocument('' ,'html' ,doctype)
          dom.documentElement.innerHTML = html
          return dom.documentElement
        })
      }
      return Promise.reject(r.statusText)
    }) */
}
// Image Utilities
function isComparableAspectRatio(coverNaturalWidth ,coverNaturalHeight ,previewNaturalWidth ,previewNaturalHeight ,tollerance = 1) {
  // Reject failed images
  if (coverNaturalWidth === 0 || coverNaturalHeight === 0) {
    return false
  }
  // const previewNaturalWidth = preview.naturalWidth
  // const previewNaturalHeight = preview.naturalHeight
  const widthDelta = previewNaturalWidth / coverNaturalWidth
  const convertW = coverNaturalWidth * widthDelta
  const convertH = coverNaturalHeight * widthDelta
  if (previewNaturalHeight > convertH + tollerance || previewNaturalHeight < convertH - tollerance) {
    return false
  }
  return true
}
// Ignore CORS
// FIXME cache 1 seriese worth of images.
// OR do not reload page when uploading.
function getImageBlobIgnoreCORS(url) {
  return new Promise((ret ,err) => {
    GM_xmlhttpRequest({
      method: 'GET'
      ,url
      ,responseType: 'blob'
      ,onerror: err
      ,ontimeout: err
      ,onload: (response) => {
        if (response.status >= 200 && response.status <= 299) {
          return ret(response.response)
        }
        return err(response)
      }
    })
  })
}
/*
  Bookwalker Utilities
*/
function getCoverUrlFromRID(rid) {
  return `https://c.bookwalker.jp/coverImage_${rid}.jpg`
}
function getVolumePageFromSeriesPage(doc) {
  const volumePage = doc.querySelector('.overview-synopsis-hdg > a')
  if (volumePage) {
    return fetchDom(volumePage.href)
  }
  return Promise.reject(Error('No volume pages found'))
}
function getCoverImgElmsFromVolumePage(doc) {
  const volumeContainerElms = doc.querySelectorAll('.detail-section.series .cmnShelf-list')
  const imgs = []
  volumeContainerElms.forEach((list) => {
    list.querySelectorAll('.cmnShelf-item').forEach((e) => {
      const img = e.querySelector('.cmnShelf-image > img')
      if (img) {
        imgs.push(img)
      }
    })
  })
  return imgs
}
function getIdFromImg(img) {
  return img.src.split('/')[3]
}
async function toImgPromiseIgnoreCORS(uri) {
  const img = document.createElement('img')
  img.crossOrigin = 'anonymous'
  let src
  if (uri instanceof Blob) {
    src = URL.createObjectURL(uri)
  }
  else if (uri instanceof Promise) {
    src = URL.createObjectURL(await uri)
  }
  else if (typeof (uri) === 'string') {
    src = uri
  }
  else if (typeof (uri) === 'object' && uri.tagName === 'IMG') {
    // FIXME double fetch
    src = uri.src
  }
  else {
    return Promise.reject(Error(`Invalid URI '${uri}'`))
  }
  return new Promise((ret ,err) => {
    img.onload = () => {
      URL.revokeObjectURL(src)
      ret(img)
    }
    img.onerror = (e) => {
      URL.revokeObjectURL(src)
      err(Error(e.toString()))
    }
    img.src = src
  })
}
function searchBookWalkerForMangaTitle(manga) {
  // cat 2 = manga
  return fetchDomNoCORS(`https://bookwalker.jp/search/?qcat=2&word=${encodeURIComponent(manga)}`)
    .catch((err) => {
      // FIXME copy stack?
      throw new BookwalkerSearchError(`Bookwalker search for '${manga}' failed: ${err.message}`)
    })
    .then(doc => Object.values(doc.querySelectorAll('.bookItem'))
      .map(bookItem => bookItem.querySelector('[class*="bookItemHover"]'))
      .filter((bookItemHover) => {
        // NOTE: only becomes more lenient if no matches found
        if (bookItemHover) {
          if (bookItemHover.title.includes(manga)) return true
          if (toAsciiEquivilent(bookItemHover.title).replace(/\s/ ,'').includes(toAsciiEquivilent(manga).replace(/\s/ ,''))) return true
        }
        return false
      }))
    .then((bookItems) => {
      if (bookItems.length === 1) {
        const { url } = bookItems[0].dataset
        if (url) return Promise.resolve(url)
        return Promise.reject(new BookwalkerSearchError('Manga Match found but failed to find Seriese/Volume URL'))
      }
      return Promise.reject(new BookwalkerSearchError('Multiple Matching Manga Found!'))
    })
}
function toImgPromise(uri) {
  let img = document.createElement('img')
  img.crossOrigin = 'anonymous'
  let src
  if (uri instanceof Blob) {
    src = URL.createObjectURL(uri)
  }
  else if (typeof (uri) === 'string') {
    src = uri
  }
  else if (typeof (uri) === 'object' && uri.tagName === 'IMG') {
    img = uri
    src = uri.src
  }
  else {
    return Promise.reject(Error(`Invalid URI '${uri}'`))
  }
  return new Promise((ret ,err) => {
    img.onload = () => {
      URL.revokeObjectURL(src)
      return ret(img)
    }
    img.onerror = (e) => {
      URL.revokeObjectURL(src)
      return err(e)
    }
    if (img.complete) {
      return ret(img)
    }
    if (img.src !== src) img.src = src
  })
}
/* function toBuffer(ab: ArrayBuffer) {
  const buf = Buffer.alloc(ab.byteLength)
  const view = new Uint8Array(ab)
  for (let i = 0; i < buf.length; ++i) {
    buf[i] = view[i]
  }
  return buf
} */
function checkValidURL(url) {
  return new Promise((ret ,err) => {
    const request = GM_xmlhttpRequest({
      method: 'HEAD'
      ,url
      ,onerror: err
      ,ontimeout: err
      ,onload: (response) => {
        if (!(response.status >= 200 && response.status <= 299)) {
          return err(Error(response.statusText))
        }
        return ret(true)
      }
    })
  })
}
function getPartialBlob(url ,startByte ,endByte) {
  return new Promise((ret ,err) => {
    const request = GM_xmlhttpRequest({
      method: 'GET'
      ,url
      ,responseType: 'blob'
      ,onerror: err
      ,ontimeout: err
      ,headers: { Range: `bytes=${startByte}-${endByte !== undefined ? endByte : ''}` }
      ,onload: (response) => {
        if (!(response.status >= 200 && response.status <= 299)) {
          return err(Error(response.statusText))
        }
        return ret(response)
      }
    })
  })
}
async function getImgIncrementaly(url ,previewPromise ,imgPart = new Blob() ,downloadToCompletion = false) {
  // Downloads in 3 steps
  // Step 1: Validate existance
  // Step 2: Validate Dimensions
  // Step 3: Download Full image
  const startByte = imgPart.size
  let stopByte
  if (!downloadToCompletion) {
    // if (imgPart.size === 0) {
    //  stopByte = 1024
    // }
    // else {
    stopByte = imgPart.size + 65536
    // }
  }
  if (imgPart.size === 0) {
    // FIXME: Beter covention
    // Checks if url returns valid statu (200-299). Throws error otherwise
    // Relying on errors this way feels wrong
    // I should at least catch and rethrow, for fun.
    checkValidURL(url)
  }
  const preview = await previewPromise
  const previewNaturalWidth = preview.naturalWidth
  const previewNaturalHeight = preview.naturalHeight
  return getPartialBlob(url ,startByte ,stopByte)
    .then(async (response) => {
      // Ensure response sanity
      const rangeMatch = response.responseHeaders.match(/content-range: bytes (\d+)-(\d+)\/(\d+)/i)
      if (!rangeMatch) {
        throw new DebugableError('Could not determin blob partial range' ,response)
      }
      const [,start ,stop ,size] = rangeMatch
      const isFinished = parseInt(stop) >= parseInt(size) - 1
      // FIXME Check for overlaps
      imgPart = new Blob([imgPart ,response.response] ,{ type: response.response.type })
      // FIXME Faster method? But... cannot find GM supported library withour hacks
      let partialCoverImg
      try {
        partialCoverImg = await toImgPromiseIgnoreCORS(imgPart)
      }
      catch (err) {
        if (!isFinished) {
          throw new PromiseIteratorContinueError(err.toString())
        }
        throw err
      }
      if (partialCoverImg.naturalWidth === 0 || partialCoverImg.naturalHeight === 0) {
        if (!isFinished) {
          throw new PromiseIteratorContinueError('Cover Image width/height cannot be 0')
        }
        else {
          throw new Error('Cover Image width/height cannot be 0')
        }
      }
      // FIXME: Ensure full size metadata was recieved? is it possible to parse while missing bytes for dimension?
      if (isComparableAspectRatio(partialCoverImg.naturalWidth ,partialCoverImg.naturalHeight ,previewNaturalWidth ,previewNaturalHeight)) {
        if (isFinished) {
          return imgPart
        }
        return getImgIncrementaly(url ,previewPromise ,imgPart ,true)
      }
      // Specialized library. Use?
      /* const coverSizeInfo = sizeOf(toBuffer(response.arraybuffer))
        if (!coverSizeInfo && parseInt(stop) >= parseInt(size) - 1) {
          throw Error('Failed to parse cover size. No more data to download')
        }
        if (!coverSizeInfo) {
        // FIXME loop
          return ret(imgBuffer + LoopSelfSomehow(stop))
        }
        if (coverSizeInfo
        && coverSizeInfo.width
        && coverSizeInfo.height
        && isComparableAspectRatio(coverSizeInfo.width ,coverSizeInfo.height ,previewNaturalWidth ,previewNaturalHeight)) {
        // FIXME Fetch rest
          return ret(response.response)
        }
        */
      throw Error('Fetched invalid image aspect ratio!')
    })
    .then((b) => {
      if (b instanceof Blob) {
        return {
          img: toImgPromiseIgnoreCORS(b) ,blob: b
        }
      }
      return b
    })
    .catch((err) => {
      if (err instanceof PromiseIteratorContinueError) {
        return getImgIncrementaly(url ,previewPromise ,imgPart)
      }
      throw err
    })
}
async function getCoverFromRid(rid ,previewPromise) {
  const url = getCoverUrlFromRID(rid)
  // NOTE: onprogress/onreadystatechange do not set response unless readyState=4 (aka, loaded state)...
  // Using partial/range request as a workaround
  return getImgIncrementaly(url ,previewPromise)
  /* return getImageBlobIgnoreCORS(url)
      .then(b => ({
        img: toImgPromiseIgnoreCORS(b) ,blob: b
      })) */
}
function getRidFromId(id) {
  return parseInt(id.toString().split('').reverse().join(''))
}
function serializeImg(img) {
  const id = getIdFromImg(img)
  const previewBlob = getImageBlobIgnoreCORS(img.src)
  const serialData = {
    id
    ,serialLevel: 0 /* BASE */
    ,preview: toImgPromiseIgnoreCORS(previewBlob)
    ,previewBlob
    ,rid: getRidFromId(id)
    ,title: img.alt
  }
  // FIXME: definitly not the right go about this.
  // new Promise((upperRes) => {
  serialData.coverPromise = new Promise((res ,rej) => {
    serialData.coverResolver = res
    serialData.coverRejector = rej
    // return upperRes()
  })
  // }).then()
  return serialData
}
function getSerialDataFromVolumePage(doc) {
  const serialData = getCoverImgElmsFromVolumePage(doc).map(img => serializeImg(img))
  return serialData
}
function getSerialDataFromSeriesPage(doc) {
  setStatusMessage('Fetching BookWalker Volume Page')
  return getVolumePageFromSeriesPage(doc)
    .then(volDoc => getSerialDataFromVolumePage(volDoc))
}
function getSerialDataFromBookwalker(url ,doc) {
  if (url.match(/^(?:https?:\/\/)?bookwalker\.jp\/series\/\d+(\/.*)?/)) {
    return getSerialDataFromSeriesPage(doc)
  }
  if (url.match(/^(?:https?:\/\/)?bookwalker\.jp\/de[a-zA-Z0-9]+-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]+(\/.*)?/)) {
    return Promise.resolve(getSerialDataFromVolumePage(doc))
  }
  return Promise.reject(Error(`Bookwalker URL expected. Got '${url}'`))
}
function fetchCoverImageFromSerialData(serialDataOrig) {
  let serialData
  if (serialDataOrig.serialLevel === 2 /* COVER */) {
    if (serialDataOrig.fetchLocked === true) {
      return Promise.reject(Error('fetchLocked'))
    }
    serialData = serialDataOrig
  }
  else {
    serialDataOrig.ready = false
    serialDataOrig.fetchLocked = true
    serialDataOrig.fetchLockedId = 0
    if (serialDataOrig.serialLevel === 0 /* BASE */) {
      serialDataOrig.maxTries = 15
    }
    serialDataOrig.triesLeft = serialDataOrig.maxTries
    serialData = serialDataOrig
    serialData.serialLevel = 2 /* COVER */
  }
  serialData.fetchLocked = true
  serialData.fetchLockedId++
  const ourLock = serialData.fetchLockedId
  // Add 1 to rid. We will premptivly subtract one in out loop
  if (!serialData.ready) {
    serialData.rid++
  }
  serialData.ready = false
  // FIXME Work with CORS/Non-Userscript mode
  function loopRun(fn) {
    return fn()
      .catch((e) => {
        // FIXME type errors
        if (e.message !== 'Out of Tries') return loopRun(fn)
        return Promise.reject(e)
      })
  }
  return loopRun(() => {
    if (serialData.triesLeft <= 0) {
      serialData.fetchLocked = false
      return Promise.reject(Error('Out of Tries'))
    }
    serialData.triesLeft--
    serialData.rid--
    setStatusMessage(`Testing BookWalker Cover: ${serialData.rid}`)
    return getCoverFromRid(serialData.rid ,serialData.preview)
      .then(async ({ img ,blob }) => {
        serialData.cover = img
        const preview = await serialData.preview
        const previewNaturalWidth = preview.naturalWidth
        const previewNaturalHeight = preview.naturalHeight
        const cover = await serialData.cover
        const coverNaturalWidth = cover.naturalWidth
        const coverNaturalHeight = cover.naturalHeight
        if (!isComparableAspectRatio(coverNaturalWidth ,coverNaturalHeight ,previewNaturalWidth ,previewNaturalHeight)) {
          return Promise.reject(Error('Invalid Aspect Ratio'))
          // return Promise.reject(Error('Invalid Aspect Ratio'))
        }
        if (blob) serialData.blob = blob
        img.then(() => {
          if (serialData.coverResolver) {
            setStatusMessage('Covers Found!')
            return serialData.coverResolver(img)
          }
          return Promise.reject(Error('Cover Resolver failed to initialize before images were found!'))
        })
        // this should never happen. else isComparableAspectRatio would fail
        img.catch(() => {
          if (serialData.coverRejector) return serialData.coverRejector(img)
          return Promise.reject(Error('Cover Rejector failed to initialize and an attempt to use it was made!'))
        })
        serialData.ready = true
        serialData.fetchLocked = false
        return serialData
      })
  })
}
function getExistingCoversFromMD() {
  return Object.values(document.querySelectorAll('.edit div[id^="volume_"]')).map((e) => {
    if (e.id) return e.id.match(/volume_(.*)/)[1]
    throw Error('Failed to find volume Id from MD cover image')
  })
}
function getSerieseDetailsFromMD(mangadexId) {
  return fetch(`https://mangadex.org/api/manga/${mangadexId}`)
    .then((r) => {
      if (r.ok) {
        return r.json().then(j => j)
      }
      return Promise.reject(r.statusText)
    })
}
function getTitleIdFromMD() {
  const m = window.location.href.match(/^https?:\/\/(?:www\.)?mangadex\.org\/title\/(\d+)(?:\/.*)?$/)
  if (m) {
    return parseInt(m[1])
  }
  throw Error('No MD Title ID Found')
}
function filterBwLink(url) {
  const series = url.match(/^((?:https?:\/\/)?bookwalker\.jp\/series\/\d+)(\/.*)?/)
  if (series) return series[1]
  const volume = url.match(/^((?:https?:\/\/)?bookwalker\.jp\/de[a-zA-Z0-9]+-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]+)(\/.*)?/)
  if (volume) return volume[1]
  return undefined
}
function japaneseConfidenceRating(str) {
  // Regex for matching Hirgana or Katakana (*)
  if (str.match(/[ぁ-んァ-ンァ-ン゙゚]/)) return 1
  // Regex for matching ALL Japanese common & uncommon Kanji (4e00 – 9fcf) ~ The Big Kahuna!
  if (str.match(/[一-龯]/)) return 0.8
  return 0
  // str.match(/[A - z]/)
  // Regex for matching Hirgana
  // str.match(/[ぁ-ん]/)
  // Regex for matching full-width Katakana (zenkaku 全角)
  // str.match(/[ァ-ン]/)
  // Regex for matching half-width Katakana (hankaku 半角)
  // str.match(/[ァ-ン゙゚]/)
  // Regex for matching full-width Numbers (zenkaku 全角)
  // str.match(/[0-9]/)
  // str.match(/[A - z]/)
  // str.match(/[ぁ - ゞ]/)
  // str.match(/[ァ - ヶ]/)
  // str.match(/[ヲ - ゚]/)
}
function getJapaneseTitlesFromMD() {
  const cont = document.querySelector('#content')
  if (cont) {
    return Object.values(cont.querySelectorAll('.fa-book')).map((e) => {
      // Parent has to exist. We are a child of cont after all
      if (e.parentElement.textContent) {
        const trimed = e.parentElement.textContent.trim()
        if (trimed.length > 0) return trimed
      }
      return undefined
    })
      .filter(e => e !== undefined) // Definitly defined now
      .sort((a ,b) => {
        const conf = japaneseConfidenceRating(b) - japaneseConfidenceRating(a)
        if (conf !== 0) return conf
        return b.length - a.length
      })
  }
  throw Error('Could not find MD Titles')
}
function getBW_CoversFromMD() {
  const id = getTitleIdFromMD()
  // FIXME why am I doing this again?
  let resolveAllSerialData
  let rejectAllSerialData
  const allSerialDataPromise = new Promise((r ,e) => {
    resolveAllSerialData = r
    rejectAllSerialData = e
  })
  let resolveBookwalkerSerieseUrl
  let rejectBookwalkerSerieseUrl
  const bookwalkerSerieseUrlPromise = new Promise((r ,e) => {
    resolveBookwalkerSerieseUrl = r
    rejectBookwalkerSerieseUrl = e
  })
  return getSerieseDetailsFromMD(id)
    .then((mangaDexDetails) => {
      // FIXME this is a little late... but until we parse the flag from current page, it will have to do
      createInterface(allSerialDataPromise ,bookwalkerSerieseUrlPromise)
      setStatusMessage('Checking for BookWalker link')
      // Try to get BW link from MD page
      if (mangaDexDetails.manga.links) {
        const { bw } = mangaDexDetails.manga.links
        if (bw) {
          const usableBw = filterBwLink(`https://bookwalker.jp/${bw}`)
          if (usableBw) {
            return Promise.resolve({
              bwLink: usableBw ,mangaDexDetails
            })
          }
          return Promise.reject(new BookwalkerLinkError(`Unusable Bookwalker Url Recieved! '${bw}'` ,mangaDexDetails))
        }
      }
      return Promise.reject(new BookwalkerLinkError('Bookwalker Url Not Found!' ,mangaDexDetails))
    })
    .then(({ bwLink ,mangaDexDetails }) => {
      setStatusMessage('Verifying Link is to a Manga!')
      return fetchDom(bwLink).then(dom => ({
        bwLink ,dom ,mangaDexDetails
      }))
    })
  // Error if non manga title
    .then(({ bwLink ,dom ,mangaDexDetails }) => {
      /*
        if (dom.querySelector('#detail-productInfo .work-tag-item a[href="https://bookwalker.jp/category/2/"]')) {
          return {
            bwLink ,dom ,mangaDexDetails
          }
        }
        */
      // Alternitivly, use
      /*
        const mainGenreElm = dom.querySelector('.detail-header-main .main-info .main-genre')
        let mainGenere
        if (mainGenreElm && mainGenreElm.textContent) mainGenere = mainGenreElm.textContent
        if (mainGenere) {
          if (mainGenere.match('マンガ')) {
            return {
              bwLink ,dom ,mangaDexDetails
            }
          }
          throw new BookwalkerLinkError(`Provided BookWalker Url is not a manga!「${mainGenere}}」!=「マンガ」` ,mangaDexDetails)
        }
        */
      // One more try
      const categoryElm = dom.querySelector('.bw_link-breadcrumb li.breadcrumb-item a[href^="https://bookwalker.jp/category/"]')
      let category
      if (categoryElm != null) category = categoryElm.textContent
      if (category != null) {
        if (category.match('マンガ')) {
          return {
            bwLink ,dom ,mangaDexDetails
          }
        }
        throw new BookwalkerLinkError(`Provided BookWalker Url is not a manga!「${category}}」!=「マンガ」` ,mangaDexDetails)
      }
      throw new BookwalkerLinkError('Failed to determine if BookWalker link is to a manga!' ,mangaDexDetails ,true)
    })
  // Chose Title to Search Bookwalker for if link is not provided or link is invalid
    .catch((err) => {
      if (!(err instanceof BookwalkerLinkError) || err.isFatal) {
        throw err
      }
      const mangaDexDetails = err.serialDetails
      if (mangaDexDetails.manga.lang_flag !== 'jp') {
        return Promise.reject(new BookwalkerLinkError(`Bookwalker is for Japanese Manga Only. This is '${mangaDexDetails.manga.lang_name}'` ,mangaDexDetails ,true ,true))
      }
      // Try auto-search
      const titles = getJapaneseTitlesFromMD()
      // FIXME do not just assume 1st result is correct
      if (titles && titles[0]) {
        setStatusMessage(`Searching BookWalker for '${titles[0]}'`)
        return searchBookWalkerForMangaTitle(titles[0]).then((searchRes) => {
          // This will be a seriese link if more than 1 volume is out
          // Else it will be a volume link
          const usableBw = filterBwLink(searchRes)
          if (usableBw) {
            setStatusMessage(`BookWalker Search resolved to '${usableBw}'`)
            return fetchDom(usableBw).then((dom) => {
              if (usableBw.startsWith('https://bookwalker.jp/series')) {
                resolveBookwalkerSerieseUrl(usableBw)
              }
              else {
                const seriesBreadcrumb = dom.querySelector('.bw_link-breadcrumb li.breadcrumb-item a[href^="https://bookwalker.jp/series/"]')
                if (seriesBreadcrumb && seriesBreadcrumb.href && seriesBreadcrumb.href.length !== 0) {
                  const seriesLink = filterBwLink(seriesBreadcrumb.href)
                  if (seriesLink) resolveBookwalkerSerieseUrl(seriesLink)
                }
              }
              // NOTE: Double resolve is safe atm BECAUSE it is a promise
              // If we move to another callback, be sure to fix this
              resolveBookwalkerSerieseUrl(usableBw)
              return Promise.resolve({
                bwLink: usableBw ,dom ,mangaDexDetails
              })
            })
          }
          return Promise.reject(new BookwalkerLinkError(`Search Gave Unusable Bookwalker Url! '${searchRes}'` ,mangaDexDetails ,true))
        })
      }
      return Promise.reject(new BookwalkerLinkError('Failed to find Bookwalker URL!' ,mangaDexDetails ,true))
    })
  // Load Serial Details
    .then(({ bwLink ,dom }) => getSerialDataFromBookwalker(bwLink ,dom))
  // Add on volume number, if possible
    .then((serialDataAll) => {
      serialDataAll.forEach((serialData) => {
        try {
          serialData.volumeNumber = toVolumeNumber(serialData.title)
        }
        catch { }
      })
      return serialDataAll
    })
  // NOTE: This filter awaits all preview images. Then REMOVES serial data with identicle previews
    .then((serialDataAll) => {
      setStatusMessage('Filtering out duplicate volume covers for same volume')
      function loopRun(fn) {
        return fn().then(serialData => loopRun(fn))
      }
      let idx = 0
      const resSerial = []
      return loopRun(() => {
        const serialData1 = serialDataAll[idx]
        idx++
        if (!serialData1) return Promise.reject(new PromiseIteratorEndError('Out of IDXs'))
        let idx2 = idx
        return loopRun(async () => {
          const serialData2 = serialDataAll[idx2]
          idx2++
          if (!serialData2) return Promise.reject(new PromiseIteratorEndError('Out of IDXs'))
          // Ignore/Skip diffrent volumes
          if (serialData1.volumeNumber !== serialData2.volumeNumber) return Promise.resolve(resSerial)
          // Compare Image size
          const [previewImg1 ,previewImg2] = [await serialData1.preview ,await serialData2.preview]
          if (previewImg1.naturalWidth !== previewImg2.naturalWidth) return Promise.resolve(resSerial)
          if (previewImg1.naturalHeight !== previewImg2.naturalHeight) return Promise.resolve(resSerial)
          // WARNING! same cover & visual same preview (for volume on sale compared to normal volume preview)
          // are generating 13% diffrence. 13%, however, is far more than large enough for false positives
          // Unexpected since preview was generated by same site
          return imagePixelsAreComparable(serialData1.previewBlob ,serialData2.previewBlob ,0.98)
            .then((b) => {
              if (!b) {
                throw new PromiseIteratorBreakError('Duplicate Found')
              }
              return resSerial
            })
        }).then(() => {
          resSerial.push(serialData1)
          return resSerial
        })
          .catch((e) => {
            if (e instanceof PromiseIteratorBreakError) return resSerial
            if (e instanceof PromiseIteratorEndError) {
              resSerial.push(serialData1)
              return resSerial
            }
            throw e
          })
      })
        .catch(() => resSerial)
    })
    .then((serialDataAll) => {
      serialDataAll.forEach((serialData) => {
        serialData.mangadexId = id
        serialData.mangadexCoverIds = getExistingCoversFromMD()
        const currentChapterCover = Object.values(document.querySelectorAll('#content .card .card-body a img')).filter(e => e.src.match(/^(?:https?:\/\/)?(?:mangadex\.org)?\/images\/manga/))[0]
        if (currentChapterCover) serialData.mangadexCurrentCover = Promise.resolve(currentChapterCover)
        try {
          serialData.volumeDecimal = toVolumeDecimal(serialData ,serialDataAll)
        }
        catch { }
        try {
          serialData.volumeLevel = toVolumeLevel(serialData ,serialDataAll)
        }
        catch { }
      })
      return serialDataAll
    })
    .then((serialDataAll) => {
      resolveAllSerialData(serialDataAll)
      function loopRun(fn) {
        return fn().then(() => loopRun(fn)).catch(() => { })
      }
      let idx = 0
      const serialDataAllSortedByLevel = serialDataAll.slice().sort((a ,b) => a.volumeLevel - b.volumeLevel)
      loopRun(() => {
        if (serialDataAllSortedByLevel[idx]) {
          return fetchCoverImageFromSerialData(serialDataAllSortedByLevel[idx]).then(() => idx++)
        }
        return Promise.reject(Error('Out of Idxs'))
      })
    })
}
function listUploadBtn(serialData ,volumeNumber ,filename) {
  const { blob } = serialData
  const form = document.querySelector('#manga_cover_upload_form')
  if (!form) {
    throw Error('No Cover Upload Form Found')
  }
  const fileNameField = form.querySelector("input[name='old_file']")
  if (!fileNameField) {
    throw Error('No Cover File Name Field Found')
  }
  fileNameField.value = filename
  const volumeNameField = form.querySelector("input[name='volume']")
  if (!volumeNameField) {
    throw Error('No Cover Volume Field Found')
  }
  volumeNameField.classList.remove('bg-danger')
  volumeNameField.classList.remove('bg-warning')
  volumeNameField.classList.remove('bg-success')
  if (volumeNumber !== '') volumeNameField.value = volumeNumber
  if (volumeNumber !== '') {
    try {
      if (serialData.volumeLevel === 0 /* NEW */) volumeNameField.classList.add('bg-success')
      else if (serialData.volumeLevel === 2 /* CONTESTED */) volumeNameField.classList.add('bg-warning')
      else if (serialData.volumeLevel === 3 /* UPLOADED */) volumeNameField.classList.add('bg-danger')
    }
    catch (e) {
      console.warn(e)
    }
  }
  const uploadBtn = form.querySelector('#upload_cover_button')
  if (!uploadBtn) {
    throw Error('No Submit Button Found')
  }
  const fileField = form.querySelector("input[type='file']")
  if (!fileField) {
    throw Error('No Cover File Field Found')
  }
  const dt = new DataTransfer() // specs compliant (as of March 2018 only Chrome)
  dt.items.add(new File([blob] ,filename))
  fileField.files = dt.files
  const showDiagBtn = document.querySelector('a[data-target="#manga_cover_upload_modal"]')
  if (showDiagBtn) showDiagBtn.click()
  return undefined
}
function blobPost(mangadexId ,volume ,blob ,filename) {
  if (!['image/png' ,'image/jpeg' ,'image/gif'].includes(blob.type)) {
    throw Error(`Unsupported Image Format '${blob.type}'`)
  }
  if (volume.trim() === '') {
    throw new VolumeNameParseError(`Invalid Volume Number '${volume}'`)
  }
  const formData = new FormData()
  formData.append('volume' ,volume)
  formData.append('old_file' ,filename)
  formData.append('file' ,blob ,filename)
  // unsafeWindow.formData = formData
  // return undefined
  fetch(`https://mangadex.org/ajax/actions.ajax.php?function=manga_cover_upload&id=${mangadexId}` ,{
    credentials: 'include'
    ,headers: {
      // 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:70.0) Gecko/20100101 Firefox/70.0'
      // ,'Accept': '*/*'
      // ,'Accept-Language': 'en-US,en;q=0.5'
      'cache-control': 'no-cache'
      ,'X-Requested-With': 'XMLHttpRequest'
      // ,'Content-Type': 'multipart/form-data; boundary=---------------------------157450823414663905041102867756'
    }
    ,referrer: `https://mangadex.org/title/${mangadexId}/ijiranaide-nagatoro-san/covers/`
    ,body: formData
    ,method: 'POST'
    ,mode: 'cors'
  })
}
/*
  Interface
*/
function toVolumeNumber(title) {
  // FIXME regex delete BookWalker seriese title out prior to search
  // NOTE. Chapter number is sometimes placed in middle of title...
  let volumeMatch = toAsciiEquivilent(title).match(/\((\d+(?:\.\d+)?)\)$/)
  if (!volumeMatch) volumeMatch = toAsciiEquivilent(title).match(/\D(\d+(?:\.\d+)?)$/)
  if (!volumeMatch) volumeMatch = toAsciiEquivilent(title).match(/\D(\d+(?:\.\d+)?)\D*?$/)
  let volume
  if (volumeMatch) {
    [,volume] = volumeMatch
  }
  if (volume) return volume
  throw new VolumeNameParseError(`Failed to parse volume number for title '${title}'`)
}
function toVolumeDecimal(serialData ,allSerialData) {
  let volumeNumber
  try {
    volumeNumber = toVolumeNumber(serialData.title)
    // Consider checking if we already have a decimal place?
    if (allSerialData) {
      const decimalPlace = allSerialData.filter(e => toVolumeNumber(e.title) === volumeNumber)
        .indexOf(serialData)
      if (decimalPlace !== 0) {
        volumeNumber = `${volumeNumber}.${decimalPlace}`
      }
    }
  }
  catch (e) {
    // Failed to calculate decimal place
    console.log(e)
  }
  return volumeNumber
}
function toVolumeLevel(serialData ,allSerialData) {
  let volumeLevel = 1 /* UNKNOWN */
  if (serialData.mangadexId !== undefined) {
    try {
      const volumeNumber = toVolumeDecimal(serialData ,allSerialData)
      let volumeNumberIsSane = true
      // FIXME Multiple covers same volume needs work
      try {
        // Ensure we do not already have a decimal place
        if (allSerialData) {
          if (volumeNumber) {
            if (parseInt(volumeNumber) > 2
                            && !allSerialData.find(e => parseInt(toVolumeNumber(e.title)) === parseInt(volumeNumber) - 1)) volumeNumberIsSane = false
          }
          else volumeNumberIsSane = false
        }
      }
      catch (e) {
        // Failed to calculate decimal place
        console.log(e)
      }
      if (volumeNumberIsSane && volumeNumber) {
        const hasVolume = serialData.mangadexCoverIds.map((e) => {
          // Prefers IDK state 2 over Do Not Upload state 1 and Do upload state 0
          if (e === volumeNumber) return 1
          if (parseInt(e) === parseInt(volumeNumber)) return 2
          return 0
        }).sort((a ,b) => b - a)[0]
        if (hasVolume === 0 || hasVolume === undefined) volumeLevel = 0 /* NEW */
        else if (hasVolume === 2) volumeLevel = 2 /* CONTESTED */
        else if (hasVolume === 1) volumeLevel = 3 /* UPLOADED */
      }
    }
    catch (e) {
      console.warn(e)
    }
  }
  return volumeLevel
}
function createSingleInterface(serialData ,allSerialData) {
  const cont = document.createElement('div')
  const titleInfoContainer = document.createElement('div')
  const imageInfoContainer = document.createElement('div')
  const title = document.createElement('h4')
  const sizeInfo = document.createElement('small')
  const coverCont = document.createElement('div')
  const cover = document.createElement('img')
  const copy = document.createElement('button')
  const next = document.createElement('button')
  const controls = document.createElement('div')
  controls.appendChild(copy)
  controls.appendChild(next)
  controls.style.position = 'relative'
  controls.style.display = 'flex'
  copy.style.flexGrow = '1'
  copy.style.whiteSpace = 'normal'
  copy.type = 'button'
  copy.classList.add('btn' ,'btn-secondary')
  next.style.flexGrow = '1'
  next.style.whiteSpace = 'normal'
  next.type = 'button'
  next.classList.add('btn' ,'btn-secondary')
  coverCont.style.position = 'relative'
  // title.style.wordWrap = 'anywhere'
  // FIXME: Hacky way to format MD and BW. Should just set fontsize em
  title.classList.add('h6')
  titleInfoContainer.appendChild(title)
  imageInfoContainer.appendChild(sizeInfo)
  const coverDisplayWidth = 200
  controls.style.width = `${coverDisplayWidth}px`
  coverCont.style.width = `${coverDisplayWidth}px`
  coverCont.appendChild(cover)
  let preview
  serialData.preview.then((serialPreview) => {
    preview = serialPreview
    preview.width = Math.ceil(coverDisplayWidth / 4)
    preview.style.left = '5px' // `${-coverDisplayWidth}px`
    preview.style.position = 'absolute'
    preview.style.bottom = '5px' // `${(Math.ceil(expectedHeight/4)) - expectedHeight}px`
    preview.style.outlineWidth = '5px'
    preview.style.outlineStyle = 'none'
    const aspectDelta = preview.naturalWidth / coverDisplayWidth
    const expectedHeight = preview.naturalHeight * aspectDelta
    // coverCont.style.maxHeight=`${Math.ceil(expectedHeight)}px`
    // coverCont.style.minHeight=`${Math.ceil(expectedHeight)}px`
    coverCont.style.height = `${Math.ceil(expectedHeight)}px`
    coverCont.appendChild(preview)
  })
  // preview.style.zIndex=1
  coverCont.style.display = 'flex'
  cover.style.alignSelf = 'center'
  cover.style.outlineWidth = '5px'
  cover.style.outlineStyle = 'none'
  cover.style.width = '100%'
  titleInfoContainer.style.marginBottom = 'auto'
  ;[titleInfoContainer ,imageInfoContainer].forEach((info) => {
    info.style.display = 'flex'
    info.style.alignItems = 'center'
    info.style.flexDirection = 'column'
    cont.appendChild(info)
  })
  cont.style.marginLeft = '5px'
  cont.appendChild(coverCont)
  cont.appendChild(controls)
  cont.style.display = 'flex'
  cont.style.flexDirection = 'column'
  cont.style.width = `${coverDisplayWidth}px`
  next.innerText = 'Next'
  copy.innerText = 'Copy'
  const uploadBtn = copy.cloneNode()
  uploadBtn.innerText = 'Upload'
  // FIXME: do this once. reuse code
  // Only add Upload button if we are on mangadex's site
  if (serialData.mangadexId !== undefined) {
    if (serialData.volumeLevel === 0 /* NEW */) title.classList.add('text-success')
    else if (serialData.volumeLevel === 2 /* CONTESTED */) title.classList.add('text-warning')
    else if (serialData.volumeLevel === 3 /* UPLOADED */) title.classList.add('text-danger')
    controls.appendChild(uploadBtn)
  }
  let copyTimeout1
  let copyTimeout2
  function tryUpload() {
    if (serialData.serialLevel === 2 /* COVER */
            && serialData.blob && serialData.mangadexId !== undefined) {
      const imageTypeMatch = serialData.blob.type.match(/^image\/(.+)/)
      let volumeNumber = serialData.volumeDecimal
      if (serialData.volumeLevel === 1 /* UNKNOWN */) volumeNumber = ''
      if (serialData.volumeNumber === undefined) volumeNumber = ''
      if (imageTypeMatch !== null && volumeNumber !== null) {
        const imageType = imageTypeMatch[1]
        listUploadBtn(serialData ,volumeNumber ,`${serialData.title}.${imageType}`)
      }
    }
  }
  function tryCopy() {
    if (!copy.disabled) {
      cover.style.outlineStyle = 'double'
      cover.style.outlineColor = 'yellow'
      if (preview) {
        preview.style.outlineStyle = 'double'
        preview.style.outlineColor = 'yellow'
      }
      cover.style.zIndex = '1'
      copyToClipboard(getCoverUrlFromRID(serialData.rid))
      copy.innerText = 'Coppied!'
      clearTimeout(copyTimeout1)
      clearTimeout(copyTimeout2)
      copyTimeout1 = setTimeout(() => {
        copy.innerText = 'Copy'
      } ,2000)
    }
    else {
      cover.style.outlineStyle = 'solid'
      if (preview) {
        preview.style.outlineStyle = 'solid'
        preview.style.outlineColor = 'red'
      }
      cover.style.outlineColor = 'red'
      copy.innerText = 'Cannot Copy!'
    }
    copyTimeout2 = setTimeout(() => {
      cover.style.outlineStyle = 'none'
      if (preview) {
        preview.style.outlineStyle = 'none'
      }
      cover.style.zIndex = '0'
    } ,500)
  }
  copy.onclick = () => {
    tryCopy()
  }
  cover.onclick = () => {
    tryCopy()
  }
  uploadBtn.onclick = () => {
    tryUpload()
  }
  let lastBlobUri
  function revokeLastUri() {
    if (lastBlobUri !== undefined) {
      URL.revokeObjectURL(lastBlobUri)
      lastBlobUri = undefined
    }
  }
  cover.onload = revokeLastUri
  cover.onerror = revokeLastUri
  // NOTE: this is same serial data obj as above, just some more type narowing done.
  function updateCover(serialDataCover) {
    let url = getCoverUrlFromRID(serialDataCover.rid)
    revokeLastUri()
    if (serialDataCover.blob) {
      url = URL.createObjectURL(serialDataCover.blob)
      lastBlobUri = url
    }
    cover.src = url
    serialDataCover.coverPromise.then((coverImg) => {
      sizeInfo.innerText = `${coverImg.naturalWidth}×${coverImg.naturalHeight}`
      if (serialDataCover.mangadexCurrentCover) {
        serialDataCover.mangadexCurrentCover.then((mdChapterCover) => {
          sizeInfo.title = `MD Chapter Cover: ${mdChapterCover.naturalWidth}×${mdChapterCover.naturalHeight}`
          sizeInfo.classList.remove('text-danger')
          sizeInfo.classList.remove('text-warning')
          if (mdChapterCover.naturalWidth - 10 > coverImg.naturalWidth
                        || mdChapterCover.naturalHeight - 10 > coverImg.naturalHeight) {
            sizeInfo.classList.add('text-danger')
            sizeInfo.title = `Smaller than Chapter Cover: ${mdChapterCover.naturalWidth}×${mdChapterCover.naturalHeight}`
          }
          // TODO aspect ratio check
          else {
            // const coverImg = await serialData.cover
            const coverNaturalWidth = coverImg.naturalWidth
            const coverNaturalHeight = coverImg.naturalHeight
            if (!isComparableAspectRatio(coverNaturalWidth ,coverNaturalHeight ,mdChapterCover.naturalWidth ,mdChapterCover.naturalHeight ,10)) {
              sizeInfo.classList.add('text-warning')
              sizeInfo.title = `Chapter Cover Aspect Ratio Missmatch: ${mdChapterCover.naturalWidth}×${mdChapterCover.naturalHeight}`
            }
          }
          //  sizeInfo.title = `MD Aspect Ratio Missmatch: ${mdChapterCover.naturalWidth}×${mdChapterCover.naturalHeight}`
          // }
        })
      }
    }).catch()
  }
  function enable() {
    next.disabled = false
    copy.disabled = false
    uploadBtn.disabled = false
    next.innerText = 'Wrong Image?'
    copy.innerText = 'Copy'
  }
  function loading() {
    cover.src = LOADING_IMG
    sizeInfo.innerText = ''
    next.disabled = true
    copy.disabled = true
    uploadBtn.disabled = true
    next.innerText = 'Looking for Image'
  }
  function fail() {
    cover.src = ERROR_IMG
    sizeInfo.innerText = ''
    next.disabled = false
    copy.disabled = true
    uploadBtn.disabled = true
    next.innerText = 'Not Found! Retry?'
    serialData.rid = getRidFromId(serialData.id)
    serialData.triesLeft = serialData.maxTries
    serialData.ready = false
  }
  loading()
  title.innerText = serialData.title
  serialData.coverPromise.then(() => {
    updateCover(serialData)
    enable()
  }).catch(fail)
  next.onclick = () => {
    loading()
    fetchCoverImageFromSerialData(serialData).then((/* same serialData Object */) => {
      enable()
      updateCover(serialData)
    }).catch(fail)
  }
  return cont
}
function createInterface(allSerialDataPromise ,bookwalkerUrlPromise) {
  const cont = document.createElement('div')
  const copyAll = document.createElement('button')
  copyAll.type = 'button'
  copyAll.style.whiteSpace = 'normal'
  copyAll.style.display = 'flex'
  copyAll.style.flexGrow = '1'
  copyAll.style.flexDirection = 'column'
  copyAll.style.width = '100%'
  copyAll.style.outlineStyle = 'none'
  copyAll.style.outlineWidth = '5px'
  copyAll.style.outlineColor = 'yellow'
  copyAll.innerText = 'Copy All Cover URLs'
  copyAll.style.fontSize = '3em'
  copyAll.disabled = true
  copyAll.classList.add('btn')
  const copyBookwalkerUrl = copyAll.cloneNode()
  copyBookwalkerUrl.innerText = 'Copy BookWalker Series URL'
  copyBookwalkerUrl.classList.add('btn-primary')
  copyAll.classList.add('btn-secondary')
  // cont.style.marginLeft = '200px'
  cont.style.marginLeft = '35px'
  cont.style.marginRight = '35px'
  cont.style.display = 'flex'
  cont.style.flexWrap = 'wrap'
  cont.appendChild(copyAll)
  if (bookwalkerUrlPromise) {
    bookwalkerUrlPromise.then((url) => {
      let copyTimeout
      copyBookwalkerUrl.disabled = false
      cont.insertBefore(copyBookwalkerUrl ,copyAll)
      copyBookwalkerUrl.addEventListener('click' ,() => {
        // copyBookwalkerUrl.style.outlineStyle = 'double'
        copyBookwalkerUrl.style.zIndex = '1'
        copyBookwalkerUrl.innerText = 'Coppied BookWalker Series URL!'
        copyToClipboard(url)
        clearTimeout(copyTimeout)
        copyTimeout = setTimeout(() => {
          copyBookwalkerUrl.style.outlineStyle = 'none'
          copyBookwalkerUrl.innerText = 'Copy BookWalker Series URL'
          copyBookwalkerUrl.style.zIndex = '0'
        } ,2000)
      })
    })
  }
  statusMessageCallback = (status) => {
    if (status instanceof BookwalkerErrorBase) {
      copyAll.innerText = status.message
      if (status.isFatal) copyAll.classList.replace('btn-secondary' ,'btn-danger')
      if (status.shouldRemoveInterface) {
        copyAll.remove()
        // setTimeout(() => copyAll.remove() ,5000)
      }
    }
    else copyAll.innerText = status
  }
  allSerialDataPromise.then((allSerialData) => {
    copyAll.disabled = false
    let copyTimeout1
    function tryCopy() {
      if (!copyAll.disabled) {
        // copyAll.style.outlineStyle = 'double'
        copyAll.style.zIndex = '1'
        copyAll.innerText = 'Coppied All Cover URLs!'
        const urls = allSerialData.reduce((a ,e) => {
          if (e.ready) {
            return `${a}\n${getCoverUrlFromRID(e.rid)}`.trim()
          }
          return a
        } ,'')
        copyToClipboard(urls)
        clearTimeout(copyTimeout1)
        copyTimeout1 = setTimeout(() => {
          copyAll.style.outlineStyle = 'none'
          copyAll.innerText = 'Copy All Cover URLs'
          copyAll.style.zIndex = '0'
        } ,2000)
      }
    }
    copyAll.addEventListener('click' ,tryCopy)
    const faces = allSerialData.map(e => createSingleInterface(e ,allSerialData))
    faces.forEach((e) => {
      cont.appendChild(e)
    })
  })
  document.body.appendChild(cont)
  return cont
}
// Run On MD
if (window.location.href.match(/^(?:https?:\/\/)?mangadex\.org\/title\/\d+\/[^/]+\/covers(\/.*)?/)) {
  getBW_CoversFromMD()
    .catch((e) => {
      if (e instanceof Error) setStatusMessage(e.message)
      throw e
    })
}
// Run On BW
else if (window.location.href.match(/^https?:\/\/(?:www\.)?book/)) {
  const sideMenu = document.querySelector('.side-deals')
  if (sideMenu) sideMenu.remove()
  getSerialDataFromBookwalker(window.location.href ,document.documentElement)
    .then((serialData) => {
      createInterface(Promise.resolve(serialData))
      function loopRun(fn) {
        return fn().then(() => loopRun(fn)).catch(() => { })
      }
      let idx = 0
      loopRun(() => {
        if (serialData[idx]) {
          return fetchCoverImageFromSerialData(serialData[idx]).then(() => idx++)
        }
        return Promise.reject(Error('Out of Idxs'))
      })
    })
    .catch((e) => {
      if (e instanceof Error) setStatusMessage(e.message)
      throw e
    })
}