elwm / gfy-tools

// ==UserScript==
// @locale       english
// @name         gfy-tools
// @namespace    https://github.com/exwm
// @version      0.0.20
// @description  tools for automating tasks on gfycat.com
// @run-at       document-end
// @license      ISC
// @author       elwm
// @match        *://*gfycat.com/*
// @match        *://*gfycat.com/@*
// @require      https://unpkg.com/isotope-layout@3/dist/isotope.pkgd.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/fuse.js/3.4.5/fuse.min.js
// @grant        none
// ==/UserScript==
document.addEventListener('keydown', hotkeys, false);

async function hotkeys(e) {
  switch (e.code) {
    case 'KeyA':
      if (e.ctrlKey && e.shiftKey && !e.altKey) {
        e.preventDefault();
        e.stopImmediatePropagation();
        gfy2cb('album', 'md');
      } else if (e.ctrlKey && e.shiftKey && e.altKey) {
        e.preventDefault();
        e.stopImmediatePropagation();
        gfy2cb('album', 'iframe');
      }
      break;
    case 'KeyX':
      if (e.ctrlKey && e.shiftKey && !e.altKey) {
        e.preventDefault();
        e.stopImmediatePropagation();
        quicktag();
      } else if (e.ctrlKey && e.shiftKey && e.altKey) {
        e.preventDefault();
        e.stopImmediatePropagation();
        deleteTags();
      }
      break;
    case 'KeyQ':
      if (e.ctrlKey && !e.shiftKey && !e.altKey) {
        e.preventDefault();
        e.stopImmediatePropagation();
        gfy2cb('any', 'plain');
        break;
      } else if (e.ctrlKey && e.shiftKey && !e.altKey) {
        e.preventDefault();
        e.stopImmediatePropagation();
        gfy2cb('any', 'plain+numbered');
        break;
      } else if (e.ctrlKey && e.shiftKey && e.altKey) {
        e.preventDefault();
        e.stopImmediatePropagation();
        gfy2cb('any', 'plain+numbered+titles');
        break;
      }
    case 'KeyS':
      if (e.ctrlKey && e.shiftKey && !e.altKey) {
        e.preventDefault();
        e.stopImmediatePropagation();
        await paginate();
        break;
      }
  }
}

async function markAllGfysSFW2() {
  const page = await fetchGfyPage();
  console.log(page);
}

function gfy2cb(page = 'any', format = 'md') {
  format = format.split('+');

  let urls = Array.from(document.getElementsByClassName('copy-input-text'));
  urls = urls.map((input) => {
    return input.value;
  });
  let titles = Array.from(
    document.querySelectorAll('div.titleinput-container > div > div > input')
  );
  titles = titles.map((input) => {
    return input.value;
  });
  let imgs = Array.from(document.querySelectorAll('.list-feed-item-image img'));
  imgs = imgs.map((gfy) => {
    return scaleRes(gfy.naturalWidth, gfy.naturalHeight + 51);
  });

  let gfys = titles.map((title, i) => {
    return { title: title, link: urls[i], img: imgs[i] };
  });

  gfys = gfys.sort((a, b) => {
    return compareLists(a.title, b.title);
  });

  let md = '';

  if (format.includes('plain')) {
    gfys.forEach((link, num) => {
      md += `${format.includes('numbered') ? num + 1 + ': ' : ''}${
        format.includes('titles') ? link.title + ' ' : ''
      }${link.link}\n`;
    });
    copyToClipboard(md);
    return;
  }

  let videoID = '';
  if (gfys.length > 0) {
    const videoIDMatches = gfys[0].title.match(/\[([-_ \d\w]{11})\]/);
    if (videoIDMatches && videoIDMatches.length > 0) {
      videoID = videoIDMatches[1].replace(' ', '_');
    }
  }

  if (format.includes('md')) {
    gfys.forEach((gfy) => {
      md += `[${gfy.title}](${gfy.link})\n\n`;
    });
    md += `[Source](https://www.youtube.com/watch?v=${videoID})\n\n`;
    if (page === 'album') {
      const album = location.href.replace('manage/', '');
      md += `[Album](${album})`;
    }
  } else if (format.includes('iframe')) {
    gfys.forEach((gfy) => {
      gfy.link = gfy.link.replace('gfycat.com/', 'gfycat.com/ifr/');
      md += `<iframe src='${gfy.link}' frameborder='0' scrolling='no' allowfullscreen width='${gfy.img.width}' height='${gfy.img.height}'></iframe>\n\n`;
    });
    md += `<br><br><a href='https://www.youtube.com/watch?v=${videoID}'>Source</a>\n`;
    if (page === 'album') {
      const album = location.href.replace('manage/', '');
      md += `<br><br><a href='${album}'>Album</a>\n\n`;
    }
  }

  copyToClipboard(md);
}

