2.3.0: proyectos por programa, carpetas vacías y mapa

Tres cosas para la reorganización, pedidas juntas.

Proyectos por programa: la plantilla pasa de 01_Proyectos/{nombre} a
01_Proyectos/{app}/{nombre}, con {app} = el programa que abre el archivo
(R.APPS_PROYECTO, R.app_proyecto). Lo que ya viene en su propia carpeta se
queda con el nombre de esa carpeta; lo que se separa es lo suelto. Las
plantillas guardadas se migran solas si nadie las tocó. Entran extensiones
que caían en «otro»: .sesx, .c4d, .blend, .motn, .aepx, .aet, .prel, .idml,
.psdt, .afpub.

Carpetas vacías: carpetas_vacias() las encuentra desde el inventario y las
comprueba en el disco; borrar_vacias() las quita. Es lo único que la app
borra, y solo porque no hay nada dentro: cada una queda en la bitácora como
rmdir y Deshacer las vuelve a crear con su nombre y en el inventario. No
entra en «Hacer las N de una vez». 29 huecos en David Reyes, 40 en N:.

Mapa: pestaña nueva con el árbol que producen las plantillas de verdad
(mapa_estructura, GET /api/estructura). Al tocar una carpeta dice qué guarda
—derivado de ROL_A_CUBETA y ROL_QUE—, cuánto hay hoy, y deja cambiar el
destino ahí mismo. POST /api/plantillas pasa a fusionar cubeta a cubeta:
guardar una sola plantilla borraba las otras diecinueve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Este commit está contenido en:
Labstream
2026-09-16 22:34:12 -05:00
padre 2930320690
commit ba186d4a26
Se han modificado 6 ficheros con 608 adiciones y 29 borrados

Ver fichero

