2.4.0: el mapa se vuelve interactivo, con colores en Windows

- Cada carpeta lleva el color de lo que guarda (R.COLOR_CUBETA). Lo que
  cuelga de una carpeta con color hereda el suyo; una que mezcla tipos se
  queda neutra.
- «¿Dónde va a parar este archivo?»: donde_iria() resuelve cualquier nombre
  con el mismo clasificador del escaneo y el árbol enciende el destino.
  Debajo, nombres de archivo de verdad de la carpeta conectada.
- Renombrar una carpeta del árbol cambia TODAS las plantillas que pasan por
  ella (00_Grabacion arrastra a CELULAR) y, si se pide, las carpetas que ya
  existen en el disco; queda en la bitácora y se deshace.
- {anio} y {mes} dejan de encajar con cualquier carpeta: solo con algo que
  parezca un año o un mes.
- Colores en el Explorador: .ico generados sin dependencias y asignados con
  SHGetSetFolderCustomSettings, la misma API de «Cambiar icono». Escribir el
  desktop.ini a mano NO basta, y SHGetFileInfo no sirve para comprobarlo:
  no informa de la personalización ni cuando la puso el propio Explorador.
  Ruta de icono relativa, así que los colores viajan con el disco. Si el
  disco no deja marcar la carpeta, se dice y no se deja nada escrito.
- .organilab no se escanea y los desktop.ini de la app no son basura.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Este commit está contenido en:
Labstream
2026-09-16 22:52:05 -05:00
padre ba186d4a26
commit 1ae7a1bb38
Se han modificado 6 ficheros con 677 adiciones y 25 borrados

Ver fichero

