View All Editorials

View all editorials of the AtCoder contest in one page.

// ==UserScript==
// @name            View All Editorials
// @name:ja         解説ぜんぶ見る
// @description     View all editorials of the AtCoder contest in one page.
// @description:ja  AtCoderコンテストの解説ページに、すべての問題の解説をまとめて表示します。
// @version         1.5.0
// @icon            https://www.google.com/s2/favicons?domain=atcoder.jp
// @match           https://atcoder.jp/contests/*/editorial
// @match           https://atcoder.jp/contests/*/editorial?*
// @grant           GM_addStyle
// @require         https://cdn.jsdelivr.net/npm/katex@0.16.2/dist/katex.min.js
// @require         https://cdn.jsdelivr.net/npm/katex@0.16.2/dist/contrib/auto-render.min.js
// @require         https://cdn.jsdelivr.net/npm/timeago@1.6.7/jquery.timeago.min.js
// @namespace       https://gitlab.com/w0mbat/user-scripts
// @author          w0mbat
// ==/UserScript==

(async function () {
  'use strict';
  console.log(`🐻 "View All Editorials" initializing... 🐻`)

  // Utils
  const appendHeadChild = (tagName, options) =>
    Object.assign(document.head.appendChild(document.createElement(tagName)), options);
  const addScript = (src) => new Promise((resolve) => {
    appendHeadChild('script', { src, type: 'text/javascript', onload: resolve });
  });
  const addStyleSheet = (src) => new Promise((resolve) => {
    appendHeadChild('link', { rel: 'stylesheet', href: src, onload: resolve });
  });
  const sleep = async (ms) => new Promise((resolve) => setTimeout(resolve, ms));

  // KaTeX
  const loadKaTeX = async () =>
    await addStyleSheet("https://cdn.jsdelivr.net/npm/katex@0.16.2/dist/katex.min.css");
  const kaTexOptions = {
    delimiters: [
      { left: "$$", right: "$$", display: true },
      { left: "$", right: "$", display: false },
      { left: "\\(", right: "\\)", display: false },
      { left: "\\[", right: "\\]", display: true }
    ],
    ignoredTags: ["script", "noscript", "style", "textarea", "code", "option"],
    ignoredClasses: ["prettyprint", "source-code-for-copy"],
    throwOnError: false
  };
  const renderKaTeX = (rootDom) => {
    /* global renderMathInElement */
    renderMathInElement && renderMathInElement(rootDom, kaTexOptions);
  };

  // code-prettify
  const loadPrettifier = async () => {
    await addScript("https://cdn.jsdelivr.net/gh/google/code-prettify@master/loader/run_prettify.js?autorun=false");
  };
  /* global PR */
  const runPrettifier = () => PR.prettyPrint();

  // jQuery TimeAgo
  const loadTimeAgo = async () => {
    /* global LANG */
    if (LANG == 'ja') await addScript("https://cdn.jsdelivr.net/npm/timeago@1.6.7/locales/jquery.timeago.ja.min.js");
  };
  const renderTimeAgo = () => {
    /* global $ */
    $("time.timeago").timeago();
    $('.tooltip-unix').each(function () {
      var unix = parseInt($(this).attr('title'), 10);
      if (1400000000 <= unix && unix <= 5000000000) {
        var date = new Date(unix * 1000);
        $(this).attr('title', date.toLocaleString());
      }
    });
    $('[data-toggle="tooltip"]').tooltip();
  };

  // Editorials Loader
  const editorialBodyQuery = "#main-container > div.row > div:nth-child(2)";
  const scrape = (doc) => [
    doc.querySelector(`${editorialBodyQuery} > div:nth-of-type(1)`),
    doc.querySelector(`${editorialBodyQuery} > div:nth-of-type(2)`),
  ];
  const fetchEditorial = async (link) => {
    const response = await fetch(link.href);
    if (!response.ok) throw "Fetch failed";
    const [content, history] = scrape(new DOMParser().parseFromString(await response.text(), 'text/html'));
    if (!content) throw "Scraping failed";
    return [content, history];
  };
  const renderEditorial = (link, content, history) => {
    const div = link.parentNode.appendChild(document.createElement('div'));
    div.classList.add('🐻-editorial-content');
    div.appendChild(content);
    if (history) div.appendChild(history);
    renderKaTeX(div);
    renderTimeAgo();
    runPrettifier();
  };
  const loadEditorial = async (link) => {
    const [content, history] = await fetchEditorial(link);
    renderEditorial(link, content, history);
  };

  // Lazy Loading
  const Timer = (callback, interval) => {
    let id = undefined;
    return {
      start: () => {
        if (id) return;
        callback();
        id = setInterval(callback, interval);
      },
      stop: () => {
        if (!id) return;
        clearInterval(id);
        id = undefined;
      },
    };
  };
  const Queue = (task, interval) => {
    const set = new Set();
    let timer = Timer(() => {
      for (const element of set) {
        task(element);
        set.delete(element);
        break;
      }
      if (set.size == 0) timer.stop();
    }, interval);
    return {
      add: (element) => {
        set.add(element);
        timer.start();
      },
      remove: (element) => set.delete(element),
    };
  };
  let unobserveEditorialLink = undefined;
  const queue = Queue(async (link) => {
    await loadEditorial(link)
      .catch(ex => console.warn(`🐻 Something wrong: "${link.href}", ${ex}`));
    unobserveEditorialLink(link);
  }, 200);
  const intersectionCallback = async (entries) => {
    for (const entry of entries) {
      if (entry.isIntersecting) queue.add(entry.target);
      else queue.remove(entry.target);
    }
  };
  const observeEditorialLinks = (links) => {
    const observer = new IntersectionObserver(intersectionCallback);
    unobserveEditorialLink = (link) => observer.unobserve(link);
    links.forEach(e => observer.observe(e));
  };

  // initialize
  const init = async () => {
    GM_addStyle(`
      pre code { tab-size: 4; }
      ${editorialBodyQuery} > ul > li { font-size: larger; }
      .🐻-editorial-content { margin-top: 0.3em; font-size: smaller; }
    `);
    await loadKaTeX();
    await loadPrettifier();
    await loadTimeAgo();
  };

  // main
  await init();
  const internalEditorialLink = (link) => link.href.match(/\/contests\/.+\/editorial\//);
  const notSpoiler = (link) => !link.classList.contains('spoiler');
  const links = [...document.getElementsByTagName('a')].filter(internalEditorialLink).filter(notSpoiler);
  if (links.length > 0) observeEditorialLinks(links);

  console.log(`🐻 "View All Editorials" initialized. 🐻`)
})();