FIMFiction - Remaining Words and Reading Time

Displays average reading time left and overall story progress.

// ==UserScript==
// @name        FIMFiction - Remaining Words and Reading Time
// @namespace   Selbi
// @include     http*://fimfiction.net/*
// @include     http*://www.fimfiction.net/*
// @version     3.2
// @description Displays average reading time left and overall story progress.
// ==/UserScript==

//////////////////////////////////////
// Read Time in Words-Per-Minute
const WPM = 220;
// You must enter your own speed!
//////////////////////////////////////

(function() {
  // Set up CSS
  let style = document.createElement('style');
  style.type = 'text/css';
  style.innerHTML = `
    #remainingTimeNode {
      font-size: 90%;
      opacity: 0.8;
      margin-right: 1em;
    }

    #progressBarProgressNode {
      background-color: green;
      height: inherit;
      border-bottom-left-radius: 4px;
      border-bottom-right-radius: 4px;
      transition: width 0.2s ease-out;
    }

    .readTime {
      font-size: 80%;
      opacity: 0.5;
      margin-right: 1em;
    }

    @media (max-width: 1280px) { 
      .story_container .chapters-footer .word_count {
        position: initial;
        margin-top: 6px;
      }

      .story_container .chapters-footer {
        padding-right: 10px;
      }
    }
  `;
  document.querySelector("head").appendChild(style);
  
  // Parse on page load
  let storyContainers = document.querySelectorAll("article.story_container");
  for (story of storyContainers) {
    parseStory(story);
  }
  
  function parseStory(story) {
    // Global variables
    let readWordsNode = document.createElement("b");
    let outOfTextNode = document.createElement("span");
    let totalWordCountElem = story.querySelector(".chapters-footer > .word_count > b");
    let remainingTimeNode = document.createElement("span");
    remainingTimeNode.id = "remainingTimeNode";
    let progressBarProgressNode = document.createElement("div");
    progressBarProgressNode.id = "progressBarProgressNode";
    let totalWordCount = parseIntFull(totalWordCountElem.innerHTML);
    let totalReadWords = 0;
    let readChapters = 0;
    let totalChapters = 0;

    // Reusable hook (with timeout troubleshooting)
    let updateHandler = function(){ setTimeout(function(){ updateRemainingReadTime(); }, 1000); };

    // One-time call at page loag
    (function init() {
      // Add hook for toggle all chapters button
      story.querySelector(".chapters-footer > a").addEventListener("click", updateHandler, false);

      // Parse chapters for the first time
      readWordsNode.innerHTML = numberWithCommas(totalWordCount - parseChapters());

      // "x of y words" box
      outOfTextNode.innerHTML = " of ";
      totalWordCountElem.before(outOfTextNode);
      outOfTextNode.before(readWordsNode);
      
      // Write total remaining reading time
      writeReadTime();
      readWordsNode.before(remainingTimeNode);

      // Create and insert the progress bar
      let progressBarNode = document.createElement("div");
      progressBarNode.style.height = "4px";
      let barWidth = getPercent(totalReadWords, totalWordCount);
      progressBarProgressNode.title = barWidth;
      progressBarProgressNode.style.width = barWidth;
      progressBarNode.appendChild(progressBarProgressNode);
      story.querySelector(".chapters-footer").after(progressBarNode);
    })();

    // Central function to read the word count and reading status of each chapter
    // Also adds reading times for each chapter on page loag
    function parseChapters() {
      // All chapters minus the "Show" button for long stories
      let chapterElems = story.querySelectorAll(".chapters > li > div:not(.chapter_expander)");
      totalChapters = chapterElems.length;
      
      // Reset accus
      let readWords = 0;
      readChapters = 0;
      
      for (let ch of chapterElems) {
        // Element references
        let readIconElem = ch.querySelector("a.chapter-read-icon");
        let wordCountElem = ch.querySelector("div.word_count span.word-count-number");
        
        // Skip unpublished chapters
        if (readIconElem.parentNode.querySelector("img") != null) {
          totalChapters--;
          continue;
        }
        
        // Total word count
        let chapterWordCount = parseIntFull(wordCountElem.innerHTML);
        
        // Check if chapter is read
        let isRead = readIconElem.classList.contains("chapter-read");
        
        // Increase global read progress
        if (isRead) {
          readWords += chapterWordCount;
          readChapters++;
        }
        
        // Check if this is an in-progress chapter add its partial read percentage if available
        let readProgress = ch.parentElement.querySelector(".read-progress");
        let partialReadWordsForChapter = 0;
        if (readProgress != null) {
          let inProgressReadPercentage = parseFloat(readProgress.style.width) / 100.0;
          partialReadWordsForChapter = Math.round(chapterWordCount * inProgressReadPercentage);
          if (!isRead) {
            readWords += partialReadWordsForChapter;
          }
        }
        
        // Reading time
        let readTimeNode = wordCountElem.parentNode.querySelector(".readTime");
        if (readTimeNode == null) {
          // Create new
          readTimeNode = document.createElement("span");
          readTimeNode.classList = "readTime";
          wordCountElem.before(readTimeNode);
          wordCountElem.parentNode.title = getPercent(chapterWordCount, totalWordCount);

          // Hook
          readIconElem.addEventListener("click", updateHandler, false);
        }
        
        let readTimeText = convertToTime(chapterWordCount);
        if (partialReadWordsForChapter > 0) {
          readTimeText = convertToTime(chapterWordCount - partialReadWordsForChapter) + " (of " + convertToTime(chapterWordCount) + ")";
        }
        readTimeNode.innerHTML = readTimeText;
      }
      
      if (readChapters >= totalChapters) {
        totalReadWords = totalWordCount;
      } else {
        totalReadWords = readWords;
      }
      return readWords;
    }

    // Gets called on page load and on every
    function updateRemainingReadTime() {
      readWordsNode.innerHTML = numberWithCommas(parseChapters());
      writeReadTime();
      let percent = getPercent(totalReadWords, totalWordCount);
      progressBarProgressNode.style.width = percent;
      progressBarProgressNode.title = percent;
    }

    // Read time with respect to the fact whether a story is read or not
    function writeReadTime() {
      remainingTimeNode.title = readChapters + " / " + totalChapters + " chapters read (" + convertToTime(totalReadWords) + ")";
      if (totalReadWords > 0 && readChapters < totalChapters) {
        readWordsNode.classList.remove("hidden");
        outOfTextNode.classList.remove("hidden");
        remainingTimeNode.innerHTML = convertToTime(totalWordCount - totalReadWords) + " of " + convertToTime(totalWordCount) + " remaining";
        return;
      }
      
      readWordsNode.classList.add("hidden");
      outOfTextNode.classList.add("hidden");
      remainingTimeNode.innerHTML = convertToTime(totalWordCount);
    }
  }
  
  ///////////////////
  // Formatting functions

  function parseIntFull(number) {
    return parseInt(number.replace(/,/g, "").trim());
  }
  
  function numberWithCommas(number) {
    return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
  }
  
  function convertToTime(wordCount) {
    let time = (Math.ceil(wordCount / WPM));
    if (time > 60) {
      time = ((Math.ceil(time / 6)) / 10).toFixed(1) + " h";
    } else {
      time += " min";
    }
    return time;	
  }
  
  function getPercent(num1, num2) {
    return Math.min(100, (Math.round(num1 / num2 * 10000) / 100)).toFixed(2) + "%";
  }
})();