Greasy Fork is available in English.

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.

// ==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)

        }

    })();
})();