function quicktag() {
  const tagsWrappers = Array.from(
    document.getElementsByClassName('scrollable-tags-wrapper')
  );
  const tagsInputs = Array.from(document.getElementsByClassName('react-tagsinput-input'));
  if (tagsWrappers.length > 1) {
    const firstTagsWrapper = tagsWrappers.shift();
    const firstTags = Array.from(
      firstTagsWrapper.getElementsByClassName('react-tagsinput-tag')
    ).map((span) => span.textContent);
    const firstTagsInput = tagsInputs.shift();
    firstTags.push(firstTagsInput.value);
    for (let tagsWrapper of tagsWrappers) {
      const tagsInput = tagsWrapper.getElementsByClassName('react-tagsinput-input')[0];
      for (let tag of firstTags) {
        tagsInput.value = tag;
        tagsInput.dispatchEvent(new Event('blur'));
      }
    }
  }
}

function markAllGfysSFW() {
  const contentRestrictionSwitches = Array.from(
    document.getElementsByClassName('content-restriction-switch')
  );
  if (contentRestrictionSwitches.length > 1) {
    for (let contentRestrictionSwitch of contentRestrictionSwitches) {
      const sfwSwitch = contentRestrictionSwitch.querySelector('button');
      sfwSwitch.click();
    }
  }
}

function deleteTags() {
  const tagsWrappers = Array.from(
    document.getElementsByClassName('scrollable-tags-wrapper')
  );
  if (tagsWrappers.length > 1) {
    for (let tagsWrapper of tagsWrappers) {
      const removeButtons = tagsWrapper.getElementsByClassName('react-tagsinput-remove');
      if (removeButtons.length >= 1) {
        removeButtons[removeButtons.length - 1].click();
      }
    }
  }
}

const paginateCSS = `
  .m-grid-container {
    display: none !important;
  }
  .grid-item {
    width: 300px;
    height: fit-content;
    margin-bottom: 3px;
  }
  .paginate-grid {
    position: relative;
    margin: 0 auto;
    max-width: 100%;
    margin-top: 10px;
    width: 310px
  }

  @media (min-width: 630px) {
    .paginate-grid {
      width:620px
    }
  }
  @media (min-width: 938px) {
    .paginate-grid {
      width:930px
    }
  }
  @media (min-width: 1247px) {
    .paginate-grid {
      width:1240px
    }
  }

  .page-navigator .page .page-input {
    position: relative;
    width: 36px;
    height: 36px;
    border: 1px solid hsla(0,0%,100%,.3);
    border-radius: 0;
    outline: none;
    font-size: 1.3rem;
    text-align: center;
    text-transform: none;
    color: #fff;
    background-color: transparent;
    padding: 0
  }

  #searchBarContainer {
    margin-left: 10px;
  }

  #searchBar {
    width: 300px;
    border: 1px solid rgb(45, 131, 199);
  }

  .textOverlay {
    position: absolute;
    bottom: -16px;
    left: 4px;
    font-size: 16pt;
    text-shadow: 2px 2px 3px black;
    color: white;
    user-select: none;
    font-weight: 600;
    display: none;
  }

  .grid-item:hover > .textOverlay {
    display: block;
  }
`;

