Google Apps Script - Descargar proyecto

Descarga todos los archivos del proyecto abierto de Google Apps Script

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Google Apps Script - Descargar proyecto
// @namespace    https://archipielagovivo.org/
// @version      0.4
// @description  Descarga todos los archivos del proyecto abierto de Google Apps Script
// @author       Archipiélago Vivo
// @license      GPL-3.0-or-later
// @match        https://script.google.com/*
// @grant        unsafeWindow
// @run-at       document-start
// ==/UserScript==

(function () {
    'use strict';

    const PAGE =
        typeof unsafeWindow !== 'undefined'
            ? unsafeWindow
            : window;

    const sleep = ms =>
        new Promise(resolve => setTimeout(resolve, ms));

    /*
     * ============================================================
     * MONACO
     * ============================================================
     */

    const capturedEditors = [];

    function patchMonaco() {
        try {
            const monaco = PAGE.monaco;

            if (
                !monaco ||
                !monaco.editor ||
                typeof monaco.editor.create !== 'function'
            ) {
                return false;
            }

            if (monaco.editor.create.__avPatched) {
                return true;
            }

            const originalCreate =
                monaco.editor.create;

            function patchedCreate(...args) {

                const editor =
                    originalCreate.apply(
                        this,
                        args
                    );

                if (
                    !capturedEditors.includes(
                        editor
                    )
                ) {
                    capturedEditors.push(
                        editor
                    );

                    console.log(
                        '[AV Downloader] Editor Monaco capturado'
                    );
                }

                return editor;
            }

            patchedCreate.__avPatched =
                true;

            monaco.editor.create =
                patchedCreate;

            console.log(
                '[AV Downloader] Monaco interceptado'
            );

            return true;

        } catch (_) {
            return false;
        }
    }

    const monacoWatcher =
        setInterval(() => {

            if (patchMonaco()) {
                clearInterval(
                    monacoWatcher
                );
            }

        }, 10);


    /*
     * ============================================================
     * ARCHIVOS
     * ============================================================
     */

    function getFiles() {

        /*
         * Estructura real actual de Google Apps Script:
         *
         * [data-res-list]
         *    └─ li[role="option"][data-res-id="file_X"]
         */

        const rows = [
            ...document.querySelectorAll(
                '[data-res-list] li[role="option"][data-res-id^="file_"]'
            )
        ];

        return rows.map(row => {

            const label =
                row.querySelector(
                    '.dxw0vf[title]'
                );

            const name =
                label
                    ? label.getAttribute(
                        'title'
                    )
                    : row.getAttribute(
                        'aria-label'
                    );

            return {
                row,
                name:
                    String(
                        name || ''
                    ).trim(),
                id:
                    row.getAttribute(
                        'data-res-id'
                    ),
                index:
                    row.getAttribute(
                        'data-index'
                    )
            };

        }).filter(file =>
            file.name
        );
    }


    /*
     * ============================================================
     * EDITOR ACTUAL
     * ============================================================
     */

    function getEditor() {

        /*
         * Preferir editor visible.
         */

        for (
            const editor
            of capturedEditors
        ) {

            try {

                const dom =
                    editor.getDomNode();

                if (
                    dom &&
                    dom.offsetParent !== null &&
                    editor.getModel()
                ) {
                    return editor;
                }

            } catch (_) {}
        }


        /*
         * Fallback.
         */

        try {

            const monaco =
                PAGE.monaco;

            if (
                monaco?.editor &&
                typeof monaco.editor.getEditors ===
                    'function'
            ) {

                const list =
                    monaco.editor.getEditors();

                for (
                    const editor
                    of list
                ) {

                    const dom =
                        editor.getDomNode();

                    if (
                        dom &&
                        dom.offsetParent !== null
                    ) {
                        return editor;
                    }
                }
            }

        } catch (_) {}


        return null;
    }


    function currentState() {

        const editor =
            getEditor();

        if (!editor) {
            return null;
        }

        const model =
            editor.getModel();

        if (!model) {
            return null;
        }

        return {
            editor,
            model,
            value:
                model.getValue(),
            uri:
                model.uri
                    ? model.uri.toString()
                    : ''
        };
    }


    /*
     * ============================================================
     * SELECCIONAR ARCHIVO
     * ============================================================
     */

    function clickFile(row) {

        row.scrollIntoView({
            block: 'center',
            inline: 'nearest'
        });

        /*
         * El <li> real tiene:
         *
         * jsaction="click:o6ZaF; ..."
         *
         * Por tanto el click debe ir al propio LI.
         */

        row.dispatchEvent(
            new MouseEvent(
                'mousedown',
                {
                    bubbles: true,
                    cancelable: true,
                    composed: true,
                    button: 0
                }
            )
        );

        row.dispatchEvent(
            new MouseEvent(
                'mouseup',
                {
                    bubbles: true,
                    cancelable: true,
                    composed: true,
                    button: 0
                }
            )
        );

        row.dispatchEvent(
            new MouseEvent(
                'click',
                {
                    bubbles: true,
                    cancelable: true,
                    composed: true,
                    button: 0
                }
            )
        );

        /*
         * También llamar click() por compatibilidad.
         */
        row.click();
    }


    /*
     * ============================================================
     * ESPERAR CAMBIO REAL
     * ============================================================
     */

    async function selectFile(
        file,
        previous
    ) {

        console.log(
            '[AV Downloader] Seleccionando',
            file.name,
            file.id
        );

        clickFile(
            file.row
        );

        const start =
            Date.now();

        const timeout =
            7000;

        while (
            Date.now() - start <
            timeout
        ) {

            await sleep(100);

            const state =
                currentState();

            if (!state) {
                continue;
            }

            /*
             * Para el primer archivo podemos
             * aceptar directamente el estado.
             */

            if (!previous) {

                await sleep(250);

                return currentState();
            }


            /*
             * Lo correcto:
             * ha cambiado el modelo.
             */

            if (
                state.model !==
                previous.model
            ) {

                await sleep(250);

                return currentState();
            }


            /*
             * Algunas implementaciones reutilizan
             * modelo pero cambian URI.
             */

            if (
                state.uri &&
                state.uri !==
                    previous.uri
            ) {

                await sleep(250);

                return currentState();
            }


            /*
             * Último fallback:
             * contenido distinto.
             */

            if (
                state.value !==
                previous.value
            ) {

                await sleep(250);

                return currentState();
            }
        }

        throw new Error(
            'No se pudo confirmar el cambio al archivo: ' +
            file.name
        );
    }


    /*
     * ============================================================
     * DESCARGAS
     * ============================================================
     */

    function sanitizeFilename(name) {

        return String(name)
            .replace(
                /[<>:"/\\|?*]/g,
                '_'
            )
            .trim();
    }


    function normalizeFilename(name) {

        name =
            sanitizeFilename(name);


        /*
         * Apps Script ya muestra normalmente
         * la extensión.
         */

        if (
            /\.(gs|html|json)$/i.test(
                name
            )
        ) {
            return name;
        }


        /*
         * Manifiesto.
         */

        if (
            name.toLowerCase() ===
            'appsscript'
        ) {
            return 'appsscript.json';
        }


        return name + '.gs';
    }


    function downloadFile(
        filename,
        content
    ) {

        const blob =
            new Blob(
                [content],
                {
                    type:
                        'text/plain;charset=utf-8'
                }
            );

        const url =
            URL.createObjectURL(
                blob
            );

        const a =
            document.createElement(
                'a'
            );

        a.href =
            url;

        a.download =
            filename;

        document.body.appendChild(
            a
        );

        a.click();

        a.remove();

        setTimeout(
            () =>
                URL.revokeObjectURL(
                    url
                ),
            1000
        );
    }


    /*
     * ============================================================
     * DESCARGAR PROYECTO
     * ============================================================
     */

    async function downloadProject() {

        const button =
            document.getElementById(
                'av-download-appscript'
            );

        button.disabled =
            true;

        try {

            const files =
                getFiles();


            console.table(
                files.map(file => ({
                    name:
                        file.name,
                    id:
                        file.id,
                    index:
                        file.index
                }))
            );


            if (!files.length) {

                throw new Error(
                    'Google Apps Script está cargado, pero no se encontraron archivos dentro de [data-res-list].'
                );
            }


            console.log(
                '[AV Downloader] Archivos encontrados:',
                files.length
            );


            let previous =
                null;

            let downloaded =
                0;


            for (
                let i = 0;
                i < files.length;
                i++
            ) {

                const file =
                    files[i];


                button.textContent =
                    `${i + 1}/${files.length} · ${file.name}`;


                const state =
                    await selectFile(
                        file,
                        previous
                    );


                if (!state) {

                    throw new Error(
                        'No se pudo leer el editor para ' +
                        file.name
                    );
                }


                /*
                 * Protección importante:
                 *
                 * A partir del segundo fichero,
                 * si Google sigue devolviendo
                 * exactamente el mismo modelo,
                 * URI y contenido, abortamos.
                 */

                if (
                    previous &&
                    state.model ===
                        previous.model &&
                    state.uri ===
                        previous.uri &&
                    state.value ===
                        previous.value
                ) {

                    throw new Error(
                        'El archivo "' +
                        file.name +
                        '" sigue mostrando el contenido anterior. Descarga cancelada.'
                    );
                }


                const filename =
                    normalizeFilename(
                        file.name
                    );


                console.log(
                    '[AV Downloader] Descargando:',
                    {
                        filename,
                        resource:
                            file.id,
                        uri:
                            state.uri,
                        chars:
                            state.value.length
                    }
                );


                downloadFile(
                    filename,
                    state.value
                );


                downloaded++;

                previous =
                    state;


                await sleep(200);
            }


            button.textContent =
                `✓ ${downloaded} archivos`;


            setTimeout(() => {

                button.textContent =
                    '↓ Descargar proyecto';

                button.disabled =
                    false;

            }, 3000);


        } catch (error) {

            console.error(
                '[AV Downloader]',
                error
            );


            alert(
                'Apps Script Downloader\n\n' +
                error.message +
                '\n\n' +
                'La descarga se ha detenido para evitar copias incorrectas.'
            );


            button.textContent =
                '↓ Descargar proyecto';

            button.disabled =
                false;
        }
    }


    /*
     * ============================================================
     * UI
     * ============================================================
     */

    function createButton() {

        if (
            document.getElementById(
                'av-download-appscript'
            )
        ) {
            return;
        }


        const button =
            document.createElement(
                'button'
            );


        button.id =
            'av-download-appscript';


        button.textContent =
            '↓ Descargar proyecto';


        Object.assign(
            button.style,
            {
                position:
                    'fixed',
                right:
                    '20px',
                bottom:
                    '20px',
                zIndex:
                    '999999',
                padding:
                    '10px 16px',
                border:
                    '0',
                borderRadius:
                    '8px',
                background:
                    '#1a73e8',
                color:
                    '#fff',
                fontFamily:
                    'Arial, sans-serif',
                fontSize:
                    '14px',
                fontWeight:
                    '600',
                cursor:
                    'pointer',
                boxShadow:
                    '0 2px 8px rgba(0,0,0,.3)'
            }
        );


        button.addEventListener(
            'click',
            downloadProject
        );


        document.body.appendChild(
            button
        );
    }


    function start() {

        if (
            !document.documentElement
        ) {

            requestAnimationFrame(
                start
            );

            return;
        }


        const observer =
            new MutationObserver(
                () => {

                    if (
                        document.body
                    ) {
                        createButton();
                    }
                }
            );


        observer.observe(
            document.documentElement,
            {
                childList: true,
                subtree: true
            }
        );


        if (
            document.body
        ) {
            createButton();
        }
    }


    start();

})();