Booru-Selector-Downloader

A selector and downloader for the various booru imageboards

// ==UserScript==
// @name              Booru-Selector-Downloader
// @namespace         http://tampermonkey.net/
// @icon              https://yande.re/favicon.ico
// @version           4.0.1
// @description       A selector and downloader for the various booru imageboards
// @description:en    A selector and downloader for the various booru imageboards
// @description:zh    图站选择下载工具
// @author            Beats0
// @license           GPL-3.0 License
// @match             *://yande.re/post*
// @match             *://konachan.net/*
// @match             *://konachan.com/*
// @match             *://gelbooru.com/*
// @match             *://danbooru.donmai.us/*
// @match             *://sonohara.donmai.us/*
// @include           *://yande.re/*
// @include           *://konachan.net/*
// @include           *://konachan.com/*
// @include           *://gelbooru.com/*
// @include           *://danbooru.donmai.us/*
// @include           *://sonohara.donmai.us/*
// @grant             GM_addStyle
// @grant             GM_download
// @grant             GM_openInTab
// @home-url          https://greasyfork.org/zh-CN/scripts/371605-booru-selector-downloader
// @home-url2         https://github.com/Beats0/scripter
// ==/UserScript==

/**
 * ### Hot keys
 *
 * `A`: Previous page
 * `D`: Next page
 * `Q`: Select/Deselect all image
 * `S`: Save sample image
 * `X`: Save original image(if no original image, the downloader will download the sample image)
 * `F`: Favorite image
 * `R`: Remove from favorites
 * `Ctrl + MouseClick`: Open in the new window
 * `Alt + MouseClick`: Open in the new window and auto focus the new tab
 * `Shift + MouseHover`: Show preview image when hover the image, default scale size is `scale(2.5, 2.5)`
 * */

