yande.re refine

Refining yande.re

// ==UserScript==
// @name           yande.re refine
// @namespace      https://greasyfork.org/scripts/397612-yande-re-refine
// @description    Refining yande.re
// @include        *://behoimi.org/*
// @include        *://www.behoimi.org/*
// @include        *://*.donmai.us/*
// @include        *://konachan.tld/*2
// @include        *://yande.re/*
// @include        *://chan.sankakucomplex.com/*
// @version        2021.08.28.a
// @grant          none
// ==/UserScript==

// You can found alternatives in https://gist.github.com/jimmywarting/ac1be6ea0297c16c477e17f8fbe51347
const CORS_PROXY = 'https://cors-anywhere.herokuapp.com/'
const CORS_ENABLED = false
const CACHE_ENABLED = true
const CACHE_ONLY_LIKE = true

const STORAGE_KEY = 'yandere-refine-liked'
const INDEXED_DB_NAME = 'yandere-refine-cache'
const SUGGEST_WIDTH = 300
// Minimum amount of window left to scroll, maintained by loading more pages.
const scrollBuffer = 600
// Time (in ms) the script will wait for a response from the next page before attempting to fetch the page again.  If the script gets trapped in a loop trying to load the next page, increase this value.
const timeToFailure = 15000

//= ===========================================================================
//= ========================Script initialization==============================
//= ===========================================================================

let nextPage, mainTable, mainParent, timeout, iframe
let previewIframe, previewImage, previewImageDiv, previewDialog
const imagesList = []
let currentImage = 0
const pending = ref(false, v =>
  document.getElementById('loader').classList.toggle('hidden', !v),
)
const likedList = readLikeList()
const viewingFavorites = isViewingFavorites()
let db
const memcache = {}

injectGlobalStyle()
initialize()

function initialize() {
  // Stop if inside an iframe
  if (window !== window.top || scrollBuffer === 0) return

  // Stop if no "table"
  mainTable = getMainTable(document)
  if (!mainTable) return

  injectStyle()
  initDOM()
  addImages(getImages())

  // Stop if no more pages
  nextPage = getNextPage(document)
  if (!nextPage) return

  // Hide the blacklist sidebar, since this script breaks the tag totals and post unhiding.
  const sidebar = document.getElementById('blacklisted-sidebar')
  if (sidebar) sidebar.style.display = 'none'

  // Other important variables:
  mainParent = mainTable.parentNode
  pending.value = false

  iframe = document.createElement('iframe')
  iframe.width = iframe.height = 0
  iframe.style.visibility = 'hidden'
  document.body.appendChild(iframe)

  // Slight delay so that Danbooru's initialize_edit_links() has time to hide all the edit boxes on the Comment index
  iframe.addEventListener(
    'load',
    (e) => {
      setTimeout(appendNewContent, 100)
    },
    false,
  )

  window.addEventListener('scroll', testScrollPosition, false)
  testScrollPosition()

  if (CACHE_ENABLED) {
    const script = document.createElement('script')
    script.src = 'https://unpkg.com/dexie@latest/dist/dexie.js'
    script.onload = () => {
      // eslint-disable-next-line no-undef
      db = new Dexie(INDEXED_DB_NAME)

      db.version(1).stores({
        images: 'id, blob',
      })
    }
    document.head.appendChild(script)
  }
}

//= ===========================================================================
//= ===========================Script functions================================
//= ===========================================================================

function awaitImage(img) {
  if (img.completed)
    return
  return new Promise((resolve) => {
    img.onload = () => {
      resolve(img)
      img.onload = undefined
    }
  })
}

async function setCache(id, img) {
  if (!db)
    return false

  console.log(`Caching ${id}`)
  const canvas = document.createElement('canvas')
  canvas.width = img.naturalWidth
  canvas.height = img.naturalHeight

  const ctx = canvas.getContext('2d')
  ctx.drawImage(img, 0, 0)

  const blob = await new Promise((resolve) => {
    canvas.toBlob(resolve)
  })

  await db.images.put({ id, blob })
  return true
}