let gfysPerPage = 20;
let nPages = 0;
let nGfys = 0;
function getPaginatorBar(page) {
  const paginatorBar = `
    <div class="paginator-bar-container bottom-bar">
      <div class="left">
        <span id="gfyPageStats" class="text"
          ><strong>${(page - 1) * gfysPerPage}</strong>-<strong
            >${Math.min(page * gfysPerPage, nGfys)}</strong
          >
          OF <strong>${nGfys}</strong></span
        >
      </div>
      <div class="right">
        <div class="page-navigator">
          <div class="page-control previous">
            <span class="SVGInline ic ic-chevron"
              ><svg
                class="SVGInline-svg ic-svg ic-chevron-svg"
                viewBox="0 0 6 10"
                xmlns="http://www.w3.org/2000/svg"
                fill-rule="evenodd"
                clip-rule="evenodd"
                stroke-miterlimit="10"
              >
                <path
                  d="M1.13 8.854a.546.546 0 0 1 0-.707L3.927 5 1.13 1.854a.546.546 0 0 1 0-.707.41.41 0 0 1 .629 0l3.111 3.5a.546.546 0 0 1 0 .707l-3.111 3.5A.422.422 0 0 1 1.445 9a.423.423 0 0 1-.315-.146z"
                ></path></svg
            ></span>
          </div>
          <div class="page">
            <input
              id="pageInput"
              autocomplete="off"
              class="page-input"
              name="currentPage"
              value="${page}"
            />
            <span class="divider">/</span><span id="maxPageCount" class="max-page-count">${nPages}</span>
          </div>
          <div class="page-control next">
            <span class="SVGInline ic ic-chevron"
              ><svg
                class="SVGInline-svg ic-svg ic-chevron-svg"
                viewBox="0 0 6 10"
                xmlns="http://www.w3.org/2000/svg"
                fill-rule="evenodd"
                clip-rule="evenodd"
                stroke-miterlimit="10"
              >
                <path
                  d="M1.13 8.854a.546.546 0 0 1 0-.707L3.927 5 1.13 1.854a.546.546 0 0 1 0-.707.41.41 0 0 1 .629 0l3.111 3.5a.546.546 0 0 1 0 .707l-3.111 3.5A.422.422 0 0 1 1.445 9a.423.423 0 0 1-.315-.146z"
                ></path></svg
            ></span>
          </div>
        </div>
      </div>
    </div>
  `;

  return htmlToElement(paginatorBar);
}

function updatePaginatorBar(page) {
  const gfyPageStats = document.getElementById('gfyPageStats');
  const pageInput = document.getElementById('pageInput');
  const maxPageCount = document.getElementById('maxPageCount');

  gfyPageStats.innerHTML = `
    <strong>${(page - 1) * gfysPerPage}</strong>-<strong
      >${Math.min(page * gfysPerPage, nGfys)}</strong
    >
    OF <strong>${nGfys}</strong>
  `;

  pageInput.value = page;
  maxPageCount.innerText = nPages;
}

function getMenuBar() {
  const menuBar = `
    <div id="searchBarContainer" class="search-bar">
      <input id="searchBar" class="search-input" name="searchText" placeholder="Fuzzy search library"/>
      <button class="search-button">
        <span class="SVGInline ic ic-search"
          ><svg
            class="SVGInline-svg ic-svg ic-search-svg"
            fill="#ffffff"
            viewBox="0 0 20 20"
            xmlns="http://www.w3.org/2000/svg"
            fill-rule="evenodd"
            clip-rule="evenodd"
            stroke-linejoin="round"
            stroke-miterlimit="1.414"
          >
            <path
              d="M2.476 14.445a8.675 8.675 0 0 0 2.692 1.809 8.268 8.268 0 0 0 3.293.667 8.275 8.275 0 0 0 4.8-1.485l4.112 4.111c.301.301.646.453 1.076.453.43 0 .776-.152 1.098-.453.301-.323.453-.667.453-1.098 0-.408-.152-.775-.453-1.076l-4.111-4.112a8.275 8.275 0 0 0 1.485-4.8c0-1.141-.215-2.24-.667-3.294a8.696 8.696 0 0 0-1.808-2.69 8.715 8.715 0 0 0-2.691-1.81A8.275 8.275 0 0 0 8.46 0C7.32 0 6.221.216 5.168.668a8.696 8.696 0 0 0-2.692 1.808 8.71 8.71 0 0 0-1.808 2.69A8.276 8.276 0 0 0 0 8.462c0 1.14.216 2.239.668 3.294a8.699 8.699 0 0 0 1.808 2.69zM8.461 3.079c1.485 0 2.756.517 3.81 1.572 1.055 1.055 1.572 2.324 1.572 3.81 0 1.485-.517 2.756-1.571 3.81-1.055 1.055-2.326 1.572-3.81 1.572-1.487 0-2.756-.517-3.811-1.571-1.055-1.055-1.572-2.326-1.572-3.81 0-1.487.517-2.756 1.572-3.811 1.055-1.055 2.324-1.572 3.81-1.572z"
            ></path></svg
        ></span>
      </button>
      <span style="white-space:pre;color:white">    Actions: </span>
        <a id="copyGfyLinks" class="outlined-button"">Copy Links</a>
        <a id="copyGfyGiantLinks" class="outlined-button"">Copy Giant Links</a>
      <span style="white-space:pre;color:white">    Sort Gfys: </span> <a id="sortGfysByViews" class="outlined-button"">Views</a>
    </div>
  `;
  return htmlToElement(menuBar);
}

