Descarga todos los archivos del proyecto abierto de Google Apps Script
// ==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();
})();