async function getCache(id) {
  if (memcache[id])
    return URL.createObjectURL(memcache[id])
  if (!db)
    return
  const data = await db.images.get(id)
  if (data && data.blob) {
    console.log(`Loading cache of ${id}`)
    memcache[id] = data.blob
    return URL.createObjectURL(data.blob)
  }
}

async function removeCache(id) {
  if (!db)
    return
  await db.images.delete(id)
}

// Some pages match multiple "tables", so order is important.
function getMainTable(source) {
  // Special case: Sankaku post index with Auto Paging enabled
  if (
    /sankaku/.test(location.host)
    && /auto_page=1/.test(document.cookie)
    && /^(post(\/|\/index\/?)?|\/)$/.test(location.pathname)
  )
    return null

  const xpath = [
    './/div[@id=\'c-favorites\']//div[@id=\'posts\']', // Danbooru (/favorites)
    './/div[@id=\'posts\']/div', // Danbooru; don't want to fall through to the wrong xpath if no posts ("<article>") on first page.
    './/div[@id=\'c-pools\']//section/article/..', // Danbooru (/pools/####)

    './/div[@id=\'a-index\']/table[not(contains(@class,\'search\'))]', // Danbooru (/forum_topics, ...), take care that this doesn't catch comments containing tables
    './/div[@id=\'a-index\']', // Danbooru (/comments, ...)

    './/table[contains(@class,\'highlight\')]', // large number of pages
    './/div[@id=\'content\']/div/div/div/div/span[@class=\'author\']/../../../..', // Sankaku: note search
    './/div[contains(@id,\'comment-list\')]/div/..', // comment index
    './/*[not(contains(@id,\'popular\'))]/span[contains(@class,\'thumb\')]/a/../..', // post/index, pool/show, note/index
    './/li/div/a[contains(@class,\'thumb\')]/../../..', // post/index, note/index
    './/div[@id=\'content\']//table/tbody/tr[@class=\'even\']/../..', // user/index, wiki/history
    './/div[@id=\'content\']/div/table', // 3dbooru user records
    './/div[@id=\'forum\']', // forum/show
  ]

  for (let i = 0; i < xpath.length; i++) {
    // eslint-disable-next-line no-func-assign
    getMainTable = (function(query) {
      return function(source) {
        const mTable = new XPathEvaluator().evaluate(
          query,
          source,
          null,
          XPathResult.FIRST_ORDERED_NODE_TYPE,
          null,
        ).singleNodeValue
        if (!mTable) return mTable

        // Special case: Danbooru's /favorites lacks the extra DIV that /posts has, which causes issues with the paginator/page break.
        const xDiv = document.createElement('div')
        xDiv.style.overflow = 'hidden'
        mTable.parentNode.insertBefore(xDiv, mTable)
        xDiv.appendChild(mTable)
        return xDiv
      }
    })(xpath[i])

    const result = getMainTable(source)
    if (result) {
      // alert("UPW main table query: "+xpath[i]+"\n\n"+location.pathname);
      return result
    }
  }

  return null
}

function getNextPage(doc = document) {
  return (doc.querySelector('a.next_page') || {}).href
}

function testScrollPosition() {
  if (!nextPage) return

  // Take the max of the two heights for browser compatibility
  if (
    !pending.value
    && window.pageYOffset + window.innerHeight + scrollBuffer
      > Math.max(
        document.documentElement.scrollHeight,
        document.documentElement.offsetHeight,
      )
  ) {
    console.log(`loading ${nextPage}`)
    pending.value = true
    timeout = setTimeout(() => {
      pending.value = false
      testScrollPosition()
    }, timeToFailure)
    iframe.contentDocument.location.replace(nextPage)
  }
}

