Ctrl+Paint: Add missing Next and Previous buttons

There are no navigation buttons for some video pages of tutorial series. This script adds missing buttons like Next or Previous.

Verze ze dne 20. 11. 2020. Zobrazit nejnovější verzi.

// ==UserScript==
// @name Ctrl+Paint: Add missing Next and Previous buttons
// @namespace https://github.com/T1mL3arn
// @description There are no navigation buttons for some video pages of tutorial series. This script adds missing buttons like Next or Previous. 
// @author T1mL3arn
// @version 1.2
// @icon https://static1.squarespace.com/static/50a3c190e4b0d12fc9231429/t/50f87f8ce4b0b3f0a2deeb1d/1537054440579/
// @match https://www.ctrlpaint.com/library* 
// @match https://www.ctrlpaint.com/videos/* 
// @run-at document-end
// @noframes
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_listValues
// @grant GM.setValue
// @grant GM.getValue
// @grant GM.deleteValue
// @grant GM.listValues
// @license GPLv3 
// @homepageURL https://github.com/t1ml3arn-userscript-js/Ctrl-Paint-Add-missing-Next-and-Previous-buttons
// @supportURL https://github.com/t1ml3arn-userscript-js/Ctrl-Paint-Add-missing-Next-and-Previous-buttons/issues
// ==/UserScript==

