El Problema y La Solución
¿Alguna vez has tenido que combinar diez, veinte o más documentos de Google en un solo archivo maestro? Copiar y pegar manualmente no solo es tedioso, sino que también es propenso a errores de formato y omisiones. Es un trabajo que nadie quiere hacer.
La buena noticia es que puedes automatizar todo este proceso con unas pocas líneas de código usando Google Apps Script.
Este tutorial te guiará paso a paso para crear una herramienta personalizada que añade un menú a tu Google Sheets (o Google Docs). Con un clic, abrirás una interfaz sencilla para fusionar documentos perfectamente, todo en segundos.
¿Qué Construiremos?
Construiremos un panel de control simple que:
- Se abre desde un menú personalizado en Google Sheets o Docs.
- Te pide el ID de una carpeta de Google Drive.
- Lista todos los Google Docs dentro de esa carpeta.
- Te permite seleccionar y ordenar los archivos que deseas fusionar.
- Crea un nuevo Google Doc con el contenido de todos los archivos seleccionados, en el orden que especificaste.
Paso 1: Configurar el Entorno de Apps Script
Primero, decide dónde vivirá tu script. Funciona perfectamente tanto en Google Sheets como en Google Docs.
- Abre una nueva Hoja de Cálculo de Google (o un Documento de Google).
- En el menú superior, ve a Extensiones > Apps Script.
- Esto abrirá una nueva pestaña con el editor de scripts de Google. Borra cualquier código de ejemplo que aparezca en el archivo
Code.gs.
Paso 2: El Código del Servidor (Code.gs)
Este es el cerebro de nuestra operación. Maneja la creación del menú, la búsqueda de archivos en Drive y el proceso de copia de contenido. Copia todo el código a continuación y pégalo en tu archivo Code.gs.
Código Completo de Code.gs
/**
* Se ejecuta CUANDO SE ABRE el archivo (Hoja de Cálculo o Documento).
* NO EJECUTAR MANUALMENTE DESDE EL EDITOR DE SCRIPTS.
* Agrega un menú personalizado para iniciar la interfaz de fusión.
*/
function onOpen() {
try {
// Intenta obtener la UI de la aplicación activa (Sheets o Docs)
let ui;
if (typeof SpreadsheetApp !== 'undefined' && SpreadsheetApp.getActiveSpreadsheet()) {
ui = SpreadsheetApp.getUi();
} else if (typeof DocumentApp !== 'undefined' && DocumentApp.getActiveDocument()) {
ui = DocumentApp.getUi();
} else {
Logger.log("No se pudo crear el menú. El script debe ejecutarse desde una Hoja de Cálculo o Documento.");
return;
}
// Crea el menú
ui.createMenu('Fusionar Documentos')
.addItem('Abrir Interfaz de Fusión', 'mostrarInterfazFusionSimplificada')
.addToUi();
} catch (e) {
Logger.log("Error en onOpen: " + e.toString());
}
}
/**
* Muestra la interfaz HTML (nuestro panel de control) como un diálogo modal.
*/
function mostrarInterfazFusionSimplificada() {
// Llama al archivo HTML que crearemos en el siguiente paso
const html = HtmlService.createHtmlOutputFromFile('Picker_Simple')
.setTitle('Fusionar Documentos de Drive')
.setWidth(700)
.setHeight(580);
try {
// Muestra el diálogo en la aplicación activa
if (typeof SpreadsheetApp !== 'undefined' && SpreadsheetApp.getActiveSpreadsheet()) {
SpreadsheetApp.getUi().showModalDialog(html, 'Fusionar Documentos de Google Drive');
} else if (typeof DocumentApp !== 'undefined' && DocumentApp.getActiveDocument()) {
DocumentApp.getUi().showModalDialog(html, 'Fusionar Documentos de Google Drive');
} else {
Logger.log("No se pudo mostrar la interfaz. No hay una aplicación activa.");
}
} catch (e) {
Logger.log("Error al mostrar la interfaz: " + e.toString());
}
}
/**
* Obtiene los archivos de Google Docs de una carpeta específica por su ID.
* Esta función es llamada desde la interfaz HTML.
*/
function getFilesInFolder(folderId) {
try {
if (!folderId || folderId.trim() === "") {
return { error: "Por favor, proporciona un ID de carpeta." };
}
const folder = DriveApp.getFolderById(folderId.trim());
const files = folder.getFilesByType(MimeType.GOOGLE_DOCS);
const fileList = [];
// Recorre los archivos y los añade a una lista
while (files.hasNext()) {
const file = files.next();
fileList.push({ id: file.getId(), name: file.getName() });
}
// Ordena los archivos alfabéticamente por nombre
fileList.sort((a, b) => a.name.localeCompare(b.name));
return { files: fileList, folderName: folder.getName() };
} catch (e) {
Logger.log("Error en getFilesInFolder: folderId='" + folderId + "', Error: " + e.toString());
// Manejo de errores comunes
if (e.message.includes("Argumento no válido: id") || e.message.includes("Not Found")) {
return { error: "Error: El ID de la carpeta ('" + folderId + "') no es válido, no existe o no tienes acceso. Verifica el ID." };
} else if (e.message.includes("no tienes permiso")) {
return { error: "Error: No tienes permiso para acceder a la carpeta con ID '" + folderId + "'." };
}
return { error: "Error al obtener archivos: " + e.message };
}
}
/**
* La función principal de fusión.
* Recibe la lista de IDs de archivos (en orden), el nombre del nuevo documento
* y un ID de carpeta de destino opcional.
*/
function mergeSelectedDocs(selectedFileIds, newDocName, targetFolderId) {
try {
if (!selectedFileIds || selectedFileIds.length === 0) {
return { error: "No se seleccionaron archivos para fusionar." };
}
// Asigna un nombre por defecto si no se proporciona uno
if (!newDocName || newDocName.trim() === "") {
const now = new Date();
const formattedDate = now.getFullYear() + "-" +
("0" + (now.getMonth() + 1)).slice(-2) + "-" +
("0" + now.getDate()).slice(-2) + "_" +
("0" + now.getHours()).slice(-2) + "-" +
("0" + now.getMinutes()).slice(-2);
newDocName = "Documento Fusionado - " + formattedDate;
}
// Crea el nuevo documento
const newDoc = DocumentApp.create(newDocName);
const newDocBody = newDoc.getBody();
let outputFolder;
// Define dónde se guardará el nuevo archivo
if (targetFolderId && targetFolderId.trim() !== "") {
try {
outputFolder = DriveApp.getFolderById(targetFolderId.trim());
} catch (e) {
Logger.log("ID de carpeta destino inválido: " + targetFolderId + ". Error: " + e.message);
return { error: "La carpeta destino con ID '" + targetFolderId + "' no es válida. El documento no fue creado."};
}
} else {
// Por defecto, se guarda en la raíz de "Mi unidad"
outputFolder = DriveApp.getRootFolder();
}
// El bucle de fusión
selectedFileIds.forEach((fileId, index) => {
if (!fileId) {
Logger.log("Se omitió un ID de archivo nulo en el índice: " + index);
return;
}
try {
const sourceDoc = DocumentApp.openById(fileId);
const sourceBody = sourceDoc.getBody();
const totalElements = sourceBody.getNumChildren();
// Copia cada elemento (párrafo, tabla, lista) del documento fuente al nuevo
for (let i = 0; i < totalElements; i++) {
const element = sourceBody.getChild(i).copy();
const type = element.getType();
if (type === DocumentApp.ElementType.PARAGRAPH) {
newDocBody.appendParagraph(element.asParagraph());
} else if (type === DocumentApp.ElementType.TABLE) {
newDocBody.appendTable(element.asTable());
} else if (type === DocumentApp.ElementType.LIST_ITEM) {
newDocBody.appendListItem(element.asListItem());
} else if (type === DocumentApp.ElementType.INLINE_IMAGE) {
newDocBody.appendParagraph(element.asParagraph());
} else if (type === DocumentApp.ElementType.HORIZONTAL_RULE) {
newDocBody.appendHorizontalRule();
} else {
// Intento genérico para otros tipos
try {
if (typeof element.asParagraph === 'function') {
newDocBody.appendParagraph(element.asParagraph());
}
} catch(copyError) {
Logger.log("Error al copiar elemento: " + copyError.message);
}
}
}
} catch (docError) {
// Si un documento falla, añade una nota en el archivo fusionado y continúa
const fileName = DriveApp.getFileById(fileId).getName();
Logger.log("Error al procesar el documento '" + fileName + "': " + docError.message);
newDocBody.appendParagraph("--- Error: No se pudo fusionar el contenido del documento: " + fileName + " ---").editAsText().setItalic(true).setForegroundColor("#CC0000");
}
// Añade un salto de página entre documentos (excepto después del último)
if (index < selectedFileIds.length - 1) {
newDocBody.appendPageBreak();
}
}); // Fin del bucle forEach
newDoc.saveAndClose();
const newFileInDrive = DriveApp.getFileById(newDoc.getId());
// Mueve el archivo a la carpeta de destino
if (outputFolder.getId() !== DriveApp.getRootFolder().getId()) {
newFileInDrive.moveTo(outputFolder);
}
return {
success: "¡Documentos fusionados exitosamente!",
docUrl: newDoc.getUrl(),
folderName: outputFolder.getName()
};
} catch (e) {
Logger.log("Error en mergeSelectedDocs: " + e.toString() + " Stack: " + e.stack);
// Si la fusión falla, intenta borrar el archivo vacío que se creó
if (typeof newDoc !== 'undefined' && newDoc && newDoc.getId()) {
try {
DriveApp.getFileById(newDoc.getId()).setTrashed(true);
Logger.log("Documento parcialmente creado enviado a la papelera.");
} catch (trashError) {
Logger.log("Error al enviar a la papelera el documento fallido: " + trashError.toString());
}
}
return { error: "Error al fusionar documentos: " + e.message };
}
}
Análisis del Código
Haz clic en una función para entender qué hace.
Función: onOpen()
Esta es una función especial de Apps Script. Se ejecuta automáticamente cada vez que un usuario abre la Hoja de Cálculo o el Documento.
Su único trabajo es detectar qué aplicación se está usando (Sheets o Docs) y luego usar la .getUi() para añadir un nuevo menú personalizado llamado "Fusionar Documentos".
Función: mostrarInterfazFusionSimplificada()
Esta función es llamada cuando el usuario hace clic en el botón del menú que creamos con onOpen().
Usa HtmlService.createHtmlOutputFromFile('Picker_Simple') para cargar el contenido de nuestro archivo HTML, le da un título y un tamaño, y finalmente lo muestra como un diálogo modal (una ventana emergente) sobre la aplicación.
Función: getFilesInFolder(folderId)
Esta es la primera función que llamamos desde nuestra interfaz HTML. Recibe el folderId que el usuario pegó.
Utiliza DriveApp.getFolderById() para encontrar la carpeta, .getFilesByType(MimeType.GOOGLE_DOCS) para filtrar solo los Google Docs, y crea una lista de objetos (con id y name) de esos archivos.
Finalmente, ordena la lista alfabéticamente y la devuelve a la interfaz HTML.
Función: mergeSelectedDocs(...)
Esta es la función principal. Recibe la lista de IDs de archivos (en el orden correcto), el nombre del nuevo documento y un ID de carpeta de destino opcional.
Pasos Clave:
- Crea un nuevo documento con
DocumentApp.create(newDocName). - Determina la carpeta de destino (la raíz de "Mi unidad" o la especificada).
- Recorre (
forEach) cadafileIden la lista. - Para cada ID, abre el documento fuente (
DocumentApp.openById()). - Recorre (
for) cada elemento (párrafo, tabla, etc.) en el cuerpo del documento fuente. - Copia (
.copy()) cada elemento y lo añade (.append...()) al final del nuevo documento. - Añade un
.appendPageBreak()entre documentos. - Guarda el nuevo documento y lo mueve a la carpeta de destino.
- Devuelve un objeto de éxito con la URL del nuevo archivo.
Paso 3: La Interfaz de Usuario (Picker_Simple.html)
Este archivo contiene todo el HTML (estructura), CSS (estilos) y JavaScript (interactividad) para la ventana emergente. Crea un nuevo archivo HTML en el editor de Apps Script (Archivo > Nuevo > Archivo HTML) y nómbralo exactamente Picker_Simple. Pega el siguiente código.
Código Completo de Picker_Simple.html
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<style>
/* Estilos básicos para una interfaz limpia y moderna */
body {
font-family: 'Segoe UI', Roboto, Arial, sans-serif;
margin: 0;
padding: 20px;
background-color: #f4f7f6;
color: #333;
font-size: 14px;
}
.container {
background-color: #fff;
padding: 20px 30px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
h2 {
color: #2077D0;
text-align: center;
margin-top: 0;
margin-bottom: 25px;
font-weight: 500;
}
label {
display: block;
margin-top: 12px;
margin-bottom: 6px;
font-weight: 500;
color: #555;
}
input[type="text"] {
width: calc(100% - 22px);
padding: 10px;
margin-bottom: 12px;
border: 1px solid #ddd;
border-radius: 5px;
box-sizing: border-box;
transition: border-color 0.3s ease;
}
input[type="text"]:focus {
border-color: #2077D0;
outline: none;
}
button {
background-color: #2077D0;
color: white;
padding: 10px 18px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 15px;
margin-top: 10px;
margin-bottom: 10px;
transition: background-color 0.2s ease, box-shadow 0.2s ease;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
button:hover {
background-color: #1a63ab;
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
button:disabled {
background-color: #bcc1c5;
cursor: not-allowed;
box-shadow: none;
}
/* Área de entrada de la carpeta */
.folder-input-area {
margin-bottom: 20px;
padding: 15px;
background-color: #e9f2fa;
border-radius: 5px;
border: 1px solid #c5d9ea;
}
.folder-input-area p {
margin-top: 0;
font-size: 0.9em;
color: #31708f;
line-height: 1.4;
}
#currentFolderNameDisplay {
font-weight: bold;
color: #1a63ab;
margin-top: 5px;
display: block;
}
/* Columnas para las listas de archivos */
.columns { display: flex; flex-wrap: wrap; gap: 20px; margin-top: 20px; }
.column { flex: 1; min-width: 250px; background-color: #f9f9f9; padding: 15px; border-radius: 5px; border: 1px solid #eee; min-height: 150px; }
.column h3 { margin-top: 0; font-size: 16px; color: #333; border-bottom: 1px solid #eee; padding-bottom: 8px; }
/* Estilos de los elementos de archivo */
.file-item, .selected-file-item { padding: 8px 10px; border-bottom: 1px solid #eaeaea; display: flex; align-items: center; justify-content: space-between; font-size: 13px; background-color: #fff; margin-bottom: 5px; border-radius: 4px; word-break: break-all; }
.file-item:last-child, .selected-file-item:last-child { border-bottom: none; }
.file-item button, .selected-file-item button { padding: 4px 8px; font-size: 12px; margin-left: 5px; background-color: #6c757d; min-width: 30px; }
.file-item button { background-color: #28a745; }
.selected-file-item .order-buttons button { background-color: #5A9BD5; }
.selected-file-item .remove-button { background-color: #dc3545; }
.file-item button:hover, .selected-file-item button:hover { opacity: 0.85; }
/* Mensajes de estado (éxito, error, info) */
#status { margin-top: 20px; padding: 12px; border-radius: 5px; font-size: 14px; line-height: 1.5; display: none; }
.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; display: block; }
.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; display: block; }
.info { background-color: #e2f3fe; color: #0c5460; border: 1px solid #bee5eb; display: block; }
/* Indicador de carga */
.loader { border: 4px solid #f3f3f3; border-top: 4px solid #2077D0; border-radius: 50%; width: 24px; height: 24px; animation: spin 1s linear infinite; display: none; margin: 15px auto; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
.file-list { max-height: 200px; overflow-y: auto; padding-right: 5px; }
</style>
</head>
<body>
<div class="container">
<h2>Fusionar Documentos de Google Drive</h2>
<div class="folder-input-area">
<label for="folderIdInput">ID de la Carpeta de Drive de Origen:</label>
<p>Abre la carpeta en Google Drive. El ID es la parte final de la URL. <br>Ej: Si la URL es <code>.../folders/ABC123XYZ</code>, el ID es <code>ABC123XYZ</code>.</p>
<input type="text" id="folderIdInput" placeholder="Pega el ID de la carpeta aquí">
<button onclick="listFilesFromFolder()" id="listFolderButton">Listar Archivos de Carpeta</button>
<span id="currentFolderNameDisplay">Carpeta no especificada.</span>
</div>
<div class="columns">
<div class="column">
<h3>Archivos Disponibles (ordenados alfabéticamente)</h3>
<div id="availableFilesList" class="file-list">
<p class="info" style="margin:0;">Ingresa un ID de carpeta y haz clic en "Listar Archivos...".</p>
</div>
</div>
<div class="column">
<h3>Archivos Seleccionados (en orden de fusión)</h3>
<div id="selectedFilesList" class="file-list">
<p class="info" style="margin:0;">Añade archivos desde la lista de disponibles.</p>
</div>
</div>
</div>
<label for="newDocName">Nombre del Documento Fusionado:</label>
<input type="text" id="newDocName" placeholder="Ej: Informe Consolidado (Automático si se deja vacío)">
<label for="targetFolderIdInput">ID Carpeta Destino (opcional, si no se especifica se guarda en la raíz):</label>
<input type="text" id="targetFolderIdInput" placeholder="Pega el ID de la carpeta destino aquí">
<button onclick="processMerge()" id="mergeButton">Fusionar Documentos</button>
<div class="loader" id="loader"></div>
<div id="status"></div>
</div>
<script>
// Almacena el estado de los archivos
let allAvailableFiles = [];
let orderedSelectedFiles = [];
document.getElementById('mergeButton').disabled = true;
// Muestra u oculta el indicador de carga y deshabilita botones
function showLoader(isLoading) {
document.getElementById('loader').style.display = isLoading ? 'block' : 'none';
document.getElementById('listFolderButton').disabled = isLoading;
document.getElementById('mergeButton').disabled = isLoading || orderedSelectedFiles.length === 0;
}
// Muestra un mensaje de estado (éxito, error, info)
function updateStatus(message, type, isHtml = false) {
const statusDiv = document.getElementById('status');
if (isHtml) {
statusDiv.innerHTML = message;
} else {
statusDiv.textContent = message;
}
statusDiv.className = type;
}
// Llama a la función getFilesInFolder de Code.gs
function listFilesFromFolder() {
const folderId = document.getElementById('folderIdInput').value.trim();
if (!folderId) {
updateStatus("Por favor, ingresa un ID de carpeta.", "error");
document.getElementById('currentFolderNameDisplay').textContent = "ID de carpeta no proporcionado.";
return;
}
showLoader(true);
updateStatus("Listando archivos de la carpeta: " + folderId + "...", "info");
document.getElementById('currentFolderNameDisplay').textContent = "Cargando...";
allAvailableFiles = [];
orderedSelectedFiles = [];
renderAvailableFiles(); // Limpia las listas
renderSelectedFiles();
google.script.run
.withSuccessHandler(response => {
showLoader(false);
if (response.error) {
updateStatus("Error al listar archivos: " + response.error, "error");
document.getElementById('currentFolderNameDisplay').textContent = "Error al cargar carpeta.";
return;
}
allAvailableFiles = response.files;
document.getElementById('currentFolderNameDisplay').textContent = `Carpeta: ${escapeHtml(response.folderName || folderId)}`;
if (allAvailableFiles.length === 0) {
updateStatus("No se encontraron Documentos de Google en esta carpeta.", "info");
} else {
updateStatus(`Archivos listados de '${escapeHtml(response.folderName || folderId)}'. Añade a la lista de seleccionados.`, "success");
}
renderAvailableFiles();
})
.withFailureHandler(error => {
showLoader(false);
updateStatus("Error de comunicación al listar archivos: " + error.message, "error");
document.getElementById('currentFolderNameDisplay').textContent = "Error de comunicación.";
})
.getFilesInFolder(folderId);
}
// Dibuja la lista de archivos disponibles
function renderAvailableFiles() {
const listDiv = document.getElementById('availableFilesList');
listDiv.innerHTML = '';
const folderId = document.getElementById('folderIdInput').value;
if (!folderId && allAvailableFiles.length === 0) {
listDiv.innerHTML = '<p class="info" style="margin:0;">Ingresa un ID de carpeta y haz clic en "Listar Archivos...".</p>';
return;
}
if (folderId && allAvailableFiles.length === 0) {
listDiv.innerHTML = '<p class="info" style="margin:0;">No se encontraron documentos o la carpeta está vacía/no accesible.</p>';
return;
}
allAvailableFiles.forEach(file => {
if (!orderedSelectedFiles.some(sf => sf.id === file.id)) {
const item = document.createElement('div');
item.className = 'file-item';
item.innerHTML = `
<span>${escapeHtml(file.name)}</span>
<button onclick="addFileToSelected('${file.id}')" title="Añadir ${escapeHtml(file.name)} a la selección">→</button>
`;
listDiv.appendChild(item);
}
});
}
// Dibuja la lista de archivos seleccionados
function renderSelectedFiles() {
const listDiv = document.getElementById('selectedFilesList');
listDiv.innerHTML = '';
if (orderedSelectedFiles.length === 0) {
listDiv.innerHTML = '<p class="info" style="margin:0;">Añade archivos desde la lista de disponibles.</p>';
document.getElementById('mergeButton').disabled = true;
return;
}
document.getElementById('mergeButton').disabled = false;
orderedSelectedFiles.forEach((file, index) => {
const item = document.createElement('div');
item.className = 'selected-file-item';
item.innerHTML = `
<span>${index + 1}. ${escapeHtml(file.name)}</span>
<span class="order-buttons">
<button onclick="moveFile(${index}, -1)" ${index === 0 ? 'disabled' : ''} title="Mover Arriba">↑</button>
<button onclick="moveFile(${index}, 1)" ${index === orderedSelectedFiles.length - 1 ? 'disabled' : ''} title="Mover Abajo">↓</button>
<button onclick="removeFileFromSelected(${index})" class="remove-button" title="Quitar ${escapeHtml(file.name)} de la selección">X</button>
</span>
`;
listDiv.appendChild(item);
});
}
// Función de utilidad para evitar problemas de seguridad (XSS)
function escapeHtml(unsafe) {
if (typeof unsafe !== 'string') return '';
return unsafe
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
// Mueve un archivo de "disponibles" a "seleccionados"
function addFileToSelected(fileId) {
const fileToAdd = allAvailableFiles.find(f => f.id === fileId);
if (fileToAdd && !orderedSelectedFiles.some(sf => sf.id === fileId)) {
orderedSelectedFiles.push(fileToAdd);
renderAvailableFiles();
renderSelectedFiles();
}
}
// Quita un archivo de "seleccionados" y lo devuelve a "disponibles"
function removeFileFromSelected(index) {
orderedSelectedFiles.splice(index, 1);
renderAvailableFiles();
renderSelectedFiles();
}
// Cambia el orden de un archivo en la lista de seleccionados
function moveFile(index, direction) {
const newIndex = index + direction;
if (newIndex >= 0 && newIndex < orderedSelectedFiles.length) {
// Intercambia elementos
[orderedSelectedFiles[index], orderedSelectedFiles[newIndex]] = [orderedSelectedFiles[newIndex], orderedSelectedFiles[index]];
renderSelectedFiles();
}
}
// Llama a la función mergeSelectedDocs de Code.gs
function processMerge() {
const newDocName = document.getElementById('newDocName').value.trim();
const targetFolderId = document.getElementById('targetFolderIdInput').value.trim();
if (orderedSelectedFiles.length === 0) {
updateStatus("Por favor, selecciona al menos un archivo para fusionar.", "error");
return;
}
showLoader(true);
updateStatus("Fusionando documentos... Esto puede tardar unos momentos.", "info");
const fileIdsToMerge = orderedSelectedFiles.map(file => file.id);
google.script.run
.withSuccessHandler(response => {
showLoader(false);
if (response.error) {
updateStatus("Error al fusionar: " + response.error, "error");
} else {
// ¡Éxito! Muestra un enlace al nuevo documento
let successMessage = `${escapeHtml(response.success)} <a href="${response.docUrl}" target="_blank" rel="noopener noreferrer">Abrir documento fusionado</a>.`;
if (response.folderName) {
successMessage += ` Guardado en la carpeta: "${escapeHtml(response.folderName)}".`;
}
updateStatus(successMessage, "success", true);
}
})
.withFailureHandler(error => {
showLoader(false);
updateStatus("Error de comunicación al fusionar: " + error.message, "error");
})
.mergeSelectedDocs(fileIdsToMerge, newDocName, targetFolderId);
}
</script>
</body>
</html>
Análisis del Código
Este archivo se divide en tres partes:
HTML (Estructura)
El HTML define la estructura de la interfaz. Los elementos clave son:
- Un
<input>(#folderIdInput) para que el usuario pegue el ID de la carpeta. - Un
<button>(#listFolderButton) para iniciar el listado de archivos. - Dos
<div>(#availableFilesListy#selectedFilesList) que actúan como contenedores para las listas de archivos. - Inputs para
#newDocNamey#targetFolderIdInput. - El botón principal
#mergeButtonpara iniciar la fusión. - Un
<div>#loader(el spinner) y un<div>#statuspara mensajes.
CSS (Estilos)
El CSS está escrito dentro de la etiqueta <style>. Le da a la interfaz su aspecto limpio y moderno. No estás usando Tailwind aquí, sino CSS simple.
Define los colores de los botones, el diseño de dos columnas (.columns), los estilos para los mensajes de éxito/error, y la animación de carga (.loader).
JavaScript (Lógica del Cliente)
Este es el código más importante dentro de la etiqueta <script>. Se encarga de la interactividad:
google.script.run: Este es el objeto mágico que permite al JavaScript del lado del cliente (HTML) llamar a las funciones del lado del servidor (Code.gs)..withSuccessHandler(response): Es una función de callback que se ejecuta si la función deCode.gstiene éxito y devuelve datos (la lista de archivos o el mensaje de éxito)..withFailureHandler(error): Se ejecuta si la función deCode.gsfalla.listFilesFromFolder(): Llama agoogle.script.run.getFilesInFolder()y usa la respuesta para llenar la listaallAvailableFiles.renderAvailableFiles()/renderSelectedFiles(): Actualizan dinámicamente el HTML de las listas de archivos.addFileToSelected()/removeFileFromSelected()/moveFile(): Manejan la lógica de mover archivos entre las listas y reordenarlos.processMerge(): Recoge todos los datos (IDs de archivos en orden, nombre nuevo) y llama agoogle.script.run.mergeSelectedDocs()para iniciar la fusión.
Paso 4: ¡Guarda, Recarga y Autoriza!
- Guarda ambos archivos: En el editor de Apps Script, asegúrate de haber guardado los cambios en
Code.gsyPicker_Simple.html(haz clic en el icono del disquete 💾). - Recarga tu Google Sheet/Doc: Vuelve a la pestaña de tu Hoja de Cálculo o Documento y actualiza la página (F5 o Cmd+R).
- Espera el menú: Puede tardar unos segundos, pero ahora deberías ver un nuevo menú en la barra de herramientas llamado "Fusionar Documentos".
- Ejecuta y Autoriza:
- Haz clic en "Fusionar Documentos" > "Abrir Interfaz de Fusión".
- La primera vez que lo hagas, Google te pedirá autorización para que el script pueda acceder a tus archivos de Drive y crear documentos.
- Sigue los pasos. Es posible que veas una advertencia de "Aplicación no verificada". Esto es normal para scripts personales. Haz clic en "Configuración avanzada" y luego en "Ir a (nombre de tu proyecto) (no seguro)".
- Concede los permisos.
Paso 5: Cómo Usar tu Nueva Herramienta
Una vez autorizado, el panel de control se abrirá cada vez que lo llames desde el menú.
-
Busca el ID de tu Carpeta de Origen: Ve a Google Drive y abre la carpeta que contiene los Google Docs que quieres fusionar. Mira la barra de direcciones de tu navegador. La URL se verá así:
https://drive.google.com/drive/folders/1a2b3c4D_e5f6g7H-i8j9k0L
El ID de la carpeta es esa última parte:1a2b3c4D_e5f6g7H-i8j9k0L. - Pega el ID: Pega ese ID en el campo "ID de la Carpeta de Drive de Origen" en tu panel de control y haz clic en "Listar Archivos de Carpeta".
-
Selecciona y Ordena:
- Los Google Docs de esa carpeta aparecerán en la columna "Archivos Disponibles".
- Haz clic en el botón verde
→para mover los archivos que quieras a la columna "Archivos Seleccionados". - En la columna de seleccionados, usa los botones
↑y↓para definir el orden exacto de la fusión.
- Nombra tu Archivo: Dale un nombre a tu nuevo documento fusionado. Si lo dejas en blanco, se generará un nombre con la fecha y hora.
- (Opcional) Carpeta Destino: Si quieres guardar el archivo fusionado en una carpeta específica, pega el ID de esa carpeta en el campo "ID Carpeta Destino". Si lo dejas vacío, se guardará en la raíz de tu "Mi unidad".
- ¡Fusiona! Haz clic en el botón "Fusionar Documentos".
En unos segundos (o minutos, si son muchos archivos), verás un mensaje de éxito con un enlace para abrir tu nuevo documento maestro.