function appendNewContent() {
  // Make sure page is correct.  Using 'indexOf' instead of '!=' because links like "https://danbooru.donmai.us/pools?page=2&search%5Border%5D=" become "https://danbooru.donmai.us/pools?page=2" in the iframe href.
  clearTimeout(timeout)
  if (!nextPage.includes(iframe.contentDocument.location.href)) {
    setTimeout(() => {
      pending.value = false
    }, 1000)
    return
  }

  const images = getImages(iframe.contentDocument)
  addImages(images)

  if (!images.length) nextPage = null
  else
    nextPage = getNextPage(iframe.contentDocument)

  if (nextPage) {
    history.pushState({}, iframe.contentDocument, nextPage)
  }
  else {
    // TODO: end of pages
    console.log('End of pages')
  }

  pending.value = false
  testScrollPosition()
}

function injectGlobalStyle() {
  const s = document.createElement('style')
  s.innerHTML = `
body { padding: 0; }
#header { margin: 0 !important; text-align: center; }
#header ul { float: none !important; display: inline-block;}
#content > div:first-child > div.sidebar { position: fixed; left: 0; top: 0; bottom: 0; overflow: auto !important; z-index: 2; width: 250px !important; transform: translate(-246px, 0); background: #171717dd; transition: all .2s ease-out; float: none !important; padding: 15px; }
#content > div:first-child > div.sidebar:hover { transform: translateX(0); }
div.content { width: 100vw; text-align: center; float: none }
div.footer { clear: both !important; }
div#paginator a { border: none; }
#comments { max-width: unset !important; width: unset !important; padding: 20px; }
.avatar { border-radius: 1000px; }
form textarea { color: white; background: inherit; padding: 10px 5px; }
.comment .content { text-align: left; }
`
  document.body.appendChild(s)
}

function injectStyle() {
  const s = document.createElement('style')
  s.innerHTML = `
#gallery .row { width: 100vw; white-space: nowrap; height: var(--image-height); --image-height: 300px; }
#gallery .row .thumb { position: relative; display: inline-block; transition: .2s ease-out; overflow: hidden; }
#gallery .row .thumb.liked::after { position: absolute; top: 3px; right: 3px; content: url('https://api.iconify.design/mdi:cards-heart.svg?color=%23f37e92&height=20'); vertical-align: -0.125em; }
#gallery .row .thumb:first-child { transform-origin: left; }
#gallery .row .thumb:last-child { transform-origin: right; }
#gallery .row .thumb img { height: var(--image-height); }
#gallery .row:hover { z-index: 1; }
#gallery .row .thumb:hover { transform: scale(1.3); z-index: 1; opacity: 1; box-shadow: 8px 8px 100px 10px rgba(0, 0, 0, 0.8); border-radius: 5px;  }

#loader { padding: 10px; text-align: center; }

.hidden { display: none !important; }
.preview-dialog { position: fixed; top: 0; left: 0; height: 100vh; width: 100vw; background: rgba(0,0,0,0.7); z-index: 100; }
.preview-dialog iframe { position: absolute; height: 90vh; width: 80vw; top: 50%; left: 50%; transform: translate(-50%, -50%); background: grey; border: none; border-radius: 5px; overflow: hidden; }
.preview-dialog .image-host { position: fixed; top: 0; left: 0; height: 100vh; width: 100vw; overflow: auto; text-align: center; }
.preview-dialog .image-host img { margin: auto; }
.preview-dialog .image-host img.loading { filter: blur(3px); height: 100vh; }
.preview-dialog .image-host.full { overflow: hidden }
.preview-dialog .image-host.full img { max-width: 100vw; max-height: 100vh; }
`
  document.body.appendChild(s)
}