(function () {
  'use strict';
  const originUrl = document.location.origin;
  const locationUrl = document.location.protocol + '//' + window.location.host;
  const REyande = /yande/,
    REkonachan = /konachan/,
    REgelbooru = /gelbooru/,
    REdanbooru = /danbooru/,
    REsonohara = /sonohara/;
  const re1 = /\d\w+/,
    re2 = /([a-fA-F0-9]{32})/,
    re3 =/\.[0-9a-z]+$/i;
  const REyandeResult = REyande.test(originUrl);
  const REkonachanResult = REkonachan.test(originUrl);
  const REdanbooruResult = REdanbooru.test(originUrl) || REsonohara.test(originUrl);
  const REgelbooruResult = REgelbooru.test(originUrl);
  let parse = null
  const reTrySvgIcon = `<svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="11702" width="20" height="20"><path d="M512 214.016q141.994667 0 242.005333 100.010667t100.010667 240q0 141.994667-100.992 242.005333t-240.981333 100.010667-240.981333-100.010667-100.992-242.005333l86.016 0q0 105.984 75.008 180.992t180.992 75.008 180.992-75.008 75.008-180.992-75.008-180.992-180.992-75.008l0 171.989333-214.016-214.016 214.016-214.016 0 171.989333z" p-id="11703" fill="#ee8887"></path></svg>`

  function $(selector) {
    return document.querySelector(selector)
  }

  function $$(selector) {
    return document.querySelectorAll(selector)
  }

  function domParser(fragment) {
    if(!parse) {
      const range = document.createRange();
      parse = range.createContextualFragment.bind(range);
    }
    return parse(fragment)
  }

  function promiseFetch(url, data) {
    return new Promise((resolve, reject) => {
      fetch(url, data)
        .then(res => {
          if (res.ok) {
            resolve(res);
          } else {
            throw res;
          }
        })
        .catch(err => {
          reject(err);
        });
    });
  }

  class BooruDownloader {
    constructor() {
      this.batchCount = 0
      this.downloadLimit = 4
      this.hoverEl = null
      this.cacheImg = {} // {id: src}
      this.init()
    }

    init() {
      console.log('init BooruDownloader')
      if (REyandeResult || REkonachanResult) {
        let posts = $('#post-list-posts');
        if (!posts) return
        this.init_yande_konachan();
      }
      if (REdanbooruResult) {
        const posts = $('.posts-container')
        if(!posts) return
        this.init_danbooru();
      }
      if (REgelbooruResult) {
        const posts = $('.thumbnail-container')
        if(!posts) return
        this.init_gelbooru();
      }
      this.initStyle()
      this.initMenuPanel()
      this.initHotKey()
    }

    initStyle() {
      const styleCode = `
        :root {
                --primary-backgroundColor: #eee;
                --primary-lineBackgroundColor: #ccc;
                --primary-fontColor: #ee8887;
                --primary-fontColorHover: #ff4342;
                --primary-headerFontColor: #ffffff;
                --primary-border: 1px solid transparent;
            }
      
            [data-theme=light] .darkToggleIcon {
                display: none;
            }
            [data-theme=dark] .lightToggleIcon {
                display: none;
            }

            ul#post-list-posts {
               padding-bottom: 350px;
            }
        
            ul#post-list-posts li {
               float: none
            }
      
            div#posts {
               padding-bottom: 200px;
            }
            .imgItem {
                transition: .2s;
            }
            .imgItem:hover, .imgItem:focus {
                outline: 1px solid var(--primary-fontColor);
            }
            .imgItem img {
                transition: .2s;
             }
      
            .imgItemChecked {
                outline: 1px solid var(--primary-fontColor);
            }
      
            article.post-preview {
                float: none;
            }
      
            .helper-board {
                width: 450px;
                height: 320px;
                position: fixed;
                font-size: 12px;
                right: 10px;
                bottom: 4px;
                background: var(--primary-backgroundColor);
                color: var(--primary-fontColor);
                border: var(--primary-border);
                border-radius: 5px;
                overflow: hidden;
                transition: all cubic-bezier(.22,.58,.12,.98) .4s;
                box-shadow: 0 2px 12px 0 rgba(246, 150, 149, 0.6);
            }
      
            .helper-board.helper-board-small {
                width: 50px;
                height: 50px;
                border-radius: 50%;
                bottom: 50vh;
                cursor: pointer;
                background: var(--primary-fontColor);
            }
      
            .helper-board.helper-board-small .board-header {
                width: 50px;
                height: 50px;
                border-radius: 50%;
            }
            .helper-board.helper-board-small .board-header .board-close-button, .helper-board.helper-board-small .board-header .board-header-text, .helper-board.helper-board-small .theme-btn {
                display: none;
            }
            .helper-board.helper-board-small .board-header .board-header-small-tip {
                display: block;
                width: 50px;
                height: 50px;
                border-radius: 50%;
                line-height: 50px;
                text-align: center;
                font-size: 26px;
            }
      
            .board-header {
                height: 30px;
                color: var(--primary-headerFontColor);
                background: var(--primary-fontColor);
                font-size: 16px;
            }
      
            .board-header-inner {
                display: flex;
                align-items: center;
                justify-content: space-between;
                padding-right: 32px;
            }
      
             .board-content {
                overflow-y: auto;
                overflow-x: hidden;
                max-height: 340px;
                padding: 10px 12px 50px 12px;
                height: 100%;
                font-size: 14px;
            }
      
            .board-header-text {
                line-height: 30px;
                padding-left: 10px;
                font-size: 14px;
            }
      
            .board-close-button {
                position: absolute;
                top: 6px;
                right: 3px;
                width: 20px;
                height: 20px;
                margin: 0;
                padding: 0;
                cursor: pointer;
                transition: all .3s;
            }
      
            .board-close-button:hover {
                color: var(--primary-headerFontColor);
            }
            .board-header-small-tip {
                display: none;
            }
      
            .board-content-row {
                display: flex;
                align-items: center;
                margin-bottom: 8px;
                height: 20px;
            }
      
            .row-label {
                color: var(--primary-fontColor);
                margin-right: 8px;
            }
            .row-content {
                display: flex;
                align-items: center;
            }
            .hover-item-line {
                color: var(--primary-fontColor);
                text-decoration: underline!important;
                cursor: pointer;
            }
            .hover-item-line:hover {
                color: var(--primary-fontColorHover);
            }
            .hover-item {
                color: var(--primary-fontColor);
                cursor: pointer;
            }
            .hover-item:hover {
                color: var(--primary-fontColorHover);
            }
      
            .download-row-container {
                margin-top: 15px;
                padding-bottom: 15px;
            }
            .download-row {
                display: flex;
                align-items: center;
                margin-bottom: 5px;
            }
            .download-row-title {
                color: var(--primary-fontColor);
                margin-right: 10px;
            }
            .download-row-line {
                flex: 1;
                height: 4px;
                background: var(--primary-lineBackgroundColor);
                margin-right: 10px;
            }
            .download-row-line-active {
                height: 100%;
                background: var(--primary-fontColor);
                transition: width .4s ease;
            }
            .download-row-percent {
                color: var(--primary-fontColor);
                width: 50px;
            }
            .fav-state {
                margin-left: 7px;
                color: var(--primary-fontColor);
            }
            .re-try-icon svg {
                margin-left: 5px;
                cursor: pointer;
                transform: translateY(3px);
            }
      
            .theme-btn {
                background: none;
                border: none;
                color: var(--primary-headerFontColor);
                cursor: pointer;
                font-family: inherit;
                padding: 0;
                align-items: center;
                border-radius: 50%;
                display: flex;
                height: 100%;
                justify-content: center;
                transition: all 200ms;
            }
            .theme-btn:hover {
              background: #ebedf0;
              color: var(--primary-fontColor);
            }
            .imgTransform {
              opacity: 1!important;
              z-index: 999;
            }
            .imgTransform img {
              transform: scale(2.5, 2.5);
              transition: .2s;
            }
            .previewTip .imgItem {
               opacity: 0.5;
            }
            .imgTransform .thumb {
                position: absolute;
                z-index: 1;
            }
            .hide {
                width: 0;
                height: 0;
                display: none!important;
            }
      `
      GM_addStyle(styleCode)
    }

    init_yande_konachan() {
      let posts = $('#post-list-posts');
      let postsItems = posts.querySelectorAll('li');
      for (let i = 0; i < postsItems.length; i++) {
        postsItems[i].classList.add('imgItem');
        postsItems[i].firstElementChild.firstElementChild.setAttribute('onclick', 'return false');
        const template = `<div style="position: relative;text-align: center;"><input type="checkbox" class="checkbox"></div>`
        postsItems[i].insertAdjacentHTML('afterbegin', template);
        postsItems[i].addEventListener('mouseover', (e) => this.setTransition(e, 'mouseover', i))
        postsItems[i].addEventListener('mouseout', (e) => this.setTransition(e, 'mouseout', i))
      }
      posts.addEventListener('click', (e) => this.handleClickImg(e))
    }

    init_danbooru() {
      const posts = $('.posts-container')
      const postsItems = posts.querySelectorAll('article');
      for (let i = 0; i < postsItems.length; i++) {
        postsItems[i].classList.add('imgItem');
        postsItems[i].firstElementChild.setAttribute('onclick', 'return false');
        const template = `<div style="position: relative;text-align: center;"><input type="checkbox" class="checkbox"></div>`
        postsItems[i].insertAdjacentHTML('afterbegin', template);
        postsItems[i].addEventListener('mouseover', (e) => this.setTransition(e, 'mouseover', i))
        postsItems[i].addEventListener('mouseout', (e) => this.setTransition(e, 'mouseout', i))
      }
      posts.addEventListener('click', (e) => this.handleClickImg(e))
    }

    init_gelbooru() {
      const posts = $('.thumbnail-container')
      const postsItems = posts.querySelectorAll('article');
      for (let i = 0; i < postsItems.length; i++) {
        postsItems[i].classList.add('imgItem');
        postsItems[i].style.position = 'relative'
        postsItems[i].firstElementChild.setAttribute('onclick', 'return false');
        const template = `<div style="position: absolute; top: 0; text-align: center;"><input type="checkbox" class="checkbox"></div>`
        postsItems[i].insertAdjacentHTML('afterbegin', template);
        postsItems[i].addEventListener('mouseover', (e) => this.setTransition(e, 'mouseover', i))
        postsItems[i].addEventListener('mouseout', (e) => this.setTransition(e, 'mouseout', i))
      }
      posts.addEventListener('click', (e) => this.handleClickImg(e))
    }

    initMenuEvent() {
      const headerEl = $('.board-header')
      headerEl.onclick = function (e) {
        const boardEl = headerEl.parentNode
        if(boardEl.classList.contains('helper-board-small')) {
          boardEl.classList.remove('helper-board-small')
        } else {
          if(e.target.className === 'board-close-button') {
            boardEl.classList.add('helper-board-small')
          }
        }
      }
      const theme = localStorage.getItem('h-theme') || 'light'
      const showToolTip = localStorage.getItem('h-show-tooltip') || '1'
      this.setTheme(theme)
      this.handleToggleToolTip(showToolTip)

      $('.theme-btn').addEventListener('click', (e) => this.handleToggleTheme(e))
      $('#buttonSelectAll').addEventListener('click', (e) => this.handleClickMenuAllBtn())
      $('#downloadSample').addEventListener('click', (e) => this.handleDownLoadImg(e, 'sample'))
      $('#downloadOriginal').addEventListener('click', (e) => this.handleDownLoadImg(e, 'original'))
      $('#addFavorite').addEventListener('click', (e) => this.handleFavorite(true))
      $('#removeFavorite').addEventListener('click', (e) => this.handleFavorite(false))
      $('.fav-list-container').addEventListener('click', (e) => this.handleClickFavList(e, 'fav-list-container'))
      $('#showToolTipBtn').addEventListener('click', (e) => {
        const newShowToolTip = localStorage.getItem('h-show-tooltip') === '1' ? '0' : '1'
        this.handleToggleToolTip(newShowToolTip)
      })
    }

    initMenuPanel() {
      const template = `
        <div class="helper-board">
         <div class="board-header">
            <div class="board-header-inner">
              <div class="board-header-text">Booru-Selector-Downloader</div>
              <button class="theme-btn"
                      type="button"
                      title="Change Theme">
                <svg viewBox="0 0 24 24" width="20" height="20" class="lightToggleIcon">
                  <path fill="currentColor" d="M12,9c1.65,0,3,1.35,3,3s-1.35,3-3,3s-3-1.35-3-3S10.35,9,12,9 M12,7c-2.76,0-5,2.24-5,5s2.24,5,5,5s5-2.24,5-5 S14.76,7,12,7L12,7z M2,13l2,0c0.55,0,1-0.45,1-1s-0.45-1-1-1l-2,0c-0.55,0-1,0.45-1,1S1.45,13,2,13z M20,13l2,0c0.55,0,1-0.45,1-1 s-0.45-1-1-1l-2,0c-0.55,0-1,0.45-1,1S19.45,13,20,13z M11,2v2c0,0.55,0.45,1,1,1s1-0.45,1-1V2c0-0.55-0.45-1-1-1S11,1.45,11,2z M11,20v2c0,0.55,0.45,1,1,1s1-0.45,1-1v-2c0-0.55-0.45-1-1-1C11.45,19,11,19.45,11,20z M5.99,4.58c-0.39-0.39-1.03-0.39-1.41,0 c-0.39,0.39-0.39,1.03,0,1.41l1.06,1.06c0.39,0.39,1.03,0.39,1.41,0s0.39-1.03,0-1.41L5.99,4.58z M18.36,16.95 c-0.39-0.39-1.03-0.39-1.41,0c-0.39,0.39-0.39,1.03,0,1.41l1.06,1.06c0.39,0.39,1.03,0.39,1.41,0c0.39-0.39,0.39-1.03,0-1.41 L18.36,16.95z M19.42,5.99c0.39-0.39,0.39-1.03,0-1.41c-0.39-0.39-1.03-0.39-1.41,0l-1.06,1.06c-0.39,0.39-0.39,1.03,0,1.41 s1.03,0.39,1.41,0L19.42,5.99z M7.05,18.36c0.39-0.39,0.39-1.03,0-1.41c-0.39-0.39-1.03-0.39-1.41,0l-1.06,1.06 c-0.39,0.39-0.39,1.03,0,1.41s1.03,0.39,1.41,0L7.05,18.36z"></path>
                </svg>
                <svg viewBox="0 0 24 24" width="20" height="20" class="darkToggleIcon">
                  <path fill="currentColor" d="M9.37,5.51C9.19,6.15,9.1,6.82,9.1,7.5c0,4.08,3.32,7.4,7.4,7.4c0.68,0,1.35-0.09,1.99-0.27C17.45,17.19,14.93,19,12,19 c-3.86,0-7-3.14-7-7C5,9.07,6.81,6.55,9.37,5.51z M12,3c-4.97,0-9,4.03-9,9s4.03,9,9,9s9-4.03,9-9c0-0.46-0.04-0.92-0.1-1.36 c-0.98,1.37-2.58,2.26-4.4,2.26c-2.98,0-5.4-2.42-5.4-5.4c0-1.81,0.89-3.42,2.26-4.4C12.92,3.04,12.46,3,12,3L12,3z"></path>
                </svg>
              </button>
            </div>
            <div class="board-close-button" title="Close">X</div>
            <div class="board-header-small-tip">H</div>
          </div>
          <div class="board-content">
            <div class="board-content-row">
              <div class="row-label hover-item" id="buttonSelectAll" title="Hotkey Q">Select All 0 Image</div>
            </div>
            <div class="board-content-row">
              <div class="row-label hover-item" id="downloadSample" title="Hotkey S">Download Sample</div>
            </div>
            <div class="board-content-row">
              <div class="row-label hover-item" id="downloadOriginal" title="Hotkey X">Download Original</div>
            </div>
            <div class="board-content-row">
              <div class="row-label hover-item" id="addFavorite" title="Hotkey F">Add To Favorite</div>
            </div>
            <div class="board-content-row">
              <div class="row-label hover-item" id="removeFavorite" title="Hotkey R">Remove From Favorite</div>
            </div>
            <div class="board-content-row">
              <div class="row-label hover-item" id="showToolTipBtn" title="Hotkey Shift + MouseHover On Image">Show Preview ToolTip: <span>[On]</span></div>
            </div>
            <div class="board-content-row">
              <div class="row-label">Release Note: </div>
              <a class="row-content hover-item-line" href="https://greasyfork.org/zh-CN/scripts/371605-booru-selector-downloader" target="_blank">v4.0.1</a>
            </div>
            <div class="fav-list-container"></div>
            <div class="download-row-container"></div>
          </div>
        </div>
      `
      document.body.insertAdjacentHTML('beforeend', template)
      this.initMenuEvent()
    }

    initHotKey() {
      window.addEventListener('keydown', (e) => this.hotKeyHandler(e), false)
      window.addEventListener('keyup', (e) => this.handleKeyUp(e), false)
    }

    /**
     * @param   {KeyboardEvent}   e
     * */
    async hotKeyHandler(e) {
      // ignore input event
      if(e.target && e.target.tagName === 'INPUT') return

      // press key `A` or `D` to paginate
      let pageRight = $('#paginator > div > a.next_page')
      let pageLeft = $('#paginator > div > a.previous_page')
      if (REgelbooruResult) {
        pageRight = $('#paginator b').previousElementSibling
        pageLeft = $('#paginator b').nextElementSibling
      }
      // `A`, `D`: paginate(only for yande.re and danbooru)
      if (e.key === 'd' && pageRight) {
        pageRight.click()
      }
      if (e.key === 'a' && pageLeft) {
        pageLeft.click()
      }

      // `Q`: Select all, Deselect all
      if (e.key === 'q') {
        this.handleClickMenuAllBtn()
      }

      // `F`: Favorite
      if (e.key === 'f') {
        this.handleFavorite(true)
      }
      // `R`: Remove from favorites
      if (e.key === 'r') {
        this.handleFavorite(false)
      }

      // `S`: Save sample image
      if (e.key === 's') {
        this.handleDownLoadImg(null, 'sample').then(res => {})
      }
      // `X`: Save original image
      if (e.key === 'x') {
        this.handleDownLoadImg(null, 'original').then(res => {})
      }
      // 'Shift': Show larger image tool tip
      if (e.key === 'Shift') {
        let posts = null
        let el = this.hoverEl
        if (el) {
          this.hoverEl.classList.add('imgTransform')
        }
        if (REyandeResult || REkonachanResult) {
          posts = $('#post-list-posts');
        }
        if (REdanbooruResult) {
          posts = $('.posts-container')
          if (el) {
            const id = Number(el.getAttribute('data-id'))
            if (!this.cacheImg.hasOwnProperty(id)) {
              this.cacheImg[id] = ''
              const imgInfo = await this.fetchDetailPage(id)
              const sample = imgInfo.sample
              this.cacheImg[id] = sample
              el.querySelector('source').srcset = sample
              el.querySelector('img').src = sample
            }
            el.classList.add('imgTransform')
          }
        }
        if (REgelbooruResult) {
          posts = $('.thumbnail-container')
          if (el) {
            const id = Number(el.querySelector('a').getAttribute('id').replace('p', ''))
            if (!this.cacheImg.hasOwnProperty(id)) {
              this.cacheImg[id] = ''
              const imgInfo = await this.fetchDetailPage(id)
              const sample = imgInfo.sample
              this.cacheImg[id] = sample
              el.querySelector('img').src = sample
            }
          }
        }
        posts.classList.add('previewTip')
      }
    }

    /**
     * @param   {KeyboardEvent}   e
     * */
    handleKeyUp(e) {
      // ignore input event
      if(e.target && e.target.tagName === 'INPUT') return

      // 'Shift': close larger image tool tip
      if (e.key === 'Shift') {
        if (this.hoverEl) {
          this.hoverEl.classList.remove('imgTransform')
        }
        let posts = null
        if (REyandeResult || REkonachanResult) {
          posts = $('#post-list-posts');
        }
        if (REdanbooruResult) {
          posts = $('.posts-container')
        }
        if (REgelbooruResult) {
          posts = $('.thumbnail-container')
        }
        posts.classList.remove('previewTip')
      }
    }

    handleToggleTheme(e) {
      const theme = document.body.getAttribute('data-theme')
      theme === 'light' ? this.setTheme('dark') : this.setTheme('light')
    }

    /**
     * @param   {string}    theme  light | dark
     * */
    setTheme(theme) {
      if(theme === 'light') {
        const lightTheme = [
          {key: 'backgroundColor', value: '#eee'},
          {key: 'lineBackgroundColor', value: '#ccc'},
          {key: 'fontColor', value: '#ee8887'},
          {key: 'fontColorHover', value: '#ff4342'},
          {key: 'headerFontColor', value: '#ffffff'},
          {key: 'border', value: '1px solid #fbe0df'},
        ]
        lightTheme.forEach(item => this.setCssVariable(item))
        document.body.setAttribute('data-theme', 'light')
        localStorage.setItem('h-theme', 'light')
      } else {
        const darkTheme = [
          {key: 'backgroundColor', value: '#222'},
          {key: 'lineBackgroundColor', value: '#ccc'},
          {key: 'fontColor', value: '#ee8887'},
          {key: 'fontColorHover', value: '#ffffff'},
          {key: 'headerFontColor', value: '#ffffff'},
          {key: 'border', value: '1px solid #ee8887'},
        ]
        darkTheme.forEach(item => this.setCssVariable(item))
        document.body.setAttribute('data-theme', 'dark')
        localStorage.setItem('h-theme', 'dark')
      }
    }

    setCssVariable({ key, value }) {
      const propertyName = `--primary-${key}`;
      document.documentElement.style.setProperty(propertyName, value);
    }

    handleClickMenuAllBtn() {
      const btn = $('#buttonSelectAll')
      if (this.batchCount >= 1) {
        btn.innerHTML = "DeselectAll  " + this.batchCount + " Image";
        this.deselectAll()
      } else {
        btn.innerHTML = "SelectAll  " + this.batchCount + " Image";
        this.selectAll()
      }
    }

    /**
     * @param   {Event}     e
     * @param   {string}   type   sample | original
     * */
    async handleDownLoadImg(e, type = 'sample') {
      const postsItems = $$('.imgItemChecked');
      let imgs = []
      if(postsItems.length) {
        this.updateFetchingProgress(0, postsItems.length)
      }
      for (let i = 0; i < postsItems.length; i++) {
        let id = 0
        if (REyandeResult || REkonachanResult) {
          id = Number(postsItems[i].getAttribute('id').replace('p', ''))
        }
        if (REdanbooruResult) {
          id = Number(postsItems[i].getAttribute('data-id'))
        }
        if (REgelbooruResult) {
          id = Number(postsItems[i].querySelector('a').getAttribute('id').replace('p', ''))
        }
        const imgInfo = await this.fetchDetailPage(id)
        imgs.push({
          id,
          url: imgInfo[type],
          fileName: `${id}${re3.exec(imgInfo[type])[0]}`
        })
        this.updateFetchingProgress(i + 1, postsItems.length)
      }
      this.createDownloadProgress(imgs)
      // downloadPool
      await this.downloadPool(imgs)
    }

    updateFetchingProgress(i, total) {
      const el = $('#fetching-download-row')
      if(!el) {
        const downloadElContainer = $('.download-row-container')
        const template = `
        <div id="fetching-download-row" class="download-row">
          <div class="download-row-title">Fetching</div>
          <div class="download-row-line">
            <div class="download-row-line-active" style="width: 0%;"></div>
          </div>
          <div class="download-row-percent">${i}/${total}</div>
        </div>
        `
        downloadElContainer.insertAdjacentHTML('afterbegin', template)
      } else {
        const width = (Math.round(i / total * 10000) / 100.00);
        el.querySelector('.download-row-line-active').style.width = `${width}%`
        el.querySelector('.download-row-percent').innerText = `${i}/${total}`
      }
    }

    async downloadPool(imgs = []) {
      for (let i = 0; i < imgs.length; i++) {
        this.updateDownloadProgress(imgs[i])
      }
      let pool = []
      for (let i = 0; i < imgs.length; i++) {
        const img = imgs[i]
        const task = this.downloadHandler(img)
        pool.push(task)
        task
          .then((id) => {
            console.log(`${ id } ok`)
          })
          .catch((id) => {
            console.log(`${ id } error`)
          })
          .finally(() => {
            pool.splice(pool.indexOf(task), 1)
          })
        if (pool.length === this.downloadLimit) {
          await Promise.race(pool)
        }
      }
    }

    downloadHandler(img) {
      // see GM_download:  https://www.tampermonkey.net/documentation.php#GM_download
      return new Promise((resolve, reject) => {
        const arg = {
          url: img.url,
          name: img.fileName,
          onprogress: (xhr) => {
            this.downloadProgress(xhr, img.id, false)
            if(Math.floor(xhr.loaded / xhr.total * 100) >= 100) {
              resolve(img.id)
            }
          },
          onload: () => {
            resolve(img.id)
            this.downloadProgress(null, img.id, true)
          },
          onerror: () => {
            // still resolve
            resolve(img.id)
            this.onDownloadError(img)
          },
          ontimeout: () => {
            // still resolve
            resolve(img.id)
            this.onDownloadError(img)
          }
        }
        GM_download(arg)
      })
    }

    /**
     * @param   {Array}   imgs
     * */
    createDownloadProgress(imgs) {
      for (let i = 0; i < imgs.length; i++) {
        const img = imgs[i]
        const pageUrl = this.getPageUrl(img.id)
        const downloadRowEl = $(`#download-row-${img.id}`)
        if(!downloadRowEl) {
          // create downloadRow
          const downloadList = $('.download-row-container')
          const template = `
            <div id="download-row-${img.id}" class="download-row">
               <a href="${pageUrl}" class="download-row-title hover-item-line" target="_blank">${img.id}</a>
               <div class="download-row-line">
                 <div class="download-row-line-active" style="width: 0%;"></div>
               </div>
               <div class="download-row-percent">0%</div>
            </div>
         `
          downloadList.insertAdjacentHTML('beforeend', template)
        }
      }
    }

    updateDownloadProgress({id, url, fileName}) {
      const downloadRowEl = $(`#download-row-${id}`)
      if(!downloadRowEl) {
        // create downloadRow
        const downloadList = $('.download-row-container')
        const pageUrl = this.getPageUrl(id)
        const template = `
            <div id="download-row-${id}" class="download-row">
               <a href="${pageUrl}" class="download-row-title hover-item-line" target="_blank">${id}</a>
               <div class="download-row-line">
                 <div class="download-row-line-active" style="width: 0%;"></div>
               </div>
               <div class="download-row-percent">0%</div>
            </div>
         `
        downloadList.insertAdjacentHTML('beforeend', template)
      }
    }

    onDownloadError(img) {
      const downloadRowEl = $(`#download-row-${img.id}`)
      const el = downloadRowEl.querySelector('.download-row-percent')
      if(!el.classList.contains('hover-item')) {
        el.classList.add('hover-item')
      }
      el.innerHTML = reTrySvgIcon
      el.onclick = this.downloadHandler(img)
    }

    /**
     * @param     {ProgressEventInit | null}   xhr
     * @param     {number}                      id
     * @param     {boolean}                     isFinished
     * */
    downloadProgress(xhr, id, isFinished = false) {
      let width = 0
      if (xhr === null && isFinished) {
        width = 100
      } else {
        width = xhr.lengthComputable ? Math.floor(xhr.loaded / xhr.total * 100) : 0;
      }

      const downloadRowEl = $(`#download-row-${ id }`)
      if (!downloadRowEl) {
        // create downloadRow
        const downloadList = $('.download-row-container')
        const pageUrl = this.getPageUrl(id)
        const template = `
            <div id="download-row-${ id }" class="download-row">
               <a href="${ pageUrl }" class="download-row-title hover-item-line" target="_blank">${ id }</a>
               <div class="download-row-line">
                 <div class="download-row-line-active" style="width: ${ width }%;"></div>
               </div>
               <div class="download-row-percent">${ width }%</div>
            </div>
         `
        downloadList.insertAdjacentHTML('beforeend', template)
      } else {
        // update downloadRow
        downloadRowEl.querySelector('.download-row-title').innerText = id
        downloadRowEl.querySelector('.download-row-line-active').style.width = `${ width }%`
        downloadRowEl.querySelector('.download-row-percent').innerText = `${ width }%`
      }
    }

    /**
     * @param   {number}    id
     * @return  {string}
     * */
    getPageUrl(id) {
      let pageUrl = ``
      if(REyandeResult || REkonachanResult) {
        pageUrl = `${locationUrl}/post/show/${id}`
      }
      if(REdanbooruResult) {
        pageUrl = `${locationUrl}/posts/${id}`
      }
      if(REgelbooruResult) {
        pageUrl = `${locationUrl}/index.php?page=post&s=view&id=${id}`
      }
      return pageUrl
    }

    /**
     * @param   {string}    showCode  '0' | '1'
     * */
    handleToggleToolTip(showCode) {
      let preViewEl = null, infoEl = null;
      if (REyandeResult || REkonachanResult) {
        preViewEl = $('#index-hover-overlay')
        infoEl = $('#index-hover-info')
      }
      if (REdanbooruResult) {
        preViewEl = $('#post-tooltips')
      }
      if (showCode === '0') {
        preViewEl && preViewEl.classList.remove('hide')
        infoEl && infoEl.classList.remove('hide')
      } else if (showCode === '1') {
        preViewEl && preViewEl.classList.add('hide')
        infoEl && infoEl.classList.add('hide')
      }
      $('#showToolTipBtn span').innerText = showCode === '0' ? '[OFF]' : '[ON]'
      localStorage.setItem('h-show-tooltip', showCode)
    }

    /**
     * @param   {boolean}   isLike
     * */
    handleFavorite(isLike) {
      const postsItems = $$('.imgItemChecked');
      for (let i = 0; i < postsItems.length; i++) {
        let id = 0
        if (REyandeResult || REkonachanResult) {
          id = Number(postsItems[i].getAttribute('id').replace('p', ''))
        }
        if (REdanbooruResult) {
          id = Number(postsItems[i].getAttribute('data-id'))
        }
        if (REgelbooruResult) {
          id = Number(postsItems[i].querySelector('a').getAttribute('id').replace('p', ''))
        }
        this.fetchFavorite(id, isLike)
      }
    }

    /**
     * @param   {number}      id
     * @param   {boolean}     isLike
     * */
    fetchFavorite(id, isLike) {
      let url = ``
      let data = {}

      if (REyandeResult || REkonachanResult) {
        url = `${ locationUrl }/post/vote.json`
        const csrfToken = $("meta[name=csrf-token]").content
        data = {
          "headers": {
            "content-type": "application/x-www-form-urlencoded; charset=UTF-8",
            "x-csrf-token": csrfToken,
          },
          "body": `id=${ id }&score=${ isLike ? 3 : 2 }`,
          "method": "POST",
          "mode": "cors",
          "credentials": "include"
        }
      }

      if (REdanbooruResult) {
        url = isLike ? `${ locationUrl }/favorites?post_id=${ id }` : `${ locationUrl }/favorites/${id}`
        const csrfToken = $("meta[name=csrf-token]").content
        data = {
          "headers": {
            "accept": "text/javascript, application/javascript, application/ecmascript, application/x-ecmascript, */*; q=0.01",
            "x-csrf-token": csrfToken,
            "x-requested-with": "XMLHttpRequest"
          },
          "body": null,
          "method": isLike ? "POST" : "DELETE",
          "mode": "cors",
          "credentials": "include"
        }
      }

      if (REgelbooruResult) {
        url = isLike ? `${ locationUrl }/public/addfav.php?id=${id}` : `${ locationUrl }/index.php?page=favorites&s=delete&id=${id}`
        data = {
          "headers": {
            "accept": "text/javascript, application/javascript, application/ecmascript, application/x-ecmascript, */*; q=0.01",
            "x-requested-with": "XMLHttpRequest"
          },
          "body": null,
          "method": "GET",
          "mode": "cors",
          "credentials": "include"
        }
      }

      promiseFetch(url, data)
        .then(res => {
          if (res.status === 200) {
            const log = {
              id,
              isLike,
              result: 'success',
            }
            this.addFavoriteLog(log)
          }
        })
        .catch(e => {
          const log = {
            id,
            isLike,
            result: 'error',
          }
          this.addFavoriteLog(log)
          console.error('add favorite error')
        })
    }

    handleClickFavList(e, parentClassName) {
      let el = e.target
      let hasEl = false
      while (el !== document && el.className !== parentClassName) {
        // click re-try-icon to re add favorite
        if (el.className === 're-try-icon') {
          hasEl = true
          break;
        }
        el = el.parentNode
      }
      if(!hasEl) return

      const id = Number(el.getAttribute('data-id'))
      const isLike = el.getAttribute('data-isLike') === 'true'
      this.fetchFavorite(id, isLike)
    }

    /**
     * @param   {number}      log.id
     * @param   {boolean}     log.isLike
     * @param   {string}      log.result    success | error
     * */
    addFavoriteLog(log) {
      const { id, isLike, result } = log
      const el = $('.fav-list-container')
      const logItemEl = $(`#favLog${id}`)
      if(!logItemEl) {
        const pageUrl = this.getPageUrl(id)
        const elItem = document.createElement('div')
        elItem.className = 'board-content-row'
        elItem.id = `favLog${id}`
        elItem.innerHTML = `
        <div class="row-label">${isLike ? 'Add' : 'Remove'} Favorites: </div>
        <div class="row-content">
          <a class="hover-item-line" href="${pageUrl}" target="_blank">${id}</a>
          <span class="fav-state">${result === 'success' ? 'Success' : 'Error'}</span>
          ${ result === 'error' ? `<span data-id="${id}" data-isLike="${String(isLike)}" class="re-try-icon">${reTrySvgIcon}</span>` : '' }
        </div>`
        el.appendChild(elItem)
      } else {
        logItemEl.querySelector('.row-label').innerText = isLike ? 'Add Favorites: ' : 'Remove Favorites: '
        logItemEl.querySelector('.fav-state').innerText = result === 'success' ? 'Success' : 'Error'
        let iconEl = logItemEl.querySelector('.re-try-icon')
        if(result === 'success') {
          // remove re-icon
          if(iconEl) iconEl.remove()
        } else if(result === 'error') {
          iconEl
            ? iconEl.setAttribute('data-isLike', String(isLike))
            : logItemEl.querySelector('.row-content').insertAdjacentHTML('beforeend', `<span data-id="${id}" data-isLike="${String(isLike)}" class="re-try-icon">${reTrySvgIcon}</span>`);
        }
      }
    }

    /**
     * @interface     imgInfo
     * @param         {number}      id
     * @return        {Promise<imgInfo | Error>}
     * */
    fetchDetailPage(id) {
      const link = this.getPageUrl(id)
      return new Promise((resolve, reject) => {
        let imgInfo = {
          sample: '',
          original: '',
        }
        if(REyandeResult || REkonachanResult) {
          promiseFetch(link)
            .then(res => res.text())
            .then(res => {
              const bodyText = res
              const dom = domParser(bodyText)
              const sampleSrc = dom.querySelector('#image').src
              const originalSrc = dom.querySelector('#highres').href
              imgInfo = {
                sample: sampleSrc,
                original: originalSrc,
              }
              resolve(imgInfo)
            }).catch(e => {
            console.log(e)
            reject(e)
          })
        }
        if(REdanbooruResult) {
          promiseFetch(link)
            .then(res => res.text())
            .then(res => {
              const bodyText = res
              const dom = domParser(bodyText)
              const sampleSrc = dom.querySelector('#image').src
              const originalEl = dom.querySelector('.image-view-original-link')
              const originalSrc = originalEl ? originalEl.href : sampleSrc
              imgInfo = {
                sample: sampleSrc,
                original: originalSrc,
              }
              resolve(imgInfo)
            }).catch(e => {
            console.log(e)
            reject(e)
          })
        }

        if(REgelbooruResult) {
          promiseFetch(link)
            .then(res => res.text())
            .then(res => {
              const bodyText = res
              const dom = domParser(bodyText)
              const sampleSrc = dom.querySelector('#image').src
              const originalEl = dom.querySelector("a[rel='noopener']")
              const originalSrc = originalEl ? originalEl.href : sampleSrc
              imgInfo = {
                sample: sampleSrc,
                original: originalSrc,
              }
              resolve(imgInfo)
            }).catch(e => {
            console.log(e)
            reject(e)
          })
        }
      })
    }

    handleClickImg(e) {
      let el = e.target
      let hasEl = false
      while (el !== document) {
        if ((REyandeResult || REkonachanResult) && el.tagName.toLowerCase() === 'li') {
          hasEl = true
          break;
        }
        if ((REdanbooruResult || REgelbooruResult) && el.tagName.toLowerCase() === 'article') {
          hasEl = true
          break;
        }
        el = el.parentNode
      }
      if(!hasEl) return;

      // press ctrlKey: open in new window, loadInBackground true, won't auto focus
      if(e.ctrlKey) {
        let link = ``
        if(REyandeResult || REkonachanResult) {
          link = el.querySelector('a.thumb').href
        }
        if(REdanbooruResult) {
          link = el.querySelector('a.post-preview-link').href
        }
        if(REgelbooruResult) {
          link = el.querySelector('a').href
        }
        GM_openInTab(link, true)
        return;
      }
      // press altKey: open in new window, loadInBackground false, will auto focus
      if(e.altKey) {
        let link = ``
        if(REyandeResult || REkonachanResult) {
          link = el.querySelector('a.thumb').href
        }
        if(REdanbooruResult) {
          link = el.querySelector('a.post-preview-link').href
        }
        if(REgelbooruResult) {
          link = el.querySelector('a').href
        }
        GM_openInTab(link, false)
        return;
      }

      const cbEl = el.getElementsByClassName('checkbox')[0]
      cbEl.checked = !cbEl.checked
      cbEl.checked ? el.classList.add('imgItemChecked') : el.classList.remove('imgItemChecked')
      this.updateBatchCount()
    }

    /**
     * @param   {MouseEvent}     e
     * @param   {string}         mouseEventName  mouseover | mouseout
     * **/
    async setTransition(e, mouseEventName) {
      let el = e.target
      let hasEl = false
      while (el !== document) {
        if ((REyandeResult || REkonachanResult) && el.tagName.toLowerCase() === 'li') {
          hasEl = true
          break;
        }
        if ((REdanbooruResult || REgelbooruResult) && el.tagName.toLowerCase() === 'article') {
          hasEl = true
          break;
        }
        el = el.parentNode
      }
      if(!hasEl) return;

      if(mouseEventName === 'mouseout') {
        el.classList.remove('imgTransform')
        this.hoverEl = null
      }
      if(mouseEventName === 'mouseover') {
        this.hoverEl = el
      }
    }

    updateBatchCount() {
      let checked = 0;
      $$('.checkbox').forEach(function (checkbox) {
        if (checkbox.checked) {
          ++checked;
        }
      });
      this.batchCount = checked;
      const btn = $('#buttonSelectAll')
      if (this.batchCount >= 1) {
        btn.innerHTML = "DeselectAll  " + this.batchCount + " Image";
      } else {
        btn.innerHTML = "SelectAll  " + this.batchCount + " Image";
      }
    }

    selectAll() {
      $$('.checkbox').forEach(function (checkbox) {
        checkbox.checked = true;
        checkbox.parentNode.parentNode.classList.add('imgItemChecked');
      });
      this.updateBatchCount();
    }

    deselectAll() {
      $$('.checkbox').forEach(function (checkbox) {
        checkbox.checked = false;
        checkbox.parentNode.parentNode.classList.remove('imgItemChecked');
      });
      this.updateBatchCount();
    }
  }

  const booruDownloader = new BooruDownloader()
  window.booruDownloader = booruDownloader
})();