(() => {
    // lib section
    let log = console.log;
    let err = console.error;
    function findHeader(elt){
        let prev = elt;
        while(prev = prev.previousSibling)
            if(prev.tagName == 'H3')
                return prev;
        return null;
    }
    function mapsListItemsToNames(list){
        let items = list.querySelectorAll('li a');
        let result = [];
        items.forEach((item)=>result.push(item.textContent));
        return result;
    }
    function mapListItemsToLinks(list){
        let items = list.querySelectorAll('li a');
        let result = [];
        items.forEach((item)=>result.push(item.href));
        return result;
    }
    function readSeriesFrom(document){
        let lists = document.querySelectorAll('ol, ul');
        let results = [];

        lists.forEach((list)=>{

            // skip empty lists
            if(list.children.length == 0)
                return;
            
            let header = findHeader(list);
            if(header){
                let links = mapListItemsToLinks(list);
                if(links.length > 0){
                    let names = mapsListItemsToNames(list);

                    // just to be sure
                    if(names.length != links.length){
                        throw `Count of links isn\'t equal to count of names\nProblem in ${header.textContent}`;
                    }

                    results.push({
                        name: header.textContent,
                        videoNames: names,
                        videoLinks: links
                    });
                }
            }
        });
        
        if(results.length == 0)
            throw 'There are no tutorial series at all!';

        return results;
    }
    async function findTutorialSeriesDataForCurrentPage() {
        const data = await gm.getValue(TUTORIAL_SERIES_KEY, null)
        if (!data)  throw "Cannot find data for tutorial series"
        
        let path = window.location.pathname;
        let videoIndex;
        let index = data.findIndex((seriesData)=>{
            videoIndex = seriesData.videoLinks.findIndex((link)=>path != "/" && link.indexOf(path) != -1)
            return videoIndex != -1;
        });

        if(index == -1)
            return null;
        
        let seriesData = data[index];
        seriesData.currentVideoIndex = videoIndex;
        return seriesData;
    }
    function addButtons(seriesData) {
        
        function getButtonHtml(href, label, name) {

            return `<div class="button-block sqs-block-button" data-block-type="53" style="${btnCss}">
            <div class="sqs-block-content">
                <div class="sqs-block-button-container--center" data-alignment="center" data-button-size="small">
                    <a href="${href}" class="sqs-block-button-element--small sqs-block-button-element" data-initialized="true">
                    ${label}
                    ${name ? 
                    `<br>
                    <span style="${videoNameCss}">${name}</span>` : ''
                    }
                    </a>
                </div>
            </div>
            </div>`
        }
        function arrayToCss(acc, val, ind){
            return ind%2 == 0 ? `${acc}${val}: ` : `${acc}${val} !important; `;
        }

        let btnCss = ["flex", "0 1 auto", "align-self", "auto", "margin", "10px"].reduce(arrayToCss, '');
        let videoNameCss = "font-size, 11px, text-transform, none, color, #DDD".split(", ").reduce(arrayToCss);
        let btnContCss = [
            "display", "flex",
            "flex-direction", "row",
            "flex-wrap", "wrap",
            "justify-content", "space-around",
            "align-content", "center",
            "align-items", "center",
            "padding", "15px",
        ].reduce(arrayToCss, '');

        let buttonsWrapper = document.createElement('div');
        buttonsWrapper.setAttribute('style', btnContCss);

        let videoBlock = document.querySelector('.sqs-block.embed-block.sqs-block-embed');
        if(videoBlock == null)
            throw 'There is no video block';

        videoBlock.insertAdjacentElement('afterend', buttonsWrapper);
        
        let names = seriesData.videoNames;
        let links = seriesData.videoLinks;
        let index = seriesData.currentVideoIndex;

        let nextHtml = index+1 < names.length ? getButtonHtml(links[index+1], 'NEXT', names[index+1]) : '';
        let prevHtml = index-1 > -1 ? getButtonHtml(links[index-1], 'PREVIOUS', names[index-1]) : '';

        buttonsWrapper.insertAdjacentHTML('beforeend', prevHtml);
        buttonsWrapper.insertAdjacentHTML('beforeend', nextHtml);
    }
    function patchSeriesData(seriesDataList) {
        
        try {
            let data = seriesDataList.find((seriesData) => seriesData.name.indexOf('Painting With Color') != -1);
            let index = data.videoNames.indexOf('Color Constructor Pt.2 Exercises');
            data.videoLinks[index] = 'https://www.ctrlpaint.com/videos/color-constructor-pt2-exercises';
        } catch (e) {
            err('Patch-1 error', e);
        }
        
        // remove the first series cause it has next/prev buttons
        index = seriesDataList.findIndex((seriesData) => seriesData.name.indexOf('Digital Painting 101') != -1);
        if(index != -1)
            seriesDataList.splice(index, 1);
        else
            throw 'Patch-2 error';
        
        return seriesDataList;
    }    

    const TUTORIAL_SERIES_KEY = 'tutorial_series_key';
    const ETAG_KEY = "etag"
    
    let global = this;
    let gm = {};

    try {

        if(typeof GM != 'undefined')
            gm = GM;
        else {
            gm = {};
            gm.info = GM_info;
            
            Object.entries({
                'GM_addStyle': 'addStyle',
                'GM_deleteValue': 'deleteValue',
                'GM_getResourceURL': 'getResourceUrl',
                'GM_getValue': 'getValue',
                'GM_listValues': 'listValues',
                'GM_notification': 'notification',
                'GM_openInTab': 'openInTab',
                'GM_registerMenuCommand': 'registerMenuCommand',
                'GM_setClipboard': 'setClipboard',
                'GM_setValue': 'setValue',
                'GM_xmlhttpRequest': 'xmlHttpRequest',
                'GM_getResourceText': 'getResourceText',
            }).forEach(([oldKey, newKey]) => {
            let old = global[oldKey] || window[oldKey];
            if(old && (typeof gm[newKey] == 'undefined')){
                gm[newKey] = function(...args){
                    return new Promise((resolve, reject) => {
                        try { resolve(old.apply(global, args)) } 
                        catch(e) { reject(e) }
                    });
                  }
                }
            });
        }

        log(`
        [ ${gm.info.script.name} ] inited
        Script handler is ${gm.info.scriptHandler}
        `);

    } catch (e) {
        log('ctrlpaint+ inited partialy. Something went wrong.');
    }

    // structure sample
    let series ={
        name: 'First Steps',                /* Name of chapter */
        videoNames: ['welcome', 'tut01'],   /* Names of all videos in this chapter  */
        videoLinks: ['#', '#'],             /* Links to each video in this chapter */
        currentVideoIndex: -1               /* Uses to find previous or next video in this chapter */
    };

    async function fetchLibraryPage() {

        // with this Cache-Control header I get cache hits
        const url = 'https://www.ctrlpaint.com/library'
        let response = await fetch(url, {headers: {"Cache-Control": "max-age=0"} });
        if(!response.ok) throw `Cannot fetch library page at ${url}`;

        return response
    }

    async function parseAndStoreTutorialSeriesData(response) {
        let pageText = await response.text();
        let libraryDocument = new DOMParser().parseFromString(pageText, 'text/html');

        let tutorialSeries = readSeriesFrom(libraryDocument);
        tutorialSeries = patchSeriesData(tutorialSeries);

        await gm.setValue(TUTORIAL_SERIES_KEY, tutorialSeries);
        await gm.setValue(ETAG_KEY, response.headers.get('ETag'))
    }

    (async ()=>{
        lastTutorialData = await gm.getValue(TUTORIAL_SERIES_KEY, null);
        lastETag = await gm.getValue(ETAG_KEY, null)

        // update all - it is the very first time or the previous version of the script
        if (lastTutorialData === null || lastETag === null) {

            const response = await fetchLibraryPage()
            await parseAndStoreTutorialSeriesData(response)

        } else if (lastETag !== null) {
            // ETAG is set, so lets check it

            const response = await fetchLibraryPage()
            const currentETag = response.headers.get('etag')

            // new etag, so I need to parse data again
            if (lastETag !== currentETag)
                await parseAndStoreTutorialSeriesData(response)
                
        }

        // check if current page is a VIDEO page
        if (window.location.pathname.startsWith("/videos/")) {

            let seriesData = await findTutorialSeriesDataForCurrentPage();
            if(seriesData != null) addButtons(seriesData)

        }

    })();
})();