function initPreviewIframe() {
  previewDialog = document.createElement('div')
  previewDialog.addClassName('preview-dialog hidden')
  previewDialog.onclick = (e) => {
    if (e.target === previewDialog)
      previewDialog.classList.toggle('hidden', true)
  }
  window.onkeydown = (e) => {
    if (!previewDialog.classList.contains('hidden')) {
      if (e.key === 'ArrowLeft') {
        currentImage = Math.max(0, currentImage - 1)
        openImage(currentImage)
        e.preventDefault()
      }
      if (e.key === 'ArrowRight') {
        currentImage = Math.min(imagesList.length - 1, currentImage + 1)
        openImage(currentImage)
        e.preventDefault()
      }
      if (e.key === 'Escape') {
        previewDialog.classList.toggle('hidden', true)
        e.preventDefault()
      }
      if (e.key === 'Tab') {
        openImage(currentImage, 'page')
        e.preventDefault()
      }
      if (e.code === 'Space') {
        previewImageDiv.classList.toggle('full')
        e.preventDefault()
      }
      if (e.code === 'KeyL') {
        like(currentImage, 3)
        e.preventDefault()
      }
      if (e.code === 'KeyU') {
        unlike(currentImage, 2)
        e.preventDefault()
      }
    }
  }

  previewIframe = document.createElement('iframe')
  previewImageDiv = document.createElement('div')
  previewImageDiv.className = 'image-host full'
  previewImage = document.createElement('img')

  previewImageDiv.onclick = (e) => {
    previewDialog.classList.toggle('hidden', true)
  }

  previewDialog.appendChild(previewIframe)
  previewImageDiv.appendChild(previewImage)
  previewDialog.appendChild(previewImageDiv)
  document.body.appendChild(previewDialog)
}

function ref(v, handler) {
  let value = v
  return new Proxy(
    {},
    {
      get(obj, prop) {
        return value
      },
      set(obj, prop, v) {
        if (value !== v) {
          value = v
          handler(value)
        }
      },
    },
  )
}

function getImages(doc = document) {
  const result = Array.from(
    doc.querySelectorAll('ul#post-list-posts > li'),
  ).map((li) => {
    const page = (li.querySelector('a.thumb') || {}).href
    const thumb = (li.querySelector('a.thumb img') || {}).src
    const large = (li.querySelector('a.largeimg') || {}).href || (li.querySelector('a.smallimg') || {}).href
    const id = page.split('/').slice(-1)[0]
    const resText = (li.querySelector('.directlink-res') || {}).textContent
    let res
    if (resText && resText.includes('x')) {
      const [height, width] = resText.split(' x ').map(i => +i)
      res = { height, width, radio: width / height }
    }
    if (viewingFavorites) setLiked(id, true)
    const liked = isLiked(id)

    return { page, thumb, large, id, res, liked }
  })
  doc.getElementById('post-list-posts').remove()
  return result
}

function initDOM() {
  const list = document.getElementById('post-list')
  const gallery = document.createElement('div')
  gallery.id = 'gallery'
  list.appendChild(gallery)
  const loader = document.createElement('div')
  loader.id = 'loader'
  loader.textContent = 'Loading...'
  list.appendChild(loader)
}

function addImages(images) {
  const gallery = document.getElementById('gallery')
  const RADIO = Math.round(window.innerWidth / SUGGEST_WIDTH)

  images.forEach((info, i) => {
    const idx = imagesList.length + i
    let row = gallery.querySelector('.row:last-child:not(.full)')
    if (!row) {
      row = document.createElement('div')
      row.className = 'row'
      gallery.appendChild(row)
    }
    row.dataset.width = +(row.dataset.width || 0) + 1 / info.res.radio

    if (+row.dataset.width >= RADIO) {
      row.classList.toggle('full', true)
      row.style = `--image-height: calc(100vw / ${row.dataset.width})`
    }

    const thumb = document.createElement('div')
    thumb.className = 'thumb'
    row.appendChild(thumb)

    const img = document.createElement('img')
    img.src = info.thumb
    thumb.appendChild(img)
    info.dom = thumb
    thumb.classList.toggle('liked', info.liked)

    let lastClicked = -Infinity
    let timer = null
    img.onclick = (e) => {
      e.preventDefault()
      // double click
      if (Date.now() - lastClicked < 300) {
        if (info.liked) unlike(idx)
        else like(idx)

        clearTimeout(timer)
        // click
      }
      else {
        lastClicked = +Date.now()
        timer = setTimeout(() => openImage(idx), 400)
      }
      return false
    }
  })
  imagesList.push(...images)
}

