Chapter Counter for Map and Next Chapter choice

Counts and displays how many chapters each branch has inside on the map screen and when choosing your next chapter

As of 2021-04-27. See the latest version.

// ==UserScript==
// @name         Chapter Counter for Map and Next Chapter choice
// @version      2.0.1
// @description  Counts and displays how many chapters each branch has inside on the map screen and when choosing your next chapter
// @author       sllypper
// @namespace    https://greasyfork.org/en/users/55535-sllypper
// @match        *://chyoa.com/story/*
// @match        *://chyoa.com/chapter/*
// @icon         https://chyoa.com/favicon.png
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_listValues
// @grant        GM_registerMenuCommand
// ==/UserScript==

/*
How to use:
* Load the entire map by moving to the bottom, then click the Regenerate button.
* There's more commands on the right-click menu or by clicking on the extension you used to load the script
* The script only sees the chapters that are currently loaded in the map page. You might have to regenerate after loading more
* There's also a toggler for the map to close/open all chapters of a certain depth
* If you got the map data, the counter will also appear when choosing your next chapter
*/

// runs automatically when opening a new map
let runAutomatically = true

// shows the floating buttons on the bottom-right of the page
let showFloatingButton = true
let showTogglerButton = true

// Style for the counter on the chapter pages. Use one of the below between ""
// alternative | simple | crazy | default
const cssStyle = "alternative"

/************************/

GM_registerMenuCommand("Regenerate map data", generateDraw, "r");
GM_registerMenuCommand("Redraw map counters", drawChildrenCounter, "d");
GM_registerMenuCommand("Delete this from cache", deleteThisFromCache, "c");
GM_registerMenuCommand("Delete entire cache", deleteEntireCache, "a");

/************************/

let storyMap = [];
let page = getCurrentPage()
console.log('The current page is a ' + page)

if (page == "map") {
    if (!attemptLoadFromCache()) {
        // no map data on cache
        if (runAutomatically) {
            generateDraw()
        }
    } else {
        // got map data from cache
        drawChildrenCounter()
    }

    if (showFloatingButton) { document.body.appendChild(btnLoad()) }

    addStyleHead(".title-wrapper > .children { padding-right: 8px; }")

    if (showTogglerButton) { collapsibator() }

    return
}

if (page == "chapter") {
    showChapterCountOnNextChapters()

    return
}

/************************/

function generateDraw() {
    generateMap()
    drawChildrenCounter()
}

function createChildrenElement(chapterCount) {
    var child = document.createElement('span')
    child.setAttribute('class', 'control-item children')
    let icon = document.createElement('i')
    icon.setAttribute('class', 'btb bt-folder')
    child.appendChild(icon);
    let count = document.createTextNode(" "+chapterCount)
    child.appendChild(count)
    return child
}

function addStyleHead(css) {
    var style = document.createElement('style');
    document.head.appendChild(style);
    style.textContent = css;
};

function attemptLoadFromCache() {
    const cachedMap = GM_getValue(window.location.pathname)

    if (cachedMap == undefined) {
        console.log("!! map not found in cache");
        return false
    }

    storyMap = JSON.parse(cachedMap);
    console.log("!! got map from cache");
    unsafeWindow.storyMap = storyMap
    console.log("Map available as a javascript array on `storyMap`")

    return true
}

function deleteThisFromCache() {
    if (page == "map") {
        GM_deleteValue(window.location.pathname)
    }
    let storyMapPath = getStoryPathName() + '/map'
    GM_deleteValue(storyMapPath)
}

function deleteEntireCache() {
    GM_listValues().forEach((value) => {
        GM_deleteValue(value)
    })
}

function readOrGenerateMap() {
    if (! attemptLoadFromCache()) generateMap()
}

function generateMap() {
    console.log("!! generating map");
    storyMap = createStoryMap()
    console.log("Chapter Count = ", storyMap.length);

    unsafeWindow.storyMap = storyMap
    console.log("Map available as a javascript array on `storyMap`")

    console.log("!! assembling hierarchy tree");
    countMapChapters();
    console.log("!! done counting the children");

    // cache it
    GM_setValue(window.location.pathname, JSON.stringify(storyMap))
}

/**
* counts and assigns the children count of all elements
*/
function countMapChapters() {
    for (const chapter of storyMap) {
        // find the leaves of the tree
        if (chapter.children.length !== 0) continue;
        chapter.chapterCount = 0;

        let next = chapter.parent != null ? chapter.parent : -1;

        // rise through the branch until it can't
        while (next != -1) {
            //console.log("from", chapter.id, "to", next);
            next = countChildChaptersOf(next);
        }
        // then continue to the next chapter childless chapter
    }
    // done
}