@@ -150,6 +150,22 @@
.ejtipo div{display:flex;gap:4px 10px;align-items:baseline;flex-wrap:wrap}
.ejtipo b{font-family:ui-monospace,Consolas,monospace;font-size:11.5px;font-weight:600;word-break:break-all}
.ejtipo span{font-size:12px;color:var(--mut)}
.arbol{font-family:ui-monospace,Consolas,monospace;font-size:12px;line-height:1.55}
.arbol .nd{display:flex;gap:10px;align-items:baseline;padding:3px 7px;border-radius:5px;cursor:pointer}
.arbol .nd:hover{background:var(--panel2)}
.arbol .nd.sel{background:var(--acc2);color:var(--acc)}
.arbol .nd .nm{font-weight:600}
.arbol .nd.var .nm{font-style:italic;font-weight:500}
.arbol .leyenda{margin-top:10px;font-family:Manrope,system-ui,sans-serif;font-size:11.5px;color:var(--mut)}
.arbol .nd .cif{margin-left:auto;font-family:Manrope,system-ui,sans-serif;font-size:11px;color:var(--mut);white-space:nowrap}
.arbol .hijos{margin-left:9px;border-left:1px solid var(--line);padding-left:11px}
.arbol .vacio{color:var(--mut);font-weight:400}
.det h4{margin:0 0 4px;font-size:13.5px}
.det .ruta{font-family:ui-monospace,Consolas,monospace;font-size:11.5px;word-break:break-all;color:var(--mut);margin-bottom:10px}
.det ul{margin:4px 0 0;padding-left:18px;font-size:12.5px}
.det .vars{display:grid;gap:3px;margin-top:6px;font-size:11.5px;color:var(--mut)}
.det .vars code{font-family:ui-monospace,Consolas,monospace;color:var(--tx)}
.sinsitio{display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding:6px 0;border-top:1px solid var(--line2)}
pre{background:var(--panel2);padding:10px;border-radius:6px;overflow:auto;font-size:11.5px;max-height:280px}
.tree{font-family:ui-monospace,Consolas,monospace;font-size:11.5px;white-space:pre;line-height:1.5;overflow:auto;max-height:420px}
.hint{background:var(--acc2);border:0;border-left:3px solid var(--acc);border-radius:0 6px 6px 0;padding:9px 12px;color:var(--tx);font-size:12.5px;margin-bottom:14px}
@@ -309,6 +325,7 @@
<nav id="nav" aria-label="Vistas">
<a data-t="carpeta" class="on" tabindex="0">Carpeta</a>
<a data-t="revisar" tabindex="0">Revisar y ordenar</a>
<a data-t="mapa" tabindex="0" title="Cómo van a quedar las carpetas y qué va en cada una">Mapa</a>
<a data-t="convertir" tabindex="0">Convertidor</a>
<a data-t="respaldar" tabindex="0">Respaldar</a>
<a data-t="historial" tabindex="0">Historial</a>
@@ -347,10 +364,10 @@
<!-- ============ REVISAR Y ORDENAR ============ -->
<section id="t-revisar">
<h2 class="v">Revisar y ordenar</h2>
<p class="ayuda">Todo se mueve <b>dentro de la misma carpeta</b> y nada se borra. <span class="q" title="Más">?</span><span class="mas">Basura y proxies van a <span class="path">_Para borrar</span>. Los nombres de archivo no cambian, así Resolve y Premiere pueden re-enlazar. Cada aplicación es un cambio que se deshace desde Historial. Las carpetas que la app no reconoce salen en el plan como «dudoso» y no se marcan: nómbralas en «Por nombrar» y se ordenan solas, en cualquier disco.</span></p>
<p class="ayuda">Todo se mueve <b>dentro de la misma carpeta</b>, y lo único que se borra son las carpetas que se quedan vacías, si las marcas tú. <span class="q" title="Más">?</span><span class="mas">Basura y proxies van a <span class="path">_Para borrar</span>. Los nombres de archivo no cambian, así Resolve y Premiere pueden re-enlazar. Cada aplicación es un cambio que se deshace desde Historial. Las carpetas que la app no reconoce salen en el plan como «dudoso» y no se marcan: nómbralas en «Por nombrar» y se ordenan solas, en cualquier disco.</span></p>
<div id="mapaVacio" class="hint">Esta carpeta todavía no está escaneada. Vuelve a <b>Carpeta</b> y pulsa <b>Escanear</b> (solo lee nombres y tamaños; no toca nada).</div>
<div id="revisarLleno" style="display:none">
<div class="tabs" id="tabsRev"><a data-s="hay" class="on">Qué hay</a><a data-s="plan">Qué va a pasar</a><a data-s="nombrar">Por nombrar <span class="badge" id="badgeNombrar">…</span></a><a data-s="stock">Stock viejo</a></div>
<div class="tabs" id="tabsRev"><a data-s="hay" class="on">Qué hay</a><a data-s="plan">Qué va a pasar</a><a data-s="nombrar">Por nombrar <span class="badge" id="badgeNombrar">…</span></a><a data-s="stock">Stock viejo</a><a data-s="vacias">Carpetas vacías</a></div>
<div class="sub on" id="s-hay">
<div class="cards" id="cards"></div>
@@ -400,9 +417,35 @@
</div>
<div class="box" id="stockCaja" style="display:none;max-height:420px;overflow:auto"><table id="tablaStock"><thead><tr><th></th><th>Carpeta</th><th>Mes de la pieza</th><th class="num">Archivos</th><th class="num">Peso</th><th>Ejemplos</th></tr></thead><tbody></tbody></table></div>
</div>
<div class="sub" id="s-vacias">
<p class="ayuda">Carpetas sin un solo archivo dentro: lo que queda cuando el material ya se movió. <b>Es lo único que la app borra</b>, y solo porque no hay nada que perder. <span class="q">?</span><span class="mas">Se comprueba en el disco antes de enseñarlas, y otra vez justo antes de borrarlas: si alguien dejó algo dentro mientras tanto, esa se salta y se avisa. <b>Deshacer</b> las vuelve a crear con su nombre. Las que son un enlace o una unión de Windows no se tocan nunca, aunque parezcan vacías. Fíjate en los nombres antes de marcar: una carpeta vacía llamada «03- Exfoliación química» es lo único que queda de esa pieza.</span></p>
<div class="toolbar">
<button id="btnVacias" class="p">Buscar carpetas vacías</button>
<button class="lnk" id="btnVaciasTodas" style="display:none">Marcar todas</button>
<span class="sep"></span><span id="vaciasResumen" class="mut"></span>
<button id="btnVaciasBorrar" class="bad" disabled>Borrar las marcadas</button><span class="por-que" id="btnVaciasBorrarPorQue">Busca primero</span>
</div>
<div class="box" id="vaciasCaja" style="display:none;max-height:460px;overflow:auto"><table id="tablaVacias"><thead><tr><th></th><th>Carpeta</th><th class="num">Subcarpetas vacías dentro</th></tr></thead><tbody></tbody></table></div>
</div>
</div>
</section>
<!-- ============ MAPA ============ -->
<section id="t-mapa">
<div class="cab"><div><h2 class="v">Mapa</h2><div class="sub" id="mapaSub">cómo van a quedar las carpetas y qué va en cada una</div></div></div>
<p class="ayuda">Este árbol sale de las plantillas de verdad, así que es exactamente lo que la app va a hacer al ordenar. <span class="q">?</span><span class="mas">Las carpetas en cursiva cambian según el material (el año, el mes, el nombre de la pieza, el programa del proyecto). Las cifras son el material que tienes hoy en esta carpeta, repartido por lo que es cada archivo. Toca cualquier carpeta del árbol para ver qué guarda y cambiar dónde va.</span></p>
<div class="toolbar">
<label>tipo de trabajo <select id="mapaTipo"></select></label>
<span class="sep"></span><span id="mapaResumen" class="mut"></span>
</div>
<div class="row" style="grid-template-columns:1.35fr 1fr">
<div class="box"><h3>Cómo van a quedar las carpetas</h3><div id="mapaArbol" class="arbol mut">cargando…</div></div>
<div class="box"><h3>Qué va en esta carpeta</h3><div id="mapaDetalle" class="det mut">Toca una carpeta del árbol para ver qué guarda y cambiar dónde va.</div></div>
</div>
<div class="box" id="mapaSinSitio" style="display:none"><h3>Sin carpeta asignada</h3><div class="mut" style="font-size:12.5px;margin-bottom:6px">Este material no tiene dónde ir con este tipo de trabajo: se queda donde está. Añádele una carpeta y la app empezará a ordenarlo.</div><div id="mapaSinSitioLista"></div></div>
</section>
<!-- ============ CONVERTIR ============ -->
<section id="t-convertir">
<div class="cab"><h2 class="v">Convertidor</h2><span class="sub">clips de cámara <b id="convCriterio">con más de 3 meses y más de 300 MB</b> · <details class="ajc" id="convAjustes"><summary>cambiar</summary><div class="toolbar">
@@ -556,7 +599,8 @@ const cargarVista = seguro(async t => {
const r = cfgRaiz();
if (t !== 'carpeta' && t !== 'ajustes' && r) { try { CARPETA = await api('/api/carpeta/estado?raiz=' + encodeURIComponent(raizActual())); pintarIndice(); } catch (e) { console.warn(e); } }
if (t === 'carpeta') await cargarCarpeta();
if (t === 'revisar') { await cargarMapa(); await cargarVoc(); if (PLAN) pintarPlan(); if (STOCK.length && STOCK[0].raiz !== raizActual()) { STOCK = []; $('#stockCaja').style.display = 'none'; } }
if (t === 'revisar') { await cargarMapa(); await cargarVoc(); if (PLAN) pintarPlan(); if (STOCK.length && STOCK[0].raiz !== raizActual()) { STOCK = []; $('#stockCaja').style.display = 'none'; } if (VACIAS && VACIAS.raiz !== raizActual()) { VACIAS = null; pintarVacias(); } }
if (t === 'mapa') await cargarMapaEst();
if (t === 'convertir') { await cargarCola(); }
if (t === 'respaldar') { if (!$('#respDestino').value) $('#respDestino').value = (ESTADO.config.respaldo || {}).destino || ''; pintarRespaldo(); }
if (t === 'historial') { await cargarBit(); }
@@ -733,6 +777,8 @@ const IR_ACCION = {
plan: p => { irA('revisar'); subRev('plan'); if (!PLAN) $('#btnPlan').click(); else if (p) { PLAN_ABIERTOS = new Set([p]); pintarPlan(); } },
nombrar: () => { irA('revisar'); subRev('nombrar'); },
stock: () => { irA('revisar'); subRev('stock'); if (!STOCK.length) $('#btnStock').click(); },
vacias: () => { irA('revisar'); subRev('vacias'); if (!VACIAS || VACIAS.raiz !== raizActual()) $('#btnVacias').click(); },
mapa: () => irA('mapa'),
repetidos: () => { irA('revisar'); subRev('hay'); const d = $('#dups'); if (d) d.scrollIntoView({block: 'center', behavior: 'smooth'}); },
convertir: () => { irA('convertir'); $('#btnBuscarClips').click(); },
respaldar: () => irA('respaldar'),
@@ -768,6 +814,7 @@ function queSeHizo(lote){
if (lote === 'optimizar' || lote.startsWith('optimizar-')) return 'Reemplazó originales por la versión convertida';
if (lote.startsWith('stock-')) return 'Apartó stock de Envato viejo';
if (lote.startsWith('dup-')) return 'Juntó material repetido';
if (lote.startsWith('vacias-')) return 'Borró carpetas vacías';
return 'Ordenó carpetas y archivos';
}
function pasosDe(C, r){
@@ -1203,6 +1250,154 @@ $('#filtroCli').oninput = () => { leerAjustes(); pintarAjustes(); }; // sin le
$('#btnAddCliente').onclick = () => { leerAjustes(); AJ.clientes.unshift({nombre: '', orig: '', tipo: 'contenido', alias: '', rutas: []}); CLI_ABIERTO = 0; $('#filtroCli').value = ''; pintarAjustes(); const el = $('#clientes .cli.abierto [data-k=nombre]'); if (el) el.focus(); };
$('#btnGuardarPlantillas').onclick = () => accion($('#btnGuardarPlantillas'), async () => { const plantillas = {}; $$('#plantillas input').forEach(i => { plantillas[i.dataset.t] = plantillas[i.dataset.t] || {}; plantillas[i.dataset.t][i.dataset.b] = i.value.trim(); }); await api('/api/plantillas', {plantillas}); await cargarEstado(); PLAN = null; estadoPlan(); aviso('Plantillas guardadas', 'ok'); });
// ---------------- carpetas vacías ----------------
let VACIAS = null;
const cargarVacias = seguro(async () => {
const raiz = raizActual(); if (!raiz) return;
VACIAS = await api('/api/vacias?raiz=' + encodeURIComponent(raiz));
VACIAS.carpetas.forEach(c => c.sel = false);
$('#btnVaciasTodas').textContent = 'Marcar todas';
pintarVacias();
});
function pintarVacias(){
const caja = $('#vaciasCaja'), tb = $('#tablaVacias tbody');
if (!VACIAS) { caja.style.display = 'none'; $('#btnVaciasTodas').style.display = 'none'; $('#vaciasResumen').textContent = ''; $('#btnVaciasBorrar').disabled = true; $('#btnVaciasBorrarPorQue').textContent = 'Busca primero'; return; }
const hay = VACIAS.carpetas.length;
caja.style.display = hay ? '' : 'none';
$('#btnVaciasTodas').style.display = hay ? '' : 'none';
tb.innerHTML = VACIAS.carpetas.map((c, i) => `<tr><td><input type="checkbox" data-i="${i}" ${c.sel ? 'checked' : ''} aria-label="${esc(c.rel)}"></td><td class="path">${esc(c.rel)}</td><td class="num">${c.subcarpetas || ''}</td></tr>`).join('');
tb.querySelectorAll('input').forEach(i => i.onchange = () => { VACIAS.carpetas[+i.dataset.i].sel = i.checked; resumenVacias(); });
resumenVacias();
}
function resumenVacias(){
const n = VACIAS ? VACIAS.carpetas.filter(c => c.sel).length : 0;
$('#vaciasResumen').textContent = !VACIAS ? '' : VACIAS.carpetas.length
? `${VACIAS.carpetas.length} vacía${VACIAS.carpetas.length > 1 ? 's' : ''} · ${n} marcada${n === 1 ? '' : 's'}`
: 'No queda ninguna carpeta vacía en esta carpeta.';
$('#btnVaciasBorrar').disabled = !n;
$('#btnVaciasBorrarPorQue').textContent = !VACIAS ? 'Busca primero' : n ? '' : 'Marca alguna';
}
$('#btnVacias').onclick = () => accion($('#btnVacias'), cargarVacias);
$('#btnVaciasTodas').onclick = () => { if (!VACIAS) return; const todas = VACIAS.carpetas.every(c => c.sel); VACIAS.carpetas.forEach(c => c.sel = !todas); $('#btnVaciasTodas').textContent = todas ? 'Marcar todas' : 'Desmarcar todas'; pintarVacias(); };
$('#btnVaciasBorrar').onclick = () => {
const s = (VACIAS ? VACIAS.carpetas : []).filter(c => c.sel); if (!s.length) return;
if (VACIAS.raiz !== raizActual()) return aviso('La lista es de otra carpeta. Busca de nuevo.', 'bad');
const dentro = s.reduce((a, c) => a + (c.subcarpetas || 0), 0);
modal('Borrar carpetas vacías', `<p><b>${s.length}</b> carpeta${s.length > 1 ? 's' : ''} sin un solo archivo dentro${dentro ? `, y ${dentro} subcarpeta${dentro > 1 ? 's' : ''} igual de vacía${dentro > 1 ? 's' : ''} que cuelga${dentro > 1 ? 'n' : ''} de ella${s.length > 1 ? 's' : ''}` : ''}, se borran del disco.</p><p>No hay nada dentro que perder. Lo único que se va es el nombre, y <b>Deshacer</b> las vuelve a crear tal cual:</p><div class="ejtipo">${s.slice(0, 8).map(c => `<div><b>${esc(c.rel)}</b></div>`).join('')}${s.length > 8 ? `<div><span>… y ${s.length - 8} más</span></div>` : ''}</div>`, () => accion($('#btnVaciasBorrar'), async () => {
const r = await api('/api/vacias/borrar', {raiz: raizActual(), rutas: s.map(c => c.ruta)});
aviso(`${r.borradas} carpeta${r.borradas === 1 ? '' : 's'} vacía${r.borradas === 1 ? '' : 's'} borrada${r.borradas === 1 ? '' : 's'}.` + (r.errores.length ? ` ${r.errores.length} se saltaron.` : ''),
r.errores.length ? 'bad' : 'ok', {texto: 'Deshacer esto', ir: () => deshacer(r.lote)});
await cargarVacias(); await cargarEstado();
}));
$('#modalSi').textContent = 'Borrar';
};
// ---------------- mapa: cómo van a quedar las carpetas ----------------
const SEP = '\\';
let MAPAE = null, MAPAE_SEL = '', MAPAE_NODOS = {};
const cargarMapaEst = seguro(async (tipo) => {
const raiz = raizActual();
const sel = $('#mapaTipo');
if (!sel.options.length && ESTADO) sel.innerHTML = Object.entries(ESTADO.tipos).map(([k, v]) => `<option value="${k}">${esc(v)}</option>`).join('');
const q = [];
if (tipo || sel.value) q.push('tipo=' + encodeURIComponent(tipo || sel.value));
if (raiz) q.push('raiz=' + encodeURIComponent(raiz));
MAPAE = await api('/api/estructura' + (q.length ? '?' + q.join('&') : ''));
sel.value = MAPAE.tipo;
MAPAE_NODOS = {}; MAPAE_SEL = '';
anotarMapa(MAPAE.arbol, '');
pintarMapaEst();
});
function anotarMapa(n, base){
n._r = base; n._id = 'm' + Object.keys(MAPAE_NODOS).length; MAPAE_NODOS[n._id] = n;
(n.hijos || []).forEach(h => anotarMapa(h, (base ? base + SEP : '') + h.nombre));
}
function nodoHtml(n){
const cif = n.n ? `${num(n.n)} · ${gb(n.bytes)}` : '';
const que = [n.variable && ('Cambia con el material: ' + n.variable), n.nota].filter(Boolean).join('. ');
return `<div><div class="nd ${MAPAE_SEL === n._id ? 'sel' : ''} ${n.n ? '' : 'vacio'} ${n.variable ? 'var' : ''}" data-id="${n._id}" tabindex="0" role="button"${que ? ` title="${esc(que)}"` : ''}>` +
`<span class="nm">${esc(n.nombre)}${SEP}</span>` +
`${cif ? `<span class="cif">${cif}</span>` : ''}</div>` +
`${n.hijos && n.hijos.length ? `<div class="hijos">${n.hijos.map(nodoHtml).join('')}</div>` : ''}</div>`;
}
function pintarMapaEst(){
if (!MAPAE) return;
$('#mapaSub').textContent = (MAPAE.cliente ? MAPAE.cliente + ' · ' : '') + MAPAE.titulo;
$('#mapaArbol').classList.remove('mut');
$('#mapaArbol').innerHTML = nodoHtml(MAPAE.arbol) +
'<div class="leyenda">Las carpetas <i>en cursiva</i> cambian con el material: el año, el mes, el nombre de la pieza o el programa. Toca cualquiera para ver qué guarda.</div>';
$$('#mapaArbol .nd').forEach(el => {
const ir = () => { MAPAE_SEL = el.dataset.id; pintarMapaEst(); };
el.onclick = ir; el.onkeydown = e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); ir(); } };
});
const con = MAPAE.cubetas.filter(c => c.n).length;
$('#mapaResumen').textContent = MAPAE.raiz ? `${con} de ${MAPAE.cubetas.filter(c => c.asignable).length} tipos de material tienen algo hoy en esta carpeta` : 'Conecta una carpeta para ver cuánto material tiene cada sitio';
pintarMapaDetalle();
pintarSinSitio();
}
function pintarMapaDetalle(){
const caja = $('#mapaDetalle'), n = MAPAE_NODOS[MAPAE_SEL];
if (!n) { caja.className = 'det mut'; caja.innerHTML = 'Toca una carpeta del árbol para ver qué guarda y cambiar dónde va.'; return; }
caja.className = 'det';
const cubs = (n.cubetas || []).map(c => MAPAE.cubetas.find(x => x.clave === c)).filter(Boolean);
let h = `<h4>${esc(n.nombre)}</h4><div class="ruta">${esc((MAPAE.arbol.nombre || '') + SEP + n._r)}</div>`;
if (n.variable) h += `<p class="mut" style="font-size:12.5px">El nombre cambia con el material: es ${esc(n.variable)}.</p>`;
if (n.nota) h += `<p class="mut" style="font-size:12.5px">${esc(n.nota.charAt(0).toUpperCase() + n.nota.slice(1))}.</p>`;
if (n.apps_de){
const c = MAPAE.cubetas.find(x => x.clave === n.apps_de);
if (c && c.apps.length) h += `<p style="margin:10px 0 0"><b>Una carpeta por programa:</b></p><div class="ejtipo">${c.apps.map(a => `<div><b>${esc(a.nombre)}</b><span>${num(a.n)} · ${gb(a.bytes)}</span></div>`).join('')}</div>`;
}
if (!cubs.length && !n.apps_de) h += '<p class="mut" style="font-size:12.5px">Aquí no cae nada directamente: es un nivel intermedio, lo que hay está en las carpetas de dentro.</p>';
cubs.forEach(c => {
h += `<div style="margin-top:12px;padding-top:10px;border-top:1px solid var(--line2)"><b>${esc(c.titulo)}</b>`;
h += `<ul>${c.guarda.map(g => `<li>${esc(g)}</li>`).join('')}</ul>`;
if (c.apps && c.apps.length) h += `<div class="ejtipo" style="margin-top:8px">${c.apps.map(a => `<div><b>${esc(a.nombre)}</b><span>${num(a.n)} · ${gb(a.bytes)}</span></div>`).join('')}</div>`;
h += `<p class="mut" style="font-size:12.5px;margin:8px 0 4px">${MAPAE.raiz ? (c.n ? `Hoy en esta carpeta: <b>${num(c.n)}</b> archivos · <b>${gb(c.bytes)}</b>.` : 'Hoy no tienes nada de esto en esta carpeta.') : ''}</p>`;
h += `<div class="toolbar" style="margin-top:6px"><input type="text" class="path" data-pl="${esc(c.clave)}" value="${esc(c.plantilla)}" style="flex:1;min-width:200px" aria-label="Dónde va ${esc(c.titulo)}">`;
h += `<button class="p" data-guardarpl="${esc(c.clave)}">Guardar</button>`;
if (c.defecto && c.plantilla !== c.defecto) h += `<button class="lnk" data-defecto="${esc(c.clave)}">Volver al de fábrica</button>`;
h += '</div></div>';
});
if (cubs.length) h += `<div class="vars" style="margin-top:10px">Puedes usar: ${Object.entries(MAPAE.variables).map(([k, v]) => `<div><code>{${k}}</code> — ${esc(v)}</div>`).join('')}</div>`;
caja.innerHTML = h;
engancharPlantillas(caja);
}
function pintarSinSitio(){
const sin = MAPAE.cubetas.filter(c => c.asignable && !c.plantilla);
$('#mapaSinSitio').style.display = sin.length ? '' : 'none';
if (!sin.length) return;
$('#mapaSinSitioLista').innerHTML = sin.map(c => `<div class="sinsitio"><b>${esc(c.titulo)}</b><span class="mut" style="font-size:12px">${c.n ? `${num(c.n)} · ${gb(c.bytes)}` : 'sin material hoy'}</span><input type="text" class="path" data-pl="${esc(c.clave)}" value="${esc(c.defecto)}" style="flex:1;min-width:200px" aria-label="Dónde va ${esc(c.titulo)}"><button class="p" data-guardarpl="${esc(c.clave)}">Añadir</button></div>`).join('');
engancharPlantillas($('#mapaSinSitioLista'));
}
function engancharPlantillas(caja){
caja.querySelectorAll('[data-guardarpl]').forEach(b => b.onclick = () => accion(b, async () => {
const clave = b.dataset.guardarpl;
const campo = caja.querySelector(`input[data-pl="${clave}"]`);
await guardarPlantilla(clave, campo.value.trim());
}));
caja.querySelectorAll('[data-defecto]').forEach(b => b.onclick = () => accion(b, async () => {
const c = MAPAE.cubetas.find(x => x.clave === b.dataset.defecto);
await guardarPlantilla(c.clave, c.defecto);
}));
}
async function guardarPlantilla(clave, valor){
await api('/api/plantillas', {plantillas: {[MAPAE.tipo]: {[clave]: valor}}});
PLAN = null; estadoPlan();
await cargarEstado();
// se vuelve a la MISMA carpeta por su ruta, no por su posicion: al cambiar la plantilla el arbol
// cambia de forma y el numero de nodo pasa a ser el de otra rama
const antes = (MAPAE_NODOS[MAPAE_SEL] || {})._r;
await cargarMapaEst(MAPAE.tipo);
const nodos = Object.values(MAPAE_NODOS);
// se salta a donde haya quedado lo que acabas de cambiar; si esa cubeta ya no cae en ninguna carpeta
// (la dejaste en blanco), se vuelve a la misma carpeta de antes
const vuelta = nodos.find(n => (n.cubetas || []).includes(clave)) || nodos.find(n => n._r === antes);
if (vuelta) { MAPAE_SEL = vuelta._id; pintarMapaEst(); }
aviso('Guardado. El plan se vuelve a calcular con el destino nuevo.', 'ok');
}
// accion() pinta un spinner dentro del elemento: en un <select> eso se llevaría por delante sus opciones
$('#mapaTipo').onchange = () => cargarMapaEst($('#mapaTipo').value);
// ---------------- soltar carpeta para añadir ----------------
const zona = $('#zonaSoltar');
['dragenter','dragover'].forEach(ev => document.addEventListener(ev, e => { e.preventDefault(); zona.classList.add('sobre'); $('#vacioCaja').classList.add('sobre'); }));