3.8.0: «database is locked» y los 753 que no se movieron, arreglados; fechas raras, escaneo profundo, buscar, otros discos, banco por fecha y compilar en el estudio

- Tras «Aplicar» en una carpeta grande el inventario se ponía al día con la base tomada más de diez minutos y
  «Buscar clips» se rendía con «database is locked». Ahora las preguntas al disco van antes y en paralelo y
  lo de dentro de cada carpeta se busca por índice: 1,3 s para 2.493 movimientos, mismas filas de archivos
  que antes y 183 carpetas que antes se perdían. La lectura de clips reintenta si la base está ocupada.
- Las fotos de «Alta» y «Baja» chocaban al juntarse en una carpeta: las subcarpetas con sentido se
  conservan, el plan avisa antes de dos archivos iguales en una fila, y un número entre dos palabras
  («Fiesta 6 Años») ya no se quita.
- Los fallos de «Aplicar» se agrupan por motivo y dicen qué hacer; el banco de material se explica en llano.
- Fechas raras (el reloj de una cámara), escaneo profundo, buscar en todos los clientes, traer lo que el
  cliente tiene en otros discos, el banco por temas o por fecha, y «Calcular de nuevo» más rápido.
- Mover reintenta un momento si el archivo está abierto un instante (lectura de clips, antivirus).
- .github/workflows/publicar.yml para compilar y publicar desde un PC del estudio; las pruebas devuelven
  error si algo falla.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Este commit está contenido en:
Labstream
2026-10-14 08:27:27 -05:00
padre 41155cd834
commit e74b8f7ece
Se han modificado 9 ficheros con 931 adiciones y 63 borrados

Ver fichero