let gfysContainer;
let currPage = 0;
let pageInput;
let searchBar;
const userIdRegex = `gfycat.com/@([\\d\\w._-]*)$`;
async function paginate() {
  if (gfysContainer) return;
  const isProfilePage = location.href.match(userIdRegex);

  if (!isProfilePage) {
  }
  window.addEventListener(
    'scroll',
    function (event) {
      event.stopImmediatePropagation();
    },
    true
  );
  const profileContent = document.getElementsByClassName('profile-content')[0];
  nGfys = await getNumPublishedGfys();
  nPages = nGfys / gfysPerPage;
  profileContent.appendChild(getPaginatorBar(1));
  profileContent.appendChild(getMenuBar());
  const sortByViewsButton = document.getElementById('sortGfysByViews');
  sortByViewsButton.addEventListener('click', sortGfysByViews);
  const copyGfyLinksButton = document.getElementById('copyGfyLinks');
  copyGfyLinksButton.addEventListener('click', () => {
    copyGfyLinks();
  });
  const copyGfyGiantLinksButton = document.getElementById('copyGfyGiantLinks');
  copyGfyGiantLinksButton.addEventListener('click', () => {
    copyGfyGiantLinks();
  });

  searchBar = document.getElementById('searchBar');
  searchBar.addEventListener('change', searchBarHandler);

  gfysContainer = profileContent.getElementsByClassName('m-grid-container')[0];
  injectCSS(paginateCSS, 'paginateCSS');

  gfysContainer = document.createElement('div');
  gfysContainer.className = 'paginate-grid';
  profileContent.appendChild(gfysContainer);

  const pageNavigator = document.getElementsByClassName('page-navigator')[0];
  const nextButton = pageNavigator.getElementsByClassName('next')[0];
  const prevButton = pageNavigator.getElementsByClassName('previous')[0];
  pageInput = pageNavigator.getElementsByClassName('page-input')[0];
  nextPage();

  nextButton.addEventListener('click', nextPage);
  prevButton.addEventListener('click', prevPage);
  pageInput.addEventListener('change', pageInputHandler);
}

async function nextPage() {
  if (currPage < nPages) {
    currPage++;
    pageInput.value = currPage;
    await changePage(currPage);
  }
}

async function prevPage() {
  if (currPage > 1) {
    currPage--;
    pageInput.value = currPage;
    await changePage(currPage);
  }
}

async function pageInputHandler(e) {
  const prevPage = currPage;
  currPage = e.target.value;
  if (currPage >= nPages) {
    currPage = prevPage;
    e.target.value = prevPage;
  } else {
    await changePage(currPage);
  }
}