@@ -160,6 +160,16 @@
.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}
.arbol .pt{width:9px;height:9px;border-radius:3px;flex:none;background:var(--line)}
.arbol .nd.hit{background:var(--ok2);outline:1px solid var(--ok)}
.chips{display:flex;flex-wrap:wrap;gap:6px;margin:8px 0 0}
.chips button{font-family:ui-monospace,Consolas,monospace;font-size:11.5px;padding:3px 8px}
.resp{display:flex;gap:10px;align-items:flex-start;margin-top:12px;padding:10px 12px;border-radius:8px;background:var(--panel2)}
.resp .pt{width:12px;height:12px;border-radius:4px;flex:none;margin-top:3px}
.resp .d{font-family:ui-monospace,Consolas,monospace;font-size:12px;word-break:break-all;font-weight:600}
.leyenda-col{display:flex;flex-wrap:wrap;gap:4px 14px;margin-top:10px;font-size:12px;color:var(--mut)}
.leyenda-col span{display:flex;align-items:center;gap:6px}
.leyenda-col i{width:10px;height:10px;border-radius:3px;display:inline-block}
.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}
@@ -437,7 +447,17 @@
<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>
<span class="sep"></span>
<button id="btnPintar" title="Le pone a cada carpeta un icono del color de lo que guarda, para verlo en el Explorador">Pintar las carpetas en Windows</button>
<button class="lnk" id="btnDespintar" style="display:none">Quitar los colores</button>
<span id="mapaResumen" class="mut"></span>
</div>
<div class="box plano">
<h3>¿Dónde va a parar este archivo?</h3>
<p class="mut" style="font-size:12.5px;margin:0 0 8px">Escribe el nombre de cualquier archivo, exista o no, y te digo qué es y en qué carpeta lo dejaría. Lo decide el mismo clasificador que usa el escaneo.</p>
<input type="text" id="mapaArchivo" placeholder="A006_12171629_C001.braw" style="width:100%;max-width:460px">
<div class="chips" id="mapaEjemplos"></div>
<div id="mapaRespuesta"></div>
</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>
@@ -1294,7 +1314,7 @@ $('#btnVaciasBorrar').onclick = () => {
// ---------------- mapa: cómo van a quedar las carpetas ----------------
const SEP = '\\';
let MAPAE = null, MAPAE_SEL = '', MAPAE_NODOS = {};
let MAPAE = null, MAPAE_SEL = '', MAPAE_NODOS = {}, MAPAE_DESTINO = '';
const cargarMapaEst = seguro(async (tipo) => {
const raiz = raizActual();
const sel = $('#mapaTipo');
@@ -1315,7 +1335,9 @@ function anotarMapa(n, base){
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)}"` : ''}>` +
const hit = MAPAE_DESTINO && (n.cubetas || []).includes(MAPAE_DESTINO);
return `<div><div class="nd ${MAPAE_SEL === n._id ? 'sel' : ''} ${hit ? 'hit' : ''} ${n.n ? '' : 'vacio'} ${n.variable ? 'var' : ''}" data-id="${n._id}" tabindex="0" role="button"${que ? ` title="${esc(que)}"` : ''}>` +
`<i class="pt"${n.color ? ` style="background:${n.color}"` : ''}></i>` +
`<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>`;
@@ -1324,8 +1346,15 @@ function pintarMapaEst(){
if (!MAPAE) return;
$('#mapaSub').textContent = (MAPAE.cliente ? MAPAE.cliente + ' · ' : '') + MAPAE.titulo;
$('#mapaArbol').classList.remove('mut');
const cols = MAPAE.cubetas.filter(c => c.plantilla && c.n).sort((a, b) => b.bytes - a.bytes);
$('#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>';
'<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 o cambiarle el nombre.</div>' +
(cols.length ? `<div class="leyenda-col">${cols.map(c => `<span><i style="background:${c.color}"></i>${esc(NOMBRE_CUBETA[c.clave] || c.titulo)}</span>`).join('')}</div>` : '');
$('#btnDespintar').style.display = MAPAE.pintado ? '' : 'none';
$('#btnPintar').textContent = MAPAE.pintado ? 'Volver a pintar' : 'Pintar las carpetas en Windows';
$('#mapaEjemplos').innerHTML = MAPAE.cubetas.filter(c => c.ejemplos.length).slice(0, 8)
.map(c => `<button data-ej="${esc(c.ejemplos[0])}">${esc(c.ejemplos[0])}</button>`).join('');
$$('#mapaEjemplos button').forEach(b => b.onclick = () => { $('#mapaArchivo').value = b.dataset.ej; probarArchivo(); });
$$('#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(); } };
@@ -1341,6 +1370,8 @@ function pintarMapaDetalle(){
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.crudo && n.crudo.length && !n.variable)
h += `<div class="toolbar" style="margin:0 0 10px"><input type="text" id="mapaNombre" value="${esc(n.nombre)}" style="flex:1;min-width:150px" aria-label="Nombre de la carpeta"><button class="p" id="btnRenombrar">Cambiar el nombre</button></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){
@@ -1361,6 +1392,12 @@ function pintarMapaDetalle(){
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);
const bn = $('#btnRenombrar');
if (bn) {
const pedir = () => renombrarCarpeta(n, $('#mapaNombre').value.trim());
bn.onclick = pedir;
$('#mapaNombre').onkeydown = e => { if (e.key === 'Enter') { e.preventDefault(); pedir(); } };
}
}
function pintarSinSitio(){
const sin = MAPAE.cubetas.filter(c => c.asignable && !c.plantilla);
@@ -1398,6 +1435,72 @@ async function guardarPlantilla(clave, valor){
// accion() pinta un spinner dentro del elemento: en un <select> eso se llevaría por delante sus opciones
$('#mapaTipo').onchange = () => cargarMapaEst($('#mapaTipo').value);
// ---- renombrar una carpeta del mapa ----
const renombrarCarpeta = seguro(async (nodo, nombre) => {
if (!nombre || nombre === nodo.nombre) return;
const raiz = raizActual();
let existen = {n: 0, carpetas: []};
if (raiz) existen = await api(`/api/estructura/existen?raiz=${encodeURIComponent(raiz)}&ruta=${encodeURIComponent(JSON.stringify(nodo.crudo))}`);
const cuenta = existen.n;
modal('Cambiar el nombre de la carpeta', `<p>A partir de ahora, lo que iba a <span class="path">${esc(nodo.nombre)}</span> va a ir a <span class="path">${esc(nombre)}</span>.</p>` +
(cuenta ? `<label style="display:flex;gap:8px;align-items:flex-start;margin-top:10px;font-size:13px"><input type="checkbox" id="mDisco" checked style="margin-top:3px"><span>Cambiarle el nombre también a ${cuenta === 1 ? 'la carpeta que ya existe' : `las <b>${cuenta}</b> carpetas que ya existen`} con ese nombre. Es un movimiento normal: se deshace desde Historial.</span></label><div class="ejtipo" style="margin-top:8px">${existen.carpetas.slice(0, 6).map(c => `<div><b>${esc(c)}</b></div>`).join('')}${cuenta > 6 ? `<div><span>… y ${cuenta - 6} más</span></div>` : ''}</div>`
: '<p class="mut" style="font-size:12.5px">No hay ninguna carpeta con ese nombre en el disco todavía; solo cambia lo que la app hará de ahora en adelante.</p>'), () => accion($('#btnRenombrar') || $('#mapaTipo'), async () => {
const r = await api('/api/estructura/renombrar', {tipo: MAPAE.tipo, ruta: nodo.crudo, nombre,
raiz: raiz || null, en_disco: cuenta ? $('#mDisco').checked : false});
if (r.error) throw new ApiError(r.error);
PLAN = null; estadoPlan();
await cargarEstado();
await cargarMapaEst(MAPAE.tipo);
const vuelta = Object.values(MAPAE_NODOS).find(x => x.nombre === nombre);
if (vuelta) { MAPAE_SEL = vuelta._id; pintarMapaEst(); }
aviso(r.movidas ? `Ahora se llama «${nombre}»; ${r.movidas} carpeta${r.movidas > 1 ? 's' : ''} renombrada${r.movidas > 1 ? 's' : ''} en el disco.` : `Ahora se llama «${nombre}».`,
(r.errores || []).length ? 'bad' : 'ok', r.movidas ? {texto: 'Ver historial', ir: () => irA('historial')} : null);
}));
$('#modalSi').textContent = 'Cambiar el nombre';
});
// ---- probar dónde iría un archivo ----
let PROBAR_T = null;
const probarArchivo = seguro(async () => {
const nombre = $('#mapaArchivo').value.trim();
const caja = $('#mapaRespuesta');
if (!nombre) { caja.innerHTML = ''; MAPAE_DESTINO = ''; pintarMapaEst(); return; }
const d = await api(`/api/estructura/archivo?nombre=${encodeURIComponent(nombre)}&tipo=${encodeURIComponent(MAPAE ? MAPAE.tipo : 'contenido')}`);
if (d.error) { caja.innerHTML = `<p class="mut">${esc(d.error)}</p>`; return; }
const raizN = MAPAE && MAPAE.arbol ? MAPAE.arbol.nombre : '';
const fecha = d.por_fecha === 'mtime'
? 'el nombre no trae fecha, así que el mes lo saca de la fecha del archivo'
: `el nombre dice que es del ${d.fecha}`;
caja.innerHTML = `<div class="resp"><i class="pt" style="background:${d.color}"></i><div>` +
`<div><b>${esc(d.nombre)}</b> — ${esc(d.que)}${d.camara ? ` · cámara ${esc(d.camara)}` : ''}${d.app ? ` · proyecto de ${esc(d.app)}` : ''}</div>` +
`<div class="d" style="margin:4px 0">${esc(raizN + SEP + d.destino)}${SEP}</div>` +
`<div class="mut" style="font-size:12px">${esc(fecha)}. Si el archivo está dentro de una carpeta que la app reconoce, esa carpeta puede mandar sobre esto.</div>` +
'</div></div>';
MAPAE_DESTINO = d.cubeta;
pintarMapaEst();
const hit = $('#mapaArbol .nd.hit');
if (hit) hit.scrollIntoView({block: 'center', behavior: 'smooth'});
});
$('#mapaArchivo').oninput = () => { clearTimeout(PROBAR_T); PROBAR_T = setTimeout(probarArchivo, 250); };
// ---- colores en el Explorador ----
$('#btnPintar').onclick = () => {
const raiz = raizActual(); if (!raiz) return aviso('Conecta una carpeta primero.', 'bad');
modal('Pintar las carpetas en Windows', '<p>A cada carpeta se le pone un icono del color de lo que guarda, para verlo en el Explorador sin abrir la app.</p><p class="mut" style="font-size:12.5px">Windows no tiene «color de carpeta», así que se hace por el mismo camino que «Propiedades › Personalizar › Cambiar icono»: un <span class="path">desktop.ini</span> y los iconos en una carpeta oculta dentro del cliente. No se mueve ni se borra ningún archivo, y se quita con un clic. En algunos discos de red Windows no lo permite; si pasa, te lo digo y no dejo nada escrito.</p>',
() => accion($('#btnPintar'), async () => {
const r = await api('/api/colores/pintar', {raiz});
if (r.error) throw new ApiError(r.error);
aviso(`${r.pintadas} carpetas pintadas con ${r.colores} colores.` + (r.sin_marca ? ` ${r.sin_marca} no se pudieron: el disco no lo permite.` : '') + ' Si el Explorador ya estaba abierto, pulsa F5.', 'ok');
await cargarMapaEst(MAPAE.tipo);
}));
$('#modalSi').textContent = 'Pintar';
};
$('#btnDespintar').onclick = () => accion($('#btnDespintar'), async () => {
const r = await api('/api/colores/quitar', {raiz: raizActual()});
aviso(`${r.quitadas} carpetas vueltas a su icono normal.`, 'ok');
await cargarMapaEst(MAPAE.tipo);
});
// ---------------- 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'); }));