function getProxiedUrl(url) {
  if (!CORS_ENABLED)
    return url
  return CORS_PROXY + url
}

async function openImage(idx, type = 'image') {
  currentImage = idx
  const img = imagesList[idx]
  const { page, large, id, thumb } = img
  if (!previewIframe) initPreviewIframe()

  if (!large) type = 'page'

  previewDialog.classList.toggle('hidden', false)
  previewImage.dataset.id = id

  if (type === 'image') {
    previewImage.crossOrigin = null
    const cache = await getCache(id)
    if (cache) {
      previewImage.src = cache
      // show image
      previewIframe.classList.toggle('hidden', true)
      previewImageDiv.classList.toggle('hidden', false)
    }
    else {
      // show image
      previewIframe.classList.toggle('hidden', true)
      previewImageDiv.classList.toggle('hidden', false)

      // thumbnail
      previewImage.classList.toggle('loading', true)
      previewImage.src = thumb
      await awaitImage(previewImage)

      // full image
      if (CORS_ENABLED)
        previewImage.crossOrigin = 'Anonymous'
      const url = getProxiedUrl(large)
      previewImage.src = url
      await awaitImage(previewImage)
      previewImage.classList.toggle('loading', false)

      // image changed
      if (previewImage.dataset.id !== id)
        return

      // cache
      if (!CACHE_ONLY_LIKE || isLiked(id))
        await setCache(id, previewImage)
    }
  }
  else {
    previewIframe.src = page
    previewIframe.classList.toggle('hidden', false)
    previewImageDiv.classList.toggle('hidden', true)
    await awaitImage(previewIframe)
    if (
      previewIframe.contentWindow.location.href !== page
        && !previewIframe.contentWindow.location.pathname.startsWith('/post/show/')
    ) {
      location.href = previewIframe.contentWindow.location.href
      previewDialog.classList.toggle('hidden', true)
      previewIframe.onload = null
    }
  }
}

function getFavoritesLike() {
  return document.querySelector('.user .submenu li:nth-child(3) a').href
}

function isViewingFavorites() {
  const fav = getFavoritesLike()
  if (!fav)
    return false

  const a = (new URL(fav).searchParams.get('tags') || '')
    .toLowerCase()
    .split(' ')
    .sort()
  const b = (new URL(location.href).searchParams.get('tags') || '')
    .toLowerCase()
    .split(' ')
    .sort()

  return a[0] && a[0] === b[0] && a[1] === b[1]
}

async function vote(id, score) {
  const body = new FormData()
  body.append('id', id)
  body.append('score', score)
  const rawResponse = await fetch('https://yande.re/post/vote.json', {
    method: 'POST',
    headers: {
      'X-CSRF-Token': document.querySelector('meta[name=csrf-token]').attributes
        .content.value,
    },
    body,
  })
  await rawResponse.json()
}

function readLikeList() {
  return Object.fromEntries(
    (localStorage.getItem(STORAGE_KEY) || '').split(',').map(i => [i, true]),
  )
}

function isLiked(id) {
  return !!likedList[id]
}

function setLiked(id, v) {
  likedList[id] = v
  localStorage.setItem(
    STORAGE_KEY,
    Object.entries(likedList)
      .map(([i, v]) => (v ? i : null))
      .filter(i => i)
      .join(','),
  )
}

function like(idx) {
  const image = imagesList[idx]
  vote(image.id, 3)
  image.liked = true
  setLiked(image.id, true)
  image.dom.classList.toggle('liked', image.liked)
}

function unlike(idx) {
  const image = imagesList[idx]
  vote(image.id, 2)
  image.liked = false
  setLiked(image.id, false)
  image.dom.classList.toggle('liked', image.liked)
  removeCache(image.id)
}