let cursor = '';
let iso;
let gfysUnfiltered = [];
async function changePage(page) {
  if (!iso) {
    iso = new Isotope(gfysContainer, {
      itemSelector: '.grid-item',
      layoutMode: 'masonry',
      masonry: {
        columnWidth: 300,
        gutter: 5,
      },
    });
  }

  const startGfyIndex = gfysPerPage * (page - 1);
  const endGfyIndex = gfysPerPage * page;
  iso.remove(gfysContainer.children);
  const gfys = isFiltered ? gfysFiltered : gfysUnfiltered;

  const searchBarContainer = document.getElementById('searchBarContainer');
  const loadingSpinner = getLoadingSpinner();
  searchBarContainer.insertAdjacentElement('afterend', loadingSpinner);

  if (!isFiltered) {
    while (endGfyIndex >= gfysUnfiltered.length) {
      const gfyPage = await fetchGfyPage(cursor);
      cursor = gfyPage.cursor;
      gfysUnfiltered.push(...gfyPage.gfycats);
    }
  }

  deleteElement(loadingSpinner);

  const newPage = [];
  gfys.slice(startGfyIndex, endGfyIndex).forEach((gfy) => {
    const { width, height } = scaleRes(gfy.width, gfy.height, 300);
    const gfyItem = htmlToElement(`
      <div class="grid-item">
        <a href="/${gfy.gfyId}">
          <picture>
            <source srcset="${gfy.webpUrl}" type="image/webp" />
            <source srcset="${gfy.max2mbGif}" type="image/gif" />
            <img src="${gfy.max1mbGif}" width="${width}px" height="${height}"/>
          </picture>
          </a>
        <p class="views textOverlay">${gfy.views}</p>
      </div>
    `);
    newPage.push(gfyItem);
  });

  iso.insert(newPage);
  window.dispatchEvent(new Event('resize'));
  iso.layout();
  currPage = page;
  updatePaginatorBar(page);
}

let gfysFiltered = [];
let isFiltered = false;
let search = '';
const fuseOptions = {
  tokenize: true,
  findAllMatches: true,
  includeScore: true,
  threshold: 0.1,
  location: 0,
  distance: Infinity,
  maxPatternLength: 128,
  minMatchCharLength: 0,
  keys: ['title', 'tags'],
};
let fuse;
async function searchBarHandler(e) {
  search = e.target.value;
  if (search === '') {
    gfysFiltered = [];
    isFiltered = false;
    nGfys = gfysUnfiltered.length;
    nPages = nGfys / gfysPerPage;
  } else {
    await fetchAllGfys();
    if (!fuse) fuse = new Fuse(gfysUnfiltered, fuseOptions); // "list" is the item array
    gfysFiltered = fuse.search(search).map((searchResult) => {
      return searchResult.item;
    });
    // console.log(gfysFiltered);
    // gfysFiltered = gfysUnfiltered.filter((gfy) => {
    //   return gfy.title.match(search);
    // });
    isFiltered = true;
    nGfys = gfysFiltered.length;
    nPages = nGfys / gfysPerPage;
  }
  changePage(1);
}

const loadingSpinnerCSS = `
  .lds-ring { display: inline-block; position: relative; width: 200px; height: 200px; margin: 10px; margin-left: 45% }
  .lds-ring div { box-sizing: border-box; display: block; position: absolute; width: 200px;
  height: 200px; margin: 8px; border: 8px solid #cef; border-radius: 50%; animation:
  lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite; border-color: #cef transparent
  transparent transparent; } .lds-ring div:nth-child(1) { animation-delay: -0.45s; }
  .lds-ring div:nth-child(2) { animation-delay: -0.3s; } .lds-ring div:nth-child(3) {
  animation-delay: -0.15s; } @keyframes lds-ring { 0% { transform: rotate(0deg); } 100% {
  transform: rotate(360deg); } }

  #loadPercent {
    color: white;
    position: absolute;
    left: 42%;
    top: 48%;
  }
`;

const loadingSpinnerHTML = `
<div class="lds-ring">
  <div></div><div></div><div></div><div></div>
  <span id="loadPercent">0%</span>
</div>
`;
function getLoadingSpinner() {
  const loadingSpinnerStyle = injectCSS(loadingSpinnerCSS);
  const loadingSpinner = htmlToElement(loadingSpinnerHTML);
  return loadingSpinner;
}