/**
* Counts and assigns the chapterCount of the node at that index
* Aborts returning -1 if it already has been counted
* or can't because one of its children hasn't been counted yet
*
* @param {number} currIndex - The index of the node
* @returns {number} The index of the next node. -1 to abort
*/
function countChildChaptersOf(currIndex) {
    let nextIndex = -1;
    // if (currIndex > storyMap.length) console.log('currIndex > storyMap.length', currIndex, storyMap.length)
    const currentNode = storyMap[currIndex];
    if (currentNode.chapterCount != undefined) {
        // this node was already been processed
        // abort
        return nextIndex;
    }
    // sum the counts of its children
    const chapterCountSum = sumChildrenChaptersCount(currentNode);
    if (chapterCountSum === -1) {
        // one or more children haven't been processed yet
        // abort
        return nextIndex;
    }

    // on successful sum
    currentNode.chapterCount = chapterCountSum + currentNode.children.length;
    nextIndex = currentNode.parent !== null ? currentNode.parent : -1;

    return nextIndex;
}

/**
* Sums the chapterCount of all children of the attribute node
*/
function sumChildrenChaptersCount(chapterObj) {
    return chapterObj.children.reduce((acc, curr) => {
        const currentNode = storyMap[curr];
        if (currentNode.chapterCount == undefined) {
            return -1;
        }
        return acc !== -1 ? acc + currentNode.chapterCount : acc;
    }, 0);
}

function drawChildrenCounter() {
    let list = document.querySelectorAll(".title-wrapper");

    list.forEach((elem, i) => {
        let existingChildren = elem.querySelector('.children')
        if (existingChildren) {
            // redraw
            existingChildren.remove()
        }

        let child = createChildrenElement(storyMap[i].chapterCount)
        let page = elem.querySelector('.page')

        elem.insertBefore(child, page)
    })
}

// receives html dom element
// returns parsed element as an object
function createChapterObj(el) {
    const pel = {};

    let tempEl = el.querySelector(".title");

    pel.title = tempEl.textContent;
    pel.url = tempEl.href;

    // sometimes the author is empty and there's no <a> inside it
    tempEl = el.querySelector(".username > a");
    pel.author = tempEl ? tempEl.textContent : "";

    // page is completely unreliable for chapters loaded afterwards. It resets on loading
    // pel.page = el.querySelector(".page").textContent;

    // Sometimes the date is empty, but there's no issue here
    // console.log(el)
    pel.date = el.querySelector(".date").textContent;

    pel.parent = null;
    pel.children = [];
    // pel.linksTo = null;

    pel.margin = parseInt(el.style["margin-left"]);

    // link chapters don't have views, likes, or comments
    // so find out if the chapter is a link chapter or not

    const viewsEl = el.querySelector(".views");
    if (viewsEl == null) {
        pel.views = null;
        pel.likes = null;
        pel.comments = null;
        pel.isLinkChapter = 1;

        return pel;
    }

    pel.views = parseInt(viewsEl.textContent.split(",").join("")) || 0;
    pel.likes = parseInt(el.querySelector(".likes").textContent) || 0;
    pel.comments = parseInt(el.querySelector(".comments").textContent) || 0;
    pel.isLinkChapter = 0;

    return pel;
}

// final list like [ chapterObj, (...) ]
// where every element has its parent and children noted
function createStoryMap() {
    // temporary list, to get the DOM element from the page
    // let list = document.getElementsByClassName("story-map-content");
    // if (list == null || !list) return;
    // list = Array.from(list[0].children);
    let chapterElementArray = Array.from(document.querySelectorAll(".story-map-chapter"))

    let prevParentI = -1;
    const finalList = [];

    chapterElementArray.forEach((el, i) => {
    // for (const i in chapterElementArray) {
        // console.log("- Processing Chapter", i);
        // const el = chapterElementArray[i];

        // parse el and add it to the final list
        const chapterObj = createChapterObj(el);
        finalList[i] = chapterObj;
        // console.log(chapterObj)

        // now we find the parent of el

        // before checking margin
        // check if it's the first element of the list
        if (i == 0) {
            prevParentI = 0;
            // continue; // when using a for loop
            return;
        }

        // check margins

        const currElMargin = chapterObj.margin
        const prevElMargin = finalList[i-1].margin

        // check if el is child of prev el
        if (prevElMargin < currElMargin) {
            // prev el is parent
            chapterObj.parent = parseInt(i - 1);
            // add this el as child of prev element
            finalList[i - 1].children.push(parseInt(i));
            // set prev parent to prev element
            prevParentI = i - 1;
            // continue; // when using a for loop
            return;
        }

        // check if el is sibling of prev el
        if (prevElMargin == currElMargin) {
            // they share the same parent

            // prevParent is parent
            chapterObj.parent = prevParentI;
            // add this el as child of prevParent
            finalList[prevParentI].children.push(i);
            // continue; // when using a for loop
            return;
        }

        // then el must be the "uncle" of prev el
        // prevElMargin > currElMargin

        // use a loop go back through the parents from the previous node
        // to find the first element with margin smaller than self
        const selfMargin = chapterObj.margin;
        for (let j = i - 1; j >= 0; j = finalList[j].parent) {
            if (finalList[j].margin < selfMargin) {
                // found the parent: j
                const actualParentI = j;
                chapterObj.parent = actualParentI;
                // add this el as child of actual parent
                // finalList[actualParentI].children.push(chapterObj.id);
                finalList[actualParentI].children.push(i);
                // set prev parent to actual parent
                prevParentI = actualParentI;
                break;
            }
        }
    // } // when using a for loop
    })

    return finalList;
}

// button stuff

function createButton(text, action, styleStr) {
    let button = document.createElement('button');
    button.textContent = text;
    button.onclick = action;
    button.setAttribute('style', styleStr || '');
    return button;
};
function toStyleStr(obj, selector) {
    let stack = [],
        key;
    for (key in obj) {
        if (obj.hasOwnProperty(key)) {
            stack.push(key + ':' + obj[key]);
        }
    }
    if (selector) {
        return selector + '{' + stack.join(';') + '}';
    }
    return stack.join(';');
};
function btnLoadCss() {
    return toStyleStr({
        'position': 'fixed',
        'bottom': 0,
        'right': 0,
        'padding': '2px',
        'margin': '0 10px 10px 0',
        'color': '#333',
        'background-color': 'rgb(246, 245, 244)',
        'z-index': '9999999999'
    })
}
function btnLoad() {
    return createButton('Regenerate', function() {
        generateDraw()
        // this.remove();
    }, btnLoadCss());
}

/* Depth Toggler */

function collapsibator() {
    const input = document.createElement('input')
    input.defaultValue = 1
    input.setAttribute('id', 'toggler-input')
    input.setAttribute('style', toStyleStr({
        // 'padding': '2px',
        'color': '#333',
        'width': '30px'
        // 'display': 'inline-block'
    }))

    const button = document.createElement('button')
    button.setAttribute('id', 'toggler-btn')
    button.textContent = 'Toggle'
    button.setAttribute('style', toStyleStr({
        'padding': '2px',
        'color': '#333',
        'background-color': 'rgb(246, 245, 244)',
        'display': 'inline-block',
        'font-size': '12px',
        'margin-left': '4px',
    }))
    button.onclick = () => {
        const level = document.getElementById('toggler-input').value
        toggleCollapsibleLevel(level)
    }

    const cont = document.createElement('div')
    cont.setAttribute('id', 'toggler-container')
    cont.setAttribute('style', toStyleStr({
        'position': 'fixed',
        'bottom': '50px',
        'right': '10px',
        'padding': '2px',
        'z-index': '9999999999'
    }))
    cont.appendChild(input)
    cont.appendChild(button)
    document.body.appendChild(cont)
}

// toggle all collapsibles from depht level "level"
function toggleCollapsibleLevel(level) {
    const chapters = Array.from(document.getElementsByClassName("story-map-chapter"));
    if (chapters == null) return;
    if (!storyMap.length) { console.error('storyMap is undefined'); return; }

    const firstMargin = parseInt(chapters[0].style['margin-left'])
    const marginGap = parseInt(chapters[1].style['margin-left']) - firstMargin;

    for (let i = 0; i < storyMap.length; i++) {
        // if (st.margin == (level*marginGap + firstMargin)) {
        if (storyMap[i].margin == (level*marginGap + firstMargin)) {
            // toggle it
            const btn = chapters[i].querySelector('.btn.btn-link.collapsable.js-collapsable')
            if (btn) btn.click()

            // maybe will use this in the future for better performance???
            // wasn't able to figure it out

            // expand
            // let clazz = Array.from(chapters[i].getAttribute('class')).split(' ').filter(c=>c!="hidden")
            // chapters[i].setAttribute('class', clazz)
            // clazz = Array.from(chapters[i].querySelector(".js-collapsable > i").getAttribute('class')).split(' ').filter(c=>c!="bt-minus"&&c!="bt-plus")
            // clazz.push('bt-minus')
            // chapters[i].querySelector(".js-collapsable > i").setAttribute('class', clazz)

            // collapse

            // if (storyMap[i].margin == (level*marginGap + firstMargin)) {
            //     let el = chapters[i].querySelector(".js-collapsable > i")
            //     let clazz = el.getAttribute('class').split(' ').filter(c=>c!="bt-minus").join(' ') + " bt-plus"
            //     el.setAttribute('class', clazz)
            // if (storyMap[i].margin > (level*marginGap + firstMargin)) {
            //     let el = chapters[i]
            //     let clazz = el.getAttribute('class') + " hidden"
            //     el.setAttribute('class', clazz)
        }
    }
    // })
}


function getCurrentPage() {
    let url = window.location.pathname
    // // console.log(url)
    if (url.search(/\/story\/.*\/map/) >= 0) {
        return "map"
    }
    if (url.search(/\/chapter\//) >= 0) {
        return "chapter"
    }
    if (url.search(/\/story\/.*\.[0-9]+$\/?/) >= 0) {
        // first chapter of story
        return "chapter"
    }
}

// get story url
// find it on cache
// get chapter url
// compare with chapters listed

function showChapterCountOnNextChapters() {

    let storyPathName = getStoryPathName() + '/map'

    if (GM_listValues().indexOf(storyPathName) < 0) { console.log('story not found in cache', GM_listValues(), storyPathName); return }

    // story is on cache
    let map = JSON.parse(GM_getValue(storyPathName))

    // getting the chapter url if we're in /story/
    let chapterUrl = window.location.href
    if (chapterUrl.search(/\/story\//) > -1) {
        let els = document.querySelectorAll('.controls-left a')
        let chapterNum = els[els.length-1].href.match(/\d+\/?$/)[0]
        chapterUrl = "https://chyoa.com/chapter/Introduction."+chapterNum;
    }

    // console.log(map, map[1].url, chapterUrl)
    let currentChapter = map.find(c=>c.url==chapterUrl)
    let date = document.createElement('div')
    date.textContent = currentChapter.date
    document.querySelector('.meta').append(date)

    let nextChapterIndexList = currentChapter.children

    let chapterElements = Array.from(document.querySelectorAll(".question-content a"))
    // Remove "Add a new chapter" link from the list (if the story is not private)
    // its link ends with "/new"
    if (chapterElements[chapterElements.length-1].href.search(/\/new$/) > -1) {
        chapterElements.pop()
    }

    if (cssStyle == "crazy") { applyCrazyCSS() } else
    if (cssStyle == "alternative") { applyAlternativeCSS() }

    chapterElements.forEach((el, i) => {
        let mapIndex = nextChapterIndexList[i]
        if (mapIndex == undefined) { return console.log('Chapter "'+el.textContent+'" not found. Please regenerate the story map.'); }
        drawLinkChapterCount(el, map[mapIndex], cssStyle)
    })
}

// crazy css
function applyCrazyCSS() {
    let style = toStyleStr({
        'display': 'flex',
        'width': '55px',
        'justify-content': 'space-between',
        'align-items': 'center',
        'margin': '0 8px 0 -55px !important',
        'position': 'inherit !important',
        'float': 'left',
        'border-right': '1px solid',
        'padding': '0 8px 0 0',
        'font-family': 'monospace',
        'font-size': '14px',
        'line-height': '27px',
    }, '.question-content .chapterCount')
    addStyleHead(style)
}

function applyAlternativeCSS() {
    let style = toStyleStr({
        'position': 'absolute',
        'left': '-45px',
        'text-align': 'right',
        'width': '40px',
        'padding': '11px 0',
        'top': '0',
    }, '.question-content .chapterCount')
        + toStyleStr({
        'position': 'relative',
    }, '.question-content a')
    addStyleHead(style)
}

function drawLinkChapterCount(el, chapterObj, cssStyle = 'default') {

    const chapterCount = chapterObj.chapterCount
    const isLinkChapter = chapterObj.isLinkChapter

    let span = document.createElement('SPAN')

    if (isLinkChapter) {
        let icon = document.createElement('i')

        icon.setAttribute('class', 'btb bt-external-link')
        el.insertAdjacentElement('afterbegin',icon)

        return
    }

    span.setAttribute('class', 'chapterCount')

    if (cssStyle == 'simple') {
        span.textContent = " ("+chapterCount+")"
        el.append(span)
        return
    }

    if (cssStyle == 'alternative') {
        let icon = document.createElement('i')

        icon.setAttribute('class', 'btb bt-folder')
        span.textContent = Number(chapterCount).toString()
        el.append(span)
        el.insertAdjacentElement('afterbegin', icon)

        return
    }

    // default & carzy markup

    let icon = document.createElement('i')
    icon.setAttribute('class', 'btb bt-folder')
    span.appendChild(icon);
    let count = document.createTextNode(" "+chapterCount)
    span.append(count)

    el.insertAdjacentElement('afterbegin',span)

    // skip styling if cssStyle is set to 'crazy'
    if (cssStyle != 'crazy') {
        let color = window.getComputedStyle(el).color
        span.setAttribute('style', 'position: absolute; margin: 0 0 0 -60px; color: '+color+' !important;')
        icon.setAttribute('style', 'margin: 0 2px 0 0;')
    }
}

function getStoryPathName() {
    let href = document.querySelectorAll('.controls-left a')
    href = href[href.length-2].href
    return href.slice(href.search(/\/story/))
}