// ==UserScript==
// @name CHYOA Map Children Counter
// @version 1.0.1
// @description Counts and displays how many chapters each branch has. Has commands on the menu
// @author sllypper
// @namespace https://greasyfork.org/en/users/55535-sllypper
// @homepage https://greasyfork.org/en/users/55535-sllypper
// @match *://chyoa.com/story/*/map*
// @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 commands on the Right-Click menu or clicking on the thingie you used to load the script
* The script only counts and draws the currently loaded chapters.
* If the story has more chapters, the user needs to Redraw or Regenrate for the chapters loaded afterwards.
* Move to the bottom of the page to load more chapters.
What's happening:
* When you load a map page, the script attempts to retrieve the saved map data from cache.
* If it doesn't find the data, it generates and stores in the cache.
* Then it draw the counters on the screen.
*/
// runs automatically when opening a new map or not
let runAutomatically = true
// shows the floating button on the bottom-right of the page
let showFloatingButton = true
/************************/
GM_registerMenuCommand("Regenerate", generateDraw, "r");
GM_registerMenuCommand("Redraw counters from cache", drawChildrenCounter, "d");
GM_registerMenuCommand("Delete cache", deleteCache, "c");
GM_registerMenuCommand("Delete all cache", deleteAllCache, "a");
/************************/
// exposing storyMap for the user to play with the data
window.storyMap;
if (!attemptLoadCache()) {
// no map data on cache
if (runAutomatically) {
generateDraw()
}
} else {
// got map data from cache
drawChildrenCounter()
}
if (showFloatingButton) { document.body.appendChild(btnLoad()) }
addStyle(".title-wrapper > .children { padding-right: 8px; }")
/************************/
function generateDraw() {
generateMap()
drawChildrenCounter()
}
function createChildrenElement(childrenCount) {
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(" "+childrenCount)
child.appendChild(count)
return child
}
function addStyle(css) {
var style = document.createElement('style');
document.head.appendChild(style);
style.textContent = css;
};
function attemptLoadCache() {
const cachedMap = GM_getValue(window.location.pathname)
if (cachedMap) {
console.log("!! got map from cache");
window.storyMap = JSON.parse(cachedMap);
// console.log(window.storyMap[0])
return true
}
console.log("!! map not found in cache");
return false
}
function deleteCache() {
GM_deleteValue(window.location.pathname)
}
function deleteAllCache() {
GM_listValues().forEach((value) => {
GM_deleteValue(value)
})
}
function readOrGenerateMap() {
if (! attemptLoadCache()) generateMap()
// const entries = exportAllLevel1Chapters();
return
}
function generateMap() {
console.log("!! generating map");
window.storyMap = getParsedList()
console.log("storyMap length = ", window.storyMap.length);
console.log("!! counting children");
countMapChildren();
console.log("!! done counting children");
// cache it
GM_setValue(window.location.pathname, JSON.stringify(window.storyMap))
console.log("Map available as a javascript object on `window.storyMap`")
}
function drawChildrenCounter() {
let list = document.querySelectorAll(".title-wrapper");
// let list = document.getElementsByClassName("story-map-content");
// if (list == null || !list) return;
// list = Array.from(list[0].children);
list.forEach((elem, i) => {
let existingChildren = elem.querySelector('.children')
if (existingChildren) { existingChildren.remove() }
let child = createChildrenElement(window.storyMap[i].childrenCount)
let page = elem.querySelector('.page')
elem.insertBefore(child, page)
// let title = elem.querySelector('.title')
// elem.insertBefore(child, title)
// let controls = elem.querySelector('.controls-wrapper')
// controls.appendChild(child)
})
}
/**
* counts and assigns the children count of all elements
*/
function countMapChildren() {
for (const node of window.storyMap) {
if (node.children.length !== 0) continue;
node.childrenCount = 0;
let next = node.parent != null ? node.parent : -1;
// rise through the leaf until it can't
while (next != -1) {
//console.log("from", node.id, "to", next);
next = countChildNodesOf(next);
}
// then continue to the next node
}
// done
}
/**
* Counts and assigns the childrenCount of the node at that index
* Aborts returning -1 if it already has been counted
* or can't because one of its children doesn't have their childrenCount
*
* @param {number} currIndex - The index of the node
* @returns {number} The index of the next node. -1 to abort
*/
function countChildNodesOf(currIndex) {
let nextIndex = -1;
const currentNode = window.storyMap[currIndex];
if (currentNode.childrenCount != undefined) {
// this node was already been processed
// abort
return nextIndex;
}
// sum the counts of its children
const childrenCountSum = getChildrenCountSum(currentNode);
if (childrenCountSum === -1) {
// one or more children haven't been processed yet
// abort
return nextIndex;
}
// on successful sum
currentNode.childrenCount = childrenCountSum + currentNode.children.length;
nextIndex = currentNode.parent !== null ? currentNode.parent : -1;
return nextIndex;
}
/**
* Sums the childrenCount of all children of the attribute node
*/
function getChildrenCountSum(node) {
return node.children.reduce((acc, curr) => {
const currentNode = window.storyMap[curr];
if (currentNode.childrenCount == undefined) {
return -1;
}
return acc !== -1 ? acc + currentNode.childrenCount : acc;
}, 0);
}
// gets html dom element
// return parsed element as an object
function parseEl(el, id) {
const pel = {};
pel.id = parseInt(id);
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 : "";
// is always wrong for loaded chapters. It resets
pel.page = el.querySelector(".page").textContent;
// Sometimes the date is empty, but there's no issue here
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 [ parsedElement(), (...) ]
// where every element has its parent and children noted
function getParsedList() {
// 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 prevParentI = -1;
const finalList = [];
for (const i in list) {
// console.log(" Processing Chapter", i);
const el = list[i];
// parse el and add it to the final list
const parsedEl = parseEl(el, i);
finalList[i] = parsedEl;
// console.log(parsedEl)
// 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;
}
// check margins
const currElMargin = parsedEl.margin
const prevElMargin = finalList[i-1].margin
// check if el is child of prev el
if (prevElMargin < currElMargin) {
// prev el is parent
parsedEl.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;
}
// check if el is sibling of prev el
if (prevElMargin == currElMargin) {
// they share the same parent
// prevParent is parent
parsedEl.parent = prevParentI;
// add this el as child of prevParent
finalList[prevParentI].children.push(i);
continue;
}
// then el must be the "uncle" of prev el
// prevElMargin > currElMargin
/*
ALTERNATIVE METHOD
*/
// use a loop go back through the parents from the previous node
// to find the first element with margin smaller than self
const selfMargin = parsedEl.margin;
for (let j = i - 1; j >= 0; j = finalList[j].parent) {
if (finalList[j].margin < selfMargin) {
// found the parent: j
const actualParentI = j;
parsedEl.parent = actualParentI;
// add this el as child of actual parent
finalList[actualParentI].children.push(parsedEl.id);
// set prev parent to actual parent
prevParentI = actualParentI;
break;
}
}
continue;
/*
END OF ALTERNATIVE METHOD
*/
// deleted old method of searching parent when uncle
}
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) {
let stack = [],
key;
for (key in obj) {
if (obj.hasOwnProperty(key)) {
stack.push(key + ':' + obj[key]);
}
}
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());
}