async function fetchAllGfys() {
  const searchBarContainer = document.getElementById('searchBarContainer');
  const loadingSpinner = getLoadingSpinner();
  searchBarContainer.insertAdjacentElement('afterend', loadingSpinner);
  const loadPercentSpan = document.getElementById('loadPercent');

  let loadPercent = ((gfysUnfiltered.length / nGfys) * 100).toFixed(2);
  loadPercentSpan.textContent = `${loadPercent}%`;

  while (cursor) {
    console.log(cursor);
    const gfyPage = await fetchGfyPage(cursor);
    cursor = gfyPage.cursor;
    gfysUnfiltered.push(...gfyPage.gfycats);

    loadPercent = ((gfysUnfiltered.length / nGfys) * 100).toFixed(2);
    loadPercentSpan.textContent = `${loadPercent}%`;
  }
  deleteElement(loadingSpinner);
}

async function copyGfyLinks() {
  await fetchAllGfys();
  let gfys = isFiltered ? gfysFiltered : gfysUnfiltered;

  gfys = gfys.map((gfy) => {
    return `https://gfycat.com/${gfy.gfyId}`;
  });

  copyToClipboard(gfys.join('\n'));
}

async function copyGfyGiantLinks() {
  await fetchAllGfys();
  let gfys = isFiltered ? gfysFiltered : gfysUnfiltered;

  gfys = gfys.map((gfy) => {
    // console.log(gfy);
    return gfy.webmUrl;
  });

  copyToClipboard(gfys.join('\n'));
}

function sortGfysByViews() {
  getSortedGfys();
}

async function getSortedGfys(gfyComparator) {
  if (gfyComparator == null) {
    gfyComparator = (gfyA, gfyB) => {
      return gfyB.views - gfyA.views;
    };
  }

  console.log(gfyComparator);
  await fetchAllGfys();
  const gfysSorted = [...gfysUnfiltered];
  gfysSorted.sort(gfyComparator);
  gfysUnfiltered = gfysSorted;
  changePage(currPage);
  return gfysSorted;
}

async function fetchGfyPage(cursor, count = 100) {
  const userId = location.href.match(userIdRegex)[1];
  let url = `https://api.gfycat.com/v1/users/${userId}/gfycats?count=${count}`;
  if (cursor) url += `&cursor=${cursor}`;
  try {
    const data = await fetch(url).then((res) => res.json());
    return data;
  } catch (error) {
    console.error(error);
  }
}

async function getNumPublishedGfys() {
  const userId = location.href.match(userIdRegex)[1];
  let url = `https://api.gfycat.com/v1/users/${userId}`;
  try {
    const data = await fetch(url).then((res) => res.json());
    return data.publishedGfycats;
  } catch (error) {
    console.error(error);
  }
}

//+BEGINSECTION utilities

function compareLists(a, b) {
  var alist = a.split(/(\d+)/), // split text on change from anything to digit and digit to anything
    blist = b.split(/(\d+)/); // split text on change from anything to digit and digit to anything

  alist.slice(-1) === '' ? alist.pop() : null; // remove the last element if empty
  blist.slice(-1) === '' ? blist.pop() : null; // remove the last element if empty

  for (var i = 0, len = alist.length; i < len; i++) {
    if (alist[i] != blist[i]) {
      // find the first non-equal part
      if (alist[i].match(/\d/)) {
        // if numeric
        return +alist[i] - +blist[i]; // compare as number
      } else {
        return alist[i].localeCompare(blist[i]); // compare as string
      }
    }
  }

  return true;
}

function scaleRes(width, height, targetWidth = 560) {
  let widthScale = targetWidth / width;
  let targetHeight = Math.round(height * widthScale);
  return { width: targetWidth, height: targetHeight };
}

function copyToClipboard(str) {
  const el = document.createElement('textarea');
  el.value = str;
  document.body.appendChild(el);
  el.select();
  document.execCommand('copy');
  document.body.removeChild(el);
}

function injectCSS(css, id) {
  const style = document.createElement('style');
  style.setAttribute('id', id);
  style.innerHTML = css;
  document.body.appendChild(style);
  return style;
}

function deleteElement(elem) {
  if (elem && elem.parentElement) {
    elem.parentElement.removeChild(elem);
  }
}

function htmlToElement(html) {
  const template = document.createElement('template');
  html = html.trim(); // Never return a text node of whitespace as the result
  template.innerHTML = html;
  return template.content.firstChild;
}
//+ENDSECTION utilities