@@ -261,6 +261,21 @@
.ac{display:grid;grid-template-columns:26px minmax(0,1fr) auto;gap:14px;align-items:center;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px 14px}
.ac.hecho{background:transparent;border-style:dashed;opacity:.62}
.ac .hc{margin-top:4px;font-size:12.5px;font-weight:600;color:var(--ok)}
.motivos{margin-top:8px;display:grid;gap:6px;font-size:12.5px}
.motivos .mt{padding:8px 10px;border-radius:8px;background:var(--panel2);color:var(--tx)}
.motivos .ej{margin-top:3px;font-size:11.5px}
.reloj{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;align-items:center;padding:10px 12px;border:1px solid var(--line);border-radius:8px;margin-bottom:8px;font-size:12.5px}
.reloj.hecho{border-style:dashed;opacity:.8}
.reloj .path,.sitio .path{word-break:normal;overflow-wrap:anywhere}
.reloj .acts{display:flex;gap:8px;align-items:center;flex-wrap:wrap;justify-content:flex-end}
@media(max-width:820px){.reloj{grid-template-columns:1fr}.reloj .acts{justify-content:flex-start}}
.sitio{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;align-items:center;padding:8px 0;border-top:1px solid var(--line);font-size:12.5px}
.sitio .acts{display:flex;gap:10px}
.buscar-todo{margin:10px 0 0}
.buscar-todo input{width:100%;box-sizing:border-box}
.busca td{vertical-align:top;font-size:12.5px}
.modob{margin:0 0 10px;padding:8px 12px;border-radius:8px;background:var(--panel2);font-size:12.5px;display:grid;gap:4px}
.modob .lnk{padding:0}.modob .lnk.on{font-weight:700;text-decoration:underline}
.ac.hecho .hc{opacity:1}
.ac.recien{opacity:1;animation:recien 3s ease-out}
@keyframes recien{0%{background:var(--ok2);border-color:var(--ok);border-style:solid}70%{background:var(--ok2)}100%{background:transparent}}
@@ -514,6 +529,7 @@
<select id="raiz" aria-hidden="true"></select>
<div id="zonaSoltar" role="button" tabindex="0" title="Suelta aquí una carpeta de cliente para añadirla, o haz clic para pegar la ruta">Suelta aquí la carpeta<br>de un cliente para añadirla</div>
<button class="lnk" id="btnElegir" style="width:100%;margin-top:4px" title="Abre el selector de carpetas de Windows">Elegir carpeta…</button>
<div class="buscar-todo"><input type="search" id="buscarTodo" placeholder="Buscar un archivo en todos…" aria-label="Buscar un archivo en todos los clientes" title="Escribe parte del nombre y pulsa Enter"></div>
<div class="lado-pie"><img src="/static/labstream.png" alt="Labstream Studio"><span id="reloj"></span><button id="btnBuscarAct" title="Consulta Labstream OS y avisa si hay una versión nueva">Buscar actualizaciones</button><span id="actUltima" class="mut" style="font-size:11px"></span></div>
</aside>
<div id="principal">
@@ -566,7 +582,7 @@
<div id="mapaCargando" class="hint" style="display:none"><span class="spin"></span> Leyendo el inventario de la carpeta… En una carpeta grande tarda unos segundos.</div>
<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><a data-s="vacias">Carpetas vacías</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><a data-s="relojes">Fechas raras <span class="badge cero" id="badgeRelojes">0</span></a></div>
<div class="sub on" id="s-hay">
<div class="cards" id="cards"></div>
@@ -620,6 +636,10 @@
<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-relojes">
<p class="ayuda">Clips cuya cámara tenía el reloj sin poner: su fecha dice 2008 o 2009, y el plan los ordenaría en un año que no es. Pon la fecha real del primer clip de cada tanda y la app corre los demás lo mismo. <span class="q">?</span><span class="mas">Se agrupan por carpeta y cámara. La pista sale de otros clips de esa carpeta que sí traen bien la fecha, o del año que diga la ruta. La corrección se guarda por archivo: sobrevive a volver a escanear y a que el plan los mueva de carpeta. Se deshace desde aquí.</span></p>
<div id="relojes" class="mut">…</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">
@@ -753,6 +773,11 @@
<h3>Revisar y ordenar</h3>
<dl>
<dt>Escaneo profundo</dt><dd>Después de leer nombres, tamaños y fechas, la app lee cada clip: códec, duración y resolución. Va en segundo plano y solo lee lo nuevo. <i>Cómo: se apaga en Ajustes › Escaneo; sin él, los repetidos «con otra calidad» quedan por comparar y no se sabe qué conviene convertir hasta buscar clips.</i></dd>
<dt>Fechas raras</dt><dd>Clips cuya cámara tenía el reloj sin poner (2008, 2009…). Pones la fecha real del primer clip de cada tanda y la app corre los demás lo mismo; la corrección sobrevive a volver a escanear y se deshace desde ahí.</dd>
<dt>Buscar en todos</dt><dd>El buscador de la barra lateral encuentra un archivo o una carpeta por su nombre en todas las carpetas de clientes escaneadas, con o sin tildes, y abre su carpeta.</dd>
<dt>Otros discos</dt><dd>Si el cliente también está en otros discos, Inicio lo dice y deja traer cada sitio a esta carpeta con copia comprobada, igual que «Traer material»: el original no se toca. Una carpeta de entregas o de exportados puede ser una copia que conviene dejar donde está.</dd>
<dt>Banco por fecha</dt><dd>Un banco de material se ordena por temas (los temas se quedan; dentro, por fecha y cámara) o, si lo eliges en «Qué va a pasar», por fecha: cada grabación va a la 00_Grabacion de su mes con su tema de etiqueta.</dd>
<dt>Qué pesa</dt><dd>Toca cualquier bloque para entrar en esa carpeta, nivel a nivel, hasta el fondo. Al lado dice qué guarda, de qué cámaras o grabadoras, de qué fechas, cuántos proxies y basura se pueden apartar, y qué hace la app con ella. <i>Cómo: sale del inventario del escaneo, que entra en todas las carpetas; en Inicio se ve cuántas leyó y hasta qué profundidad.</i></dd>
<dt>Banco de material</dt><dd>Una carpeta de material ordenada por temas (Lugares, Social, Ambiental…). Los temas se quedan como están; dentro de cada uno, las grabaciones van por fecha de rodaje y por cámara: «Lugares\Peajes\Drone» pasa a «Lugares\Peajes\2024-03-12\DJI». Una carpeta cuyo nombre diga «No tocar» se trata así, y todo lo que la app propone dentro va sin marcar: lo marcas tú en «Qué va a pasar», donde el banco es un paso propio. Otra carpeta se puede marcar como banco en «Por nombrar».</dd>
<dt>Qué hay</dt><dd>Qué pesa y de qué clase es, por cámara, por mes y por tipo de archivo, con avisos de lo raro: material suelto, meses mal escritos, dos sistemas de orden a la vez.</dd>
@@ -846,6 +871,9 @@
<div class="box" style="margin-top:18px"><h3>Actualizaciones</h3>
<label style="display:flex;gap:8px;align-items:flex-start;font-size:13px"><input type="checkbox" id="actAuto" style="margin-top:3px"><span>Al arrancar, consultar si hay una versión nueva en Labstream OS. <span class="mut">Es lo único que sale a la red; sin esto el programa no habla con nada fuera de este equipo. El botón «Buscar actualizaciones» sigue funcionando a mano.</span></span></label>
</div>
<div class="box" style="margin-top:18px"><h3>Escaneo</h3>
<label style="display:flex;gap:8px;align-items:flex-start;font-size:13px"><input type="checkbox" id="escProfundo" style="margin-top:3px"><span>Escaneo profundo: después de leer nombres, tamaños y fechas, leer cada clip (códec, duración, resolución). <span class="mut">Va en segundo plano y solo lee lo que no se había leído. Con esto se sabe qué repetidos son el mismo clip con otra calidad y qué conviene convertir. En una carpeta grande del NAS, la primera vez tarda un rato; se detiene desde Convertidor.</span></span></label>
</div>
<details class="mas" style="margin-top:18px"><summary>Avanzado: plantillas de estructura por tipo de trabajo</summary>
<div class="box" style="margin-top:10px">
<div class="mut" style="margin-bottom:8px">Ruta relativa a la carpeta del cliente. Variables: {anio} {mes} {nombre} {sub} {rel}.</div>
@@ -932,7 +960,7 @@ function cfgRaiz(){ return (ESTADO.raices||[]).find(r => r.clave === raizActual(
function irA(t){ $$('nav a').forEach(x => x.classList.toggle('on', x.dataset.t === t)); $$('section').forEach(s => s.classList.toggle('on', s.id === 't-' + t)); pintarIndice(); cargarVista(t); }
function vistaActual(){ return ($$('nav a').find(a => a.classList.contains('on')) || {}).dataset?.t || 'carpeta'; }
$$('nav a').forEach(a => { a.onclick = () => irA(a.dataset.t); a.onkeydown = e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); irA(a.dataset.t); } }; });
function subRev(s){ const venia = $('#s-plan').classList.contains('on'); $$('#tabsRev a').forEach(x => x.classList.toggle('on', x.dataset.s === s)); $$('#t-revisar .sub').forEach(x => x.classList.toggle('on', x.id === 's-' + s)); if (s === 'plan' && !PLAN && MAPA && MAPA.escaneado) $('#btnPlan').click(); if (s === 'plan' && !venia && PLAN) setTimeout(animarEjemplo, 60); }
function subRev(s){ const venia = $('#s-plan').classList.contains('on'); $$('#tabsRev a').forEach(x => x.classList.toggle('on', x.dataset.s === s)); $$('#t-revisar .sub').forEach(x => x.classList.toggle('on', x.id === 's-' + s)); if (s === 'plan' && !PLAN && MAPA && MAPA.escaneado) $('#btnPlan').click(); if (s === 'plan' && !venia && PLAN) setTimeout(animarEjemplo, 60); if (s === 'relojes') cargarRelojes().catch(e => aviso(e.message, 'bad')); }
$$('#tabsRev a').forEach(a => a.onclick = () => subRev(a.dataset.s));
const cargarVista = seguro(async t => {
t = t || vistaActual();
@@ -1073,6 +1101,38 @@ $('#btnTraer').onclick = () => {
$('#trAbrir').onclick = e => { e.preventDefault(); api('/api/entrada/abrir', {raiz}).then(() => aviso('Bandeja de entrada abierta en el Explorador. Lo que dejes ahí saldrá en la lista de la carpeta.', 'ok')).catch(er => aviso(er.message, 'bad')); };
};
// ---------------- el mismo cliente en otros discos: traerlo aquí ----------------
function juntarModal(a){
const raiz = raizActual(), sitios = a.sitios || [];
const nombreDe = s => ('De ' + s.ruta.replace(':', '')).replace(/[\\/]+/g, ' ').trim();
const filas = sitios.map((s, i) => `<div class="sitio"><div class="tx"><b class="path">${esc(s.ruta)}</b><div class="mut">${esc([s.unc, s.disco].filter(Boolean).join(' · '))}</div><div id="sitioInfo${i}" class="${s.traido ? 'ok' : 'mut'}">${s.traido ? '✓ Traído ' + esc(fecha(s.traido)) : ''}</div></div><div class="acts">${s.traido ? '' : `<button class="lnk" data-cuanto="${i}">¿Cuánto es?</button><button class="p" data-traer="${i}">Traer aquí</button>`}</div></div>`).join('');
modal(a.titulo, `<p>Cada sitio se copia a la bandeja de entrada de esta carpeta, y <b>cada archivo copiado se vuelve a leer para comprobar que quedó igual</b>. <b>El original no se toca</b>: cuando lo hayas revisado, lo borras tú si quieres.</p>${a.detalle ? `<p class="mut" style="font-size:12.5px">${esc(a.detalle)}</p>` : ''}<div class="sitios">${filas}</div><label style="display:block;margin-top:10px"><input type="checkbox" id="jnOrdenar" checked> repartirlo por mes, día de rodaje y cámara al terminar de copiar</label>`, null);
$('#modal').classList.add('ancho');
$$('#modalCuerpo [data-cuanto]').forEach(b => b.onclick = () => accion(b, async () => {
const i = b.dataset.cuanto, s = sitios[+i];
const r = await api('/api/entrada/plan', {raiz, origen: s.ruta, nombre: nombreDe(s)});
$('#sitioInfo' + i).innerHTML = `${num(r.n)} archivo${r.n !== 1 ? 's' : ''} · <b>${gb(r.bytes)}</b>${r.libre != null ? ` · en el disco de este cliente quedan ${gb(r.libre)} libres` : ''}`;
}));
$$('#modalCuerpo [data-traer]').forEach(b => b.onclick = () => accion(b, async () => {
const s = sitios[+b.dataset.traer];
const r = await api('/api/entrada/traer', {raiz, origen: s.ruta, nombre: nombreDe(s), ordenar: $('#jnOrdenar').checked});
$('#modal').classList.remove('on');
aviso(`Trayendo ${num(r.n)} archivo${r.n !== 1 ? 's' : ''} (${gb(r.bytes)}) de ${s.ruta}. El avance está en la barra de abajo; el original no se toca.`, 'ok');
await cargarEstado();
}));
}
// ---------------- buscar un archivo en todos los clientes ----------------
const buscarTodo = seguro(async () => {
const t = $('#buscarTodo').value.trim(); if (!t) return;
const d = await api('/api/buscar?q=' + encodeURIComponent(t));
const filas = d.resultados.map((r, i) => `<tr><td><b>${esc(r.nombre)}</b><div class="mut path" style="font-size:11.5px">${esc(r.rel)}</div></td><td>${esc(r.cliente)}</td><td class="mut">${esc(r.rol)}${r.apartado ? ' · apartado' : ''}</td><td class="num">${gb(r.bytes)}</td><td class="mut">${esc(fechaLarga(r.fecha))}</td><td><button class="lnk" data-abrir="${i}">Abrir la carpeta</button></td></tr>`).join('');
modal(`«${t}» en todas las carpetas`, `<p class="mut">${d.total ? `${num(d.total)} resultado${d.total !== 1 ? 's' : ''} en ${d.carpetas} carpetas de clientes${d.total > d.resultados.length ? `; aquí, los ${d.resultados.length} primeros` : ''}.` : `Nada con ese nombre en las ${d.carpetas} carpetas de clientes.`} Busca en lo escaneado: un disco sin registrar no entra.</p>${filas ? `<div style="max-height:60vh;overflow:auto"><table class="busca"><thead><tr><th>Archivo</th><th>Cliente</th><th>Qué es</th><th class="num">Peso</th><th>Fecha</th><th></th></tr></thead><tbody>${filas}</tbody></table></div>` : ''}`, null);
$('#modal').classList.add('ancho');
$$('#modalCuerpo [data-abrir]').forEach(b => b.onclick = seguro(async () => { await api('/api/abrir', {ruta: d.resultados[+b.dataset.abrir].dir}); }));
});
$('#buscarTodo').onkeydown = e => { if (e.key === 'Enter') { e.preventDefault(); buscarTodo(); } };
// ---------------- orden automático (portada) ----------------
const ESTADO_AUTO = {'al dia': ['ok', 'al día'], 'con dudas': ['', 'con dudas'], 'con errores': ['bad', 'con avisos'], 'demasiado': ['warn', 'sin tocar'], 'sin conexion': ['mut', 'sin conexión'], 'sin leer': ['warn', 'sin leer']};
async function pintarAuto(){
@@ -1235,6 +1295,8 @@ const IR_ACCION = {
$('#modalSi').textContent = 'Usar este';
},
nombrar: () => { irA('revisar'); subRev('nombrar'); },
relojes: () => { irA('revisar'); subRev('relojes'); },
juntar: (_, a) => juntarModal(a),
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'),
@@ -1372,6 +1434,7 @@ async function cargarMapa(){
try { m0 = await api('/api/mapa?raiz=' + encodeURIComponent(raiz)); } finally { $('#mapaCargando').style.display = 'none'; }
if (raizActual() !== raiz) return;
MAPA = m0;
cargarRelojes().catch(() => {}); // la cuenta de «Fechas raras»
$('#mapaVacio').style.display = MAPA.escaneado ? 'none' : 'block';
$('#revisarLleno').style.display = MAPA.escaneado ? 'block' : 'none';
cargarDiscos().catch(() => {});
@@ -1469,6 +1532,32 @@ async function cargarDiscos(){
$('#discos').innerHTML = `<table><thead><tr><th>Unidad</th><th>Nombre</th><th>Nivel</th><th style="width:22%">Uso</th><th class="num">Libre</th><th>Carpetas de primer nivel</th></tr></thead><tbody>${d.discos.map(x => `<tr><td><b>${x.letra}</b><div class="cell-small">${esc(x.unc)}</div></td><td>${esc(x.nombre)}</td><td><span class="tag">${esc(x.nivel)}</span></td><td><div class="bar"><i style="width:${Math.round((x.total-x.libre)/x.total*100)}%;background:${(x.total-x.libre)/x.total > .9 ? 'var(--bad)' : 'var(--acc)'}"></i></div><div class="cell-small">${gb(x.total-x.libre)} de ${gb(x.total)}</div></td><td class="num">${gb(x.libre)}</td><td>${x.carpetas.slice(0,40).map(c => `<span class="chip" style="${c.cliente ? 'border-color:var(--acc)' : ''}" title="${esc(c.cliente||'')}">${esc(c.nombre)}</span>`).join('')}${x.carpetas.length > 40 ? ` <span class="mut">+${x.carpetas.length-40}</span>` : ''}</td></tr>`).join('')}</tbody></table>`;
}
// ---------------- revisar: fechas raras (una cámara con el reloj sin poner) ----------------
const fechaLarga = f => { const [a, m, d] = (f || '').split('-'); return d ? `${+d} ${MESES_UI[+m - 1].slice(0, 3).toLowerCase()} ${a}` : (f || ''); };
async function cargarRelojes(){
const raiz = raizActual(); if (!raiz) return;
const d = await api('/api/relojes?raiz=' + encodeURIComponent(raiz));
if (raizActual() !== raiz) return;
const bd = $('#badgeRelojes'); bd.textContent = d.grupos.length; bd.classList.toggle('cero', !d.grupos.length);
$('#relojes').classList.remove('mut');
$('#relojes').innerHTML = (d.grupos.length ? d.grupos.map((g, i) => `<div class="reloj"><div class="tx"><b class="path">${esc(g.rel)}</b><div class="mut">${esc(g.camara || 'cámara sin identificar')} · ${num(g.n)} clip${g.n !== 1 ? 's' : ''} · ${gb(g.bytes)} · por ejemplo «${esc(g.ejemplo)}»</div><div>La cámara dice <b>${fechaLarga(g.desde)}</b>${g.hasta !== g.desde ? ` a <b>${fechaLarga(g.hasta)}</b>` : ''}.${g.sugerencia ? ` <span class="mut">Pista: ${esc(g.por_que)}.</span>` : ''}</div></div><div class="acts"><label>El primero se grabó el <input type="date" data-f="${i}" value="${esc(g.sugerencia || '')}"></label><button class="p" data-c="${i}">Corregir</button></div></div>`).join('')
: '<p class="ok" style="margin:6px 0">No queda ningún clip con fecha imposible.</p>')
+ (d.corregidos.length ? '<div class="cabsec" style="margin-top:16px">Ya corregidas</div>' + d.corregidos.map((c, i) => `<div class="reloj hecho"><div class="tx">✓ <b class="path">${esc(c.rel || '')}</b> · ${esc(c.camara || '')} · ${num(c.n)} clip${c.n !== 1 ? 's' : ''} corrido${c.n !== 1 ? 's' : ''} ${c.dias > 0 ? '+' : ''}${num(c.dias)} días · ${esc(fecha(c.ts))}</div><div class="acts"><button class="lnk" data-d="${i}">Deshacer</button></div></div>`).join('') : '');
$$('#relojes [data-c]').forEach(b => b.onclick = () => {
const g = d.grupos[+b.dataset.c], f = $(`#relojes [data-f="${b.dataset.c}"]`).value;
if (!f) { aviso('Pon la fecha en que se grabó el primer clip.', 'bad'); return; }
accion(b, async () => {
const r = await api('/api/relojes', {raiz, dir: g.dir, fuente: g.fuente, fecha: f});
aviso(`Listo: ${num(r.n)} clip${r.n !== 1 ? 's' : ''} con su fecha real (${r.dias > 0 ? '+' : ''}${num(r.dias)} días). El plan se vuelve a calcular.`, 'ok');
PLAN = null; estadoPlan(); await cargarRelojes();
});
});
$$('#relojes [data-d]').forEach(b => b.onclick = () => accion(b, async () => {
await api('/api/relojes/deshacer', {raiz, grupo: d.corregidos[+b.dataset.d].grupo});
aviso('Corrección deshecha: esos clips vuelven a la fecha de su cámara.', 'ok'); PLAN = null; estadoPlan(); await cargarRelojes();
}));
}
// ---------------- revisar: por nombrar ----------------
async function cargarVoc(){
if (!raizActual() || !(MAPA && MAPA.escaneado)) { $('#nombrar').innerHTML = '<li class="mut">Escanea primero.</li>'; return; }
@@ -1637,18 +1726,33 @@ function animarEjemplo(){
setTimeout(() => { if (v.isConnected) fin(); }, 1500);
}
// ---- la lista: de dónde → a dónde, con la cuenta; el porqué solo si se pide
// lo que comparten el origen y el destino: así se dice solo lo que cambia
function comunes(o, d){ const a = trozos(o), b = trozos(d); let i = 0; while (i < a.length && i < b.length && a[i].toLowerCase() === b[i].toLowerCase()) i++; return {comun: a.slice(0, i), soloO: a.slice(i), soloD: b.slice(i)}; }
const corta = segs => segs.length > 2 ? '…\\' + segs.slice(-2).join('\\') : segs.join('\\');
function deHtml(r){
if (r.archivos && r.paso === 'banco') {
const c = comunes(r.origen_rel, r.destino_rel), cam = ultimo(r.destino_rel);
return `<b>${r.archivos.length} clip${r.archivos.length > 1 ? 's' : ''} de ${esc(NOMBRE_CAMARA[cam] || cam)}</b><span class="en">en el tema «${esc(ultimo(c.comun.join('\\')) || r.banco || '')}»${c.soloO.length ? `, dentro de «${esc(c.soloO.join(' › '))}»` : ''}</span>`;
}
if (r.archivos && r.origen_rel === '(toda la raíz)') return `<b>${r.archivos.length} archivos de ${r.cubeta === 'PROXY' ? 'proxy' : 'basura'}</b><span class="en">por toda la carpeta</span>`;
if (r.archivos) return `<b>${r.archivos.length} archivo${r.archivos.length > 1 ? 's' : ''}</b><span class="en">en ${esc(r.origen_rel)}</span>`;
return `<b>${esc(ultimo(r.origen_rel))}</b><span class="en">en ${esc(padreDe(r.origen_rel) || 'la raíz')}</span>`;
}
function aHtml(r){
if (r.archivos && r.paso === 'banco') {
const c = comunes(r.origen_rel, r.destino_rel);
if (c.comun.length && !/^\d{4}$/.test(c.soloD[0] || '') && /^\d{4}-\d{2}-\d{2}$/.test(c.soloD[0] || '')) return `se quedan en el mismo tema, en <b>${esc(c.soloD.join(' › '))}</b> <span class="mut">(día de rodaje › cámara)</span>`;
return `<b>${esc(corta(trozos(r.destino_rel)))}</b>`;
}
if (r.accion === 'renombrar') return `pasa a llamarse <b>${esc(ultimo(r.destino_rel))}</b>`;
if (!r.archivos && ultimo(r.destino_rel) === ultimo(r.origen_rel)) return esc(padreDe(r.destino_rel) || 'la raíz');
return esc(r.destino_rel);
}
// el nombre de cámara de cada carpeta de fuente, para decirlo en llano
const NOMBRE_CAMARA = {URSA_BRAW: 'la URSA (BRAW)', BLACKMAGIC_MP4: 'la Blackmagic (MP4)', SONY: 'la Sony', OSMO: 'la Osmo', DJI: 'el dron DJI', DJI_MIC: 'el DJI Mic', TASCAM: 'la Tascam', ZOOM: 'la Zoom', ATOMOS: 'el Atomos', CELULAR: 'celular', FOTOS: 'cámara de fotos', AUDIO: 'grabadora', OTROS: 'otra cámara'};
const SEG_EXPLICA = {alta: 'La app está segura.', media: 'Probable: la fecha sale del reloj de la cámara, que suele estar bien.', baja: 'Dudoso: la app no está segura; míralo antes de marcarlo.'};
function filaHtml(r, ej){
return `<div class="fila ${r.confianza} ${r.marcado ? '' : 'sinmarcar'} ${ej && ej.id === r.id ? 'esej' : ''}" data-id="${r.id}"><input type="checkbox" ${r.marcado ? 'checked' : ''} aria-label="marcar"><div class="de">${deHtml(r)}</div><span class="fl">→</span><div class="a">${aHtml(r)}${r.editado ? ' <span class="tag">destino cambiado</span>' : ''}</div><div class="meta">${r.n ? `${num(r.n)} arch. · ${gb(r.bytes)}` : ''}</div><div class="acts">${r.confianza !== 'alta' ? `<span class="tag ${r.confianza}">${r.cosmetico ? 'cosmético' : SEG_TXT[r.confianza]}</span>` : ''}<button class="ed" data-ej="${r.id}" title="Verlo arriba, como ejemplo">ejemplo</button>${r.archivos ? '' : `<button class="ed" data-ed="${r.id}" title="Cambiar a dónde va">cambiar destino</button>`}</div><div class="pq">${esc(motivoLlano(r.motivo))}</div></div>`;
return `<div class="fila ${r.confianza} ${r.marcado ? '' : 'sinmarcar'} ${ej && ej.id === r.id ? 'esej' : ''}" data-id="${r.id}"><input type="checkbox" ${r.marcado ? 'checked' : ''} aria-label="marcar"><div class="de">${deHtml(r)}</div><span class="fl">→</span><div class="a">${aHtml(r)}${r.editado ? ' <span class="tag">destino cambiado</span>' : ''}</div><div class="meta">${r.n ? `${num(r.n)} arch. · ${gb(r.bytes)}` : ''}</div><div class="acts">${r.confianza !== 'alta' ? `<span class="tag ${r.confianza}" title="${esc(r.cosmetico ? 'Solo cambia la forma del nombre.' : SEG_EXPLICA[r.confianza] || '')}">${r.cosmetico ? 'cosmético' : SEG_TXT[r.confianza]}</span>` : ''}<button class="ed" data-ej="${r.id}" title="Verlo arriba, como ejemplo">ejemplo</button>${r.archivos ? '' : `<button class="ed" data-ed="${r.id}" title="Cambiar a dónde va">cambiar destino</button>`}</div><div class="pq">${esc(motivoLlano(r.motivo))}</div></div>`;
}
function pintarLista(){
const caja = $('#planLista'), p = PASOS_PLAN.find(x => x.k === PLAN_PASO);
@@ -1659,11 +1763,21 @@ function pintarLista(){
const grupos = rs => { const g = {}; rs.forEach(r => (g[r.cubeta] = g[r.cubeta] || []).push(r)); return Object.keys(g).map(c => `<div class="grp">${esc(NOMBRE_CUBETA[c] || c)} · ${g[c].length}</div>` + g[c].map(r => filaHtml(r, ej)).join('')).join(''); };
const nt = sin.filter(r => r.no_tocar), cos = sin.filter(r => r.cosmetico && !r.no_tocar), dud = sin.filter(r => !r.cosmetico && !r.no_tocar);
const explica = [nt.length ? `${nt.length === 1 ? 'uno está' : nt.length + ' están'} dentro de una carpeta que dice «No tocar»: no se mueve${nt.length === 1 ? '' : 'n'} hasta que ${nt.length === 1 ? 'lo' : 'los'} marques tú` : '', cos.length ? `${cos.length === 1 ? 'uno solo cambia' : cos.length + ' solo cambian'} el nombre a la forma «NN- Título» (como «01 - Celular» → «01- Celular»): da igual hacerlo o no` : '', dud.length ? `${dud.length} ${dud.length === 1 ? 'es dudoso' : 'son dudosos'}: la app no está segura y no ${dud.length === 1 ? 'lo' : 'los'} mueve sin que lo marques tú` : ''].filter(Boolean).join('; ');
caja.innerHTML = `<div class="lista ${PLAN_POR_QUE ? 'porque' : ''}"><div class="lcab"><b>${marc.length ? `Lo que va a hacer este paso · ${marc.length}` : f ? 'Nada marcado con ese filtro' : 'Este paso no tiene nada marcado'}</b><span class="sep"></span><label class="mut" style="font-size:12.5px"><input type="checkbox" id="chkPorQue" ${PLAN_POR_QUE ? 'checked' : ''}> ver por qué</label></div>${grupos(marc)}${sin.length ? `<div class="opc"><div class="lcab"><div><b>Sin marcar · ${sin.length}</b> <span class="mut">${explica}.</span></div><span class="sep"></span>${cos.length ? `<button class="lnk" id="btnMarcarCosm">marcar los cosméticos</button>` : ''}${PLAN_PASO === 'banco' && sin.length > 1 ? `<button class="lnk" id="btnMarcarBanco">marcar los ${sin.length}</button>` : ''}<button class="lnk" id="btnVerOpc">${PLAN_VER_OPC ? 'ocultar la lista' : 'ver la lista'}</button></div>${PLAN_VER_OPC ? grupos(sin) : ''}</div>` : ''}</div>`;
const bancos = (PLAN && PLAN.bancos) || [];
const modoB = PLAN_PASO === 'banco' && bancos.length ? `<div class="modob">${bancos.map((b, i) => `<div>Cómo se ordena «${esc(b.nombre)}»: <button class="lnk ${b.modo !== 'fecha' ? 'on' : ''}" data-modob="${i}" data-m="temas">por temas</button> · <button class="lnk ${b.modo === 'fecha' ? 'on' : ''}" data-modob="${i}" data-m="fecha">por fecha</button> <span class="mut">${b.modo === 'fecha' ? 'Cada grabación va a la 00_Grabacion de su mes, con su tema de etiqueta; lo montado se queda en el banco.' : 'Los temas se quedan; dentro de cada uno, por fecha de rodaje y cámara.'}</span></div>`).join('')}</div>` : '';
const ejB = PLAN_PASO === 'banco' ? filasPaso('banco').find(r => r.archivos) : null;
const explicaB = ejB ? `<div class="hint" style="margin:0 0 10px">Este paso no saca nada del banco: <b>cada tema se queda donde está</b>, y dentro de él las grabaciones se separan <b>por día de rodaje y por cámara</b>. Por ejemplo, los ${ejB.archivos.length} clips de <span class="path">${esc(corta(trozos(ejB.origen_rel)))}</span> pasan a <span class="path">${esc(corta(trozos(ejB.destino_rel)))}</span>. Lo montado (exportados, proyectos, fotos sueltas) no se toca. Va todo sin marcar porque la carpeta dice «No tocar»: si te convence, «marcar los ${filasPaso('banco').length}» y aplicar; se deshace desde Historial.</div>` : '';
caja.innerHTML = modoB + explicaB + `<div class="lista ${PLAN_POR_QUE ? 'porque' : ''}"><div class="lcab"><b>${marc.length ? `Lo que va a hacer este paso · ${marc.length}` : f ? 'Nada marcado con ese filtro' : 'Este paso no tiene nada marcado'}</b><span class="sep"></span><label class="mut" style="font-size:12.5px"><input type="checkbox" id="chkPorQue" ${PLAN_POR_QUE ? 'checked' : ''}> ver por qué</label></div>${grupos(marc)}${sin.length ? `<div class="opc"><div class="lcab"><div><b>Sin marcar · ${sin.length}</b> <span class="mut">${explica}.</span></div><span class="sep"></span>${cos.length ? `<button class="lnk" id="btnMarcarCosm">marcar los cosméticos</button>` : ''}${PLAN_PASO === 'banco' && sin.length > 1 ? `<button class="lnk" id="btnMarcarBanco">marcar los ${sin.length}</button>` : ''}<button class="lnk" id="btnVerOpc">${PLAN_VER_OPC ? 'ocultar la lista' : 'ver la lista'}</button></div>${PLAN_VER_OPC ? grupos(sin) : ''}</div>` : ''}</div>`;
$('#chkPorQue').onchange = e => { PLAN_POR_QUE = e.target.checked; caja.querySelector('.lista').classList.toggle('porque', PLAN_POR_QUE); };
const bo = $('#btnVerOpc'); if (bo) bo.onclick = () => { PLAN_VER_OPC = !PLAN_VER_OPC; pintarLista(); };
const bc = $('#btnMarcarCosm'); if (bc) bc.onclick = () => { cos.forEach(r => r.marcado = true); pintarPlan(); };
const bb = $('#btnMarcarBanco'); if (bb) bb.onclick = () => { sin.forEach(r => r.marcado = true); pintarPlan(); };
$$('#planLista [data-modob]').forEach(b => b.onclick = () => accion(b, async () => {
const bco = bancos[+b.dataset.modob]; if (bco.modo === b.dataset.m) return;
await api('/api/banco/modo', {raiz: raizActual(), nombre: bco.nombre, modo: b.dataset.m});
PLAN = null; PLAN_PASO = 'banco'; await calcularPlan();
aviso(b.dataset.m === 'fecha' ? `«${bco.nombre}» se ordena ahora por fecha.` : `«${bco.nombre}» se ordena ahora por temas.`, 'ok');
}));
// marcar o desmarcar una fila cambia las cifras, no la lista: así el foco se queda donde está
$$('#planLista .fila').forEach(el => { const r = PLAN.filas.find(x => x.id === +el.dataset.id); el.querySelector('input[type=checkbox]').onchange = e => { r.marcado = e.target.checked; el.classList.toggle('sinmarcar', !r.marcado); pintarCabPlan(); estadoPlan(); }; });
$$('#planLista [data-ej]').forEach(b => b.onclick = () => { const i = ejemplosDe(PLAN_PASO).findIndex(x => x.id === +b.dataset.ej); if (i >= 0) { PLAN_EJ = i; pintarEjemplo(); pintarLista(); $('#ejCaja').scrollIntoView({behavior: 'smooth', block: 'nearest'}); } });
@@ -1679,11 +1793,27 @@ function pintarAvance(s){
caja.innerHTML = `<div class="avance"><div class="l1"><span class="spin"></span><b>Moviendo…</b><span>${u} % · ${s.hechas || 0} de ${s.total} cambio${s.total > 1 ? 's' : ''}</span><span class="sep"></span><span class="mut">${num(s.movidos || 0)} movimiento${s.movidos === 1 ? '' : 's'}${s.fallos ? ` · <span class="bad">${s.fallos} no se pudieron</span>` : ''}</span></div><div class="bar grande"><i style="width:${u}%"></i></div><div class="l3 mut">${s.actual ? `ahora: «${esc(s.actual)}»${s.archivo ? ' · ' + esc(s.archivo) : ''}` : 'empezando…'}</div></div>`;
return;
}
const r = s.res, falt = r.errores.length;
caja.innerHTML = `<div class="avance listo ${falt ? 'conerr' : ''}"><div class="l1"><span class="ico">${falt ? '!' : '✓'}</span><b>${falt ? 'Hecho, con tropiezos.' : 'Listo.'}</b><span>${r.filas} de ${s.pedidas} cambio${s.pedidas > 1 ? 's' : ''} · ${num(r.movimientos)} movimiento${r.movimientos === 1 ? '' : 's'} · ${gb(s.bytes)} · ${tiempoDe(s.seg)}</span><span class="sep"></span>${r.lote ? `<button class="lnk" id="avDeshacer">Deshacer esto</button>` : ''}</div><div class="bar grande"><i style="width:100%"></i></div>${falt ? `<div class="l3 bad">${falt} no se pudieron mover. <button class="lnk" id="avErr" style="padding:0 4px">ver cuáles</button></div>` : ''}<div class="l3 mut" id="avQuedan"><span class="spin"></span> mirando qué queda…</div></div>`;
const r = s.res, falt = r.fallos != null ? r.fallos : r.errores.length;
caja.innerHTML = `<div class="avance listo ${falt ? 'conerr' : ''}"><div class="l1"><span class="ico">${falt ? '!' : '✓'}</span><b>${falt ? 'Hecho, con tropiezos.' : 'Listo.'}</b><span>${r.filas} de ${s.pedidas} cambio${s.pedidas > 1 ? 's' : ''} · ${num(r.movimientos)} movimiento${r.movimientos === 1 ? '' : 's'} · ${gb(s.bytes)} · ${tiempoDe(s.seg)}</span><span class="sep"></span>${r.lote ? `<button class="lnk" id="avDeshacer">Deshacer esto</button>` : ''}</div><div class="bar grande"><i style="width:100%"></i></div>${falt ? `<div class="l3 bad">${num(falt)} no se pudieron mover. Nada se perdió: se quedaron donde estaban. <button class="lnk" id="avErr" style="padding:0 4px">ver cuáles</button></div>${motivosHtml(r.motivos)}` : ''}<div class="l3 mut" id="avQuedan"><span class="spin"></span> mirando qué queda…</div></div>`;
if (r.lote) $('#avDeshacer').onclick = () => deshacer(r.lote);
if (falt) $('#avErr').onclick = () => modal('No se pudieron mover', `<pre>${esc(r.errores.join('\n'))}</pre>`, null);
}
// por qué no se pudo mover algo, en llano, y qué hacer con cada clase de fallo
const MOTIVO_FALLO = {
existe: ['en el destino ya había un archivo con el mismo nombre', 'La app nunca renombra (Resolve y Premiere enlazan por nombre), así que esos se quedaron donde estaban. Suele ser la misma foto o el mismo clip dos veces, por ejemplo en «Alta» y en «Baja»: mira «Juntar material repetido», o calcula de nuevo y cambia el destino de esa fila.'],
en_uso: ['estaban abiertos en otro programa', 'Cierra Resolve, Premiere o la ventana del Explorador que los tenga abiertos, pulsa «Calcular de nuevo» y aplica: solo intentará lo que faltó.'],
permiso: ['Windows no dio permiso para moverlos', 'Suele ser una carpeta del NAS de solo lectura o de otro usuario. Revisa los permisos de esa carpeta compartida.'],
no_esta: ['ya no estaban donde decía el plan', 'Alguien los movió o los borró después de calcular. Pulsa «Calcular de nuevo».'],
copia: ['la copia entre discos no quedó completa', 'El original no se tocó. Vuelve a aplicar; si se repite, mira el espacio libre y la conexión con el NAS.'],
red: ['se cortó la conexión con el disco de red', 'Comprueba que la unidad sigue conectada y vuelve a aplicar: solo intentará lo que faltó.'],
otro: ['por otra razón', 'Mira la lista con «ver cuáles».'],
};
function motivosHtml(m){
const ks = Object.keys(m || {}).sort((a, b) => m[b].n - m[a].n);
if (!ks.length) return '';
return `<div class="motivos">${ks.map(k => { const [que, hacer] = MOTIVO_FALLO[k] || MOTIVO_FALLO.otro; const x = m[k];
return `<div class="mt"><b>${num(x.n)} porque ${esc(que)}.</b> ${esc(hacer)}${x.ejemplos && x.ejemplos.length ? `<div class="mut ej">Por ejemplo: ${x.ejemplos.slice(0, 3).map(e => `<span class="path">${esc(e)}</span>`).join(', ')}</div>` : ''}</div>`; }).join('')}</div>`;
}
function pintarQueda(){
const el = $('#avQuedan'); if (!el || !PLAN) return;
const sin = PLAN.filas.filter(r => !r.marcado), marc = PLAN.filas.filter(r => r.marcado);
@@ -2091,6 +2221,9 @@ function pintarAjustes(){
const ac = $('#actAuto');
ac.checked = (ESTADO.config.actualizaciones || {}).auto === true;
ac.onchange = () => api('/api/config', {actualizaciones: {auto: ac.checked}}).then(cargarEstado).then(() => aviso(ac.checked ? 'Al arrancar consultará si hay versión nueva.' : 'Ya no sale a la red al arrancar.', 'ok')).catch(e => aviso(e.message, 'bad'));
const ep = $('#escProfundo');
ep.checked = (ESTADO.config.escaneo || {}).profundo !== false;
ep.onchange = () => api('/api/config', {escaneo: {profundo: ep.checked}}).then(cargarEstado).then(() => aviso(ep.checked ? 'Escaneo profundo: después de escanear se leerá cada clip.' : 'El escaneo ya no lee los clips: solo nombres, tamaños y fechas.', 'ok')).catch(e => aviso(e.message, 'bad'));
if (!AJ) {
const c = ESTADO.config; AJ = {clientes: []};
Object.entries(c.clientes).forEach(([k, v]) => AJ.clientes.push({nombre: k, orig: k, tipo: v.tipo, alias: (v.alias || []).join('; '), rutas: (c.raices || []).filter(r => r.cliente === k).map(r => r.ruta)}));