3.3.0: lo que entra nuevo ya entra ordenado

Las cuatro funciones que eligió Munka tras la 3.2.0:

- Nueva pieza: crea «NN- Título» en su mes con el número que toca y sus subcarpetas; si la
  pieza ya existe, lo dice. Se deshace mientras siga vacía.
- Bandeja de entrada: lo que se deje en _Entrada se reparte por mes, día de rodaje y cámara.
  «Traer material» copia una tarjeta comprobando cada archivo (se vuelve a leer y se compara
  la huella), conserva la fecha del rodaje y no toca el origen.
- Versiones del mismo clip: dos clips de cámara que se llaman igual, duran lo mismo y tienen
  la misma forma son el mismo clip; se queda el de más imagen y el otro va a Duplicados.
- Orden automático, apagado de fábrica: una tarea del Programador de tareas de Windows lanza
  «automatico», que lee todas las carpetas y hace solo lo seguro. No toca una carpeta que no
  se pueda leer entera ni una cuyo plan mueva más de 3.000 archivos, y si el programa ya está
  abierto se lo pide a él. Desinstalar quita la tarea.

De paso: una ruta que empieza por guion bajo ya no pierde el guion al ir a la papelera.

Pruebas: prueba_orden.py 24, probar.py 19, prueba_funcional.py 29, prueba_ventana.py 18
desde el código y 17 con el paquete.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Este commit está contenido en:
Labstream
2026-10-04 17:05:57 -05:00
padre c419d5d943
commit 477857db45
Se han modificado 11 ficheros con 1344 adiciones y 54 borrados

Ver fichero

@@ -371,6 +371,7 @@
<div class="o"><button class="p" id="btnElegir2" type="button">Elegir carpeta…</button> &nbsp; o <u>pega la ruta</u></div>
</div>
<div id="portadaCarpetas"></div>
<div id="portadaAuto"></div>
<div class="vacio-pasos">
<div><b>1 · Escanear</b>Lee nombres, tamaños y fechas. Solo lectura.</div>
<div><b>2 · Revisar</b>Qué pesa: BRAW, cámara, stock, proxies, basura, repetidos.</div>
@@ -380,7 +381,7 @@
</div>
</div>
<div id="inicioLleno">
<div class="cab"><div><h2 class="v" id="cabTitulo">…</h2><div class="sub" id="cabSub"></div></div><div class="der"><button class="lnk" id="btnEscanear">Volver a escanear</button><button class="lnk" id="btnDesconectar" title="La carpeta deja de aparecer en la lista de la izquierda. No borra ni mueve nada del disco.">Quitar de la lista</button></div></div>
<div class="cab"><div><h2 class="v" id="cabTitulo">…</h2><div class="sub" id="cabSub"></div></div><div class="der"><button class="lnk" id="btnPieza" title="Crea la carpeta de una pieza con el número que toca y sus subcarpetas">Nueva pieza</button><button class="lnk" id="btnTraer" title="Copia una tarjeta a la bandeja de entrada comprobando cada archivo, y lo reparte">Traer material</button><button class="lnk" id="btnEscanear">Volver a escanear</button><button class="lnk" id="btnDesconectar" title="La carpeta deja de aparecer en la lista de la izquierda. No borra ni mueve nada del disco.">Quitar de la lista</button></div></div>
<div id="acciones" class="acc"></div>
<div class="sig" id="siguiente" style="display:none"></div>
<div class="row">
@@ -406,7 +407,7 @@
</div>
<details class="mas"><summary>Más detalle: cámaras, meses, tipos de archivo, repetidos, dónde vive cada cliente</summary>
<div class="row" style="margin-top:12px"><div class="box"><h3>Por fuente (de qué cámara viene)</h3><div id="fuentes"></div></div><div class="box"><h3>Material de cámara por mes</h3><div id="meses"></div></div></div>
<div class="row"><div class="box"><h3>Por tipo de archivo</h3><div id="exts"></div></div><div class="box"><h3>Repetidos (mismo nombre y peso, o el mismo clip en MP4 y MOV)</h3><div id="dups"></div></div></div>
<div class="row"><div class="box"><h3>Por tipo de archivo</h3><div id="exts"></div></div><div class="box"><h3>Repetidos (mismo nombre y peso, el mismo clip en MP4 y MOV, o con dos calidades)</h3><div id="dups"></div><div class="toolbar" style="margin-top:10px"><button id="btnComparar">Comparar y ver todos</button><button class="p" id="btnJuntar" disabled>Juntar en Duplicados</button><span class="mut" id="dupsResumen"></span></div></div></div>
<div class="box" style="margin-bottom:16px"><h3>Dónde vive cada cliente (todos los discos conectados)</h3><div id="clientesDiscos" class="mut">cargando…</div></div>
<div class="box"><h3>Discos</h3><div id="discos" class="mut">cargando…</div></div>
</details>
@@ -567,6 +568,9 @@
<dt>Escanear</dt><dd>Lee nombres, tamaños y fechas de todo lo que hay dentro. <i>Cómo: recorre la carpeta sin abrir los archivos, guarda un inventario y clasifica cada cosa por su extensión, su nombre y la carpeta donde está: cámara, proyecto, exportado, stock, proxy, basura…</i></dd>
<dt>Lista de acciones</dt><dd>Todo lo que se puede hacer ahora en esa carpeta, en orden, cada cosa con su cifra y su botón; lo ya hecho sale tachado. <i>Cómo: son consultas al inventario, por eso es instantánea.</i></dd>
<dt>Hacer las N de una vez</dt><dd>Ejecuta seguidas las acciones que no necesitan que elijas nada: apartar basura y proxies, ordenar por fecha, ordenar lo demás, apartar stock viejo y juntar repetidos. <i>Cómo: solo entra lo que el plan da por seguro o probable; lo dudoso no entra nunca, tampoco si es material de cámara. Convertir, respaldar y borrar carpetas vacías tienen su propio botón.</i></dd>
<dt>Nueva pieza</dt><dd>Crea la carpeta de una pieza en su mes, con el número que le toca y sus subcarpetas (Guion, Videos, Imagenes, Musica, Elements, IA, Portada, Exportado), para que todas se llamen igual. <i>Cómo: mira qué números hay ya en ese mes y pone el siguiente; si la pieza ya existe, lo dice y no crea otra. Mientras siga vacía se deshace desde el Historial, y no sale entre las carpetas vacías por borrar.</i></dd>
<dt>Bandeja de entrada</dt><dd>Lo que se deje en la carpeta <span class="path">_Entrada</span> del cliente (una tarjeta volcada, clips sueltos, una carpeta con el nombre de la sesión) aparece en la lista como «Repartir» y va solo a su mes, a su día de rodaje y a su cámara. <i>Cómo: el plan abre siempre esa carpeta. Los índices y miniaturas que deja la cámara en la tarjeta van a <span class="path">_Para borrar</span>; lo que no sabe dónde poner se queda en la bandeja.</i></dd>
<dt>Traer material</dt><dd>Copia una tarjeta, o cualquier carpeta de fuera, a la bandeja de entrada y la reparte. De la tarjeta no borra ni mueve nada. <i>Cómo: vuelve a leer cada archivo copiado y lo compara con el original; conserva la fecha del archivo, que es la del rodaje, y deja la lista de lo copiado con su huella en <span class="path">.organilab\entradas</span>. Si se corta, al repetirlo se retoma donde iba.</i></dd>
<dt>Quitar de la lista</dt><dd>La carpeta deja de aparecer en el programa y, si quieres, se olvida su inventario. En el disco no se toca nada.</dd>
</dl>
@@ -580,7 +584,7 @@
<dt>Repaso de lo ya ordenado</dt><dd>El plan también mira lo que ya está en <span class="path">00_Grabacion</span>: si un clip lleva en el nombre una fecha que no es la del mes donde está, propone llevarlo al suyo. <i>Cómo: solo con fechas escritas por la cámara; la fecha de modificación no cuenta, salvo para lo que se grabó ese mismo día.</i></dd>
<dt>Aplicar</dt><dd>Hace los movimientos marcados, siempre dentro de la carpeta del cliente. <i>Cómo: no pisa nada; si en el destino ya hay un archivo con ese nombre, lo deja donde está y lo avisa. Cada movimiento queda apuntado para poder deshacerlo.</i></dd>
<dt>Stock viejo</dt><dd>Aparta a <span class="path">_Para borrar\stock</span> las tomas de Envato de piezas de más de 1, 2, 3, 6 o 12 meses, que se vuelven a descargar con la suscripción. <i>Cómo: las reconoce por el nombre (terminan en «-utc») o por estar en una carpeta «Elements».</i></dd>
<dt>Repetidos</dt><dd>Cuando el mismo archivo está en varios sitios, deja uno y junta los demás en una subcarpeta <span class="path">Duplicados</span>. <i>Cómo: mismo nombre y mismo peso, y antes de apartar compara el contenido (principio, centro y final); si difiere, no lo toca.</i></dd>
<dt>Repetidos</dt><dd>Cuando el mismo archivo está en varios sitios, deja uno y junta los demás en una subcarpeta <span class="path">Duplicados</span>. También junta el mismo clip guardado con dos calidades: se queda el de más imagen. <i>Cómo: mismo nombre y mismo peso, y antes de apartar compara el contenido (principio, centro y final); si difiere, no lo toca. Dos clips de cámara que se llaman igual y pesan distinto se leen: si duran lo mismo son el mismo clip, y si no, son dos clips distintos y se dejan. Dos exportados con el mismo nombre no se tocan: son dos entregas de la pieza.</i></dd>
<dt>Carpetas vacías</dt><dd>Lista los huecos que deja ordenar y los borra. Es lo único que el programa borra, porque dentro no hay nada. <i>Cómo: cada una se comprueba en el disco justo antes; se puede deshacer, y no entra en «hacer de una vez».</i></dd>
</dl>
@@ -631,7 +635,8 @@
<h3>Sin abrir el programa</h3>
<dl>
<dt>Arrastrar una carpeta</dt><dd>Sobre el .exe o sobre «Organizar carpeta (arrastra aqui).bat»: la escanea, da el diagnóstico y pregunta paso a paso en una consola.</dd>
<dt>Por comando</dt><dd><span class="path">OrganizadorLabstream.exe ayuda</span> lista los comandos: asistente, organizar, escanear, stock, respaldo, duplicados, deshacer, actualizar, instalar y desinstalar.</dd>
<dt>Por comando</dt><dd><span class="path">OrganizadorLabstream.exe ayuda</span> lista los comandos: asistente, organizar, escanear, stock, respaldo, duplicados, deshacer, actualizar, automatico, instalar y desinstalar.</dd>
<dt>Orden automático</dt><dd>En la portada. Encendido, el programa pone al día todas las carpetas cada día a la hora que digas: lee cada una y hace solo lo seguro. El resumen de la última pasada queda en la portada, carpeta por carpeta, y «Hacerlo ahora» lo lanza a mano. Viene apagado. <i>Cómo: lo abre una tarea del Programador de tareas de Windows, con tu sesión iniciada, que es cuando están montados los discos. Si una carpeta no se puede leer entera, o si su plan mueve más de 3.000 archivos, no la toca y lo dice. Cada cambio se deshace desde el Historial.</i></dd>
</dl>
<h3>Lo que nunca hace</h3>
@@ -787,12 +792,21 @@ function detectarCambios(){
if (antes.asis && !ahora.asis && vistaActual() === 'carpeta') pintarAcciones().catch(() => {});
fin('asis', 'Listo: se hizo todo lo seguro de esa carpeta.' + (ESTADO.asistente.resultado && ESTADO.asistente.resultado.errores && ESTADO.asistente.resultado.errores.length ? ` ${ESTADO.asistente.resultado.errores.length} avisos.` : ''), {texto: 'Ver historial', ir: () => irA('historial')});
fin('resp', 'Copia de respaldo terminada.', {texto: 'Ver', ir: () => irA('respaldar')});
if (antes.traer && !ahora.traer) {
const t = ESTADO.traer || {}, o = t.ordenado;
if (t.estado === 'listo') aviso(`Material traído y comprobado: ${num(t.copiados)} archivo${t.copiados !== 1 ? 's' : ''}${t.saltados ? ` (${num(t.saltados)} ya estaban)` : ''}.` + (o && !o.error ? ` Repartido: ${o.movimientos} movimiento${o.movimientos !== 1 ? 's' : ''}${o.quedan ? `; quedan ${o.quedan} en la bandeja por mirar` : ''}.` : o && o.error ? ' No se pudo repartir: ' + o.error : ' Está en la bandeja de entrada.'), 'ok', o && o.lote ? {texto: 'Deshacer el reparto', ir: () => deshacer(o.lote)} : null);
else if (t.estado === 'cancelado') aviso('Se dejó de traer material. Lo ya copiado está en la bandeja de entrada; al volver a traerlo se retoma.', 'ok');
else aviso(`Al traer el material, ${t.errores_n} archivo${t.errores_n !== 1 ? 's' : ''} no se pudieron copiar bien. Lo demás está en la bandeja de entrada, sin repartir.`, 'bad');
cargarVista();
}
if (antes.auto && !ahora.auto) { aviso('Orden automático terminado.', 'ok'); if (!raizActual()) pintarAuto().catch(() => {}); else cargarVista(); }
if ((antes.cola !== ahora.cola || ahora.cola) && vistaActual() === 'convertir' && !document.querySelector('#convLista input:focus')) cargarCola();
}
function huella(E){
const h = {};
E.raices.forEach(r => { if (r.progreso && r.progreso.estado === 'corriendo') h['esc:' + r.clave] = 1; if (r.analisis && r.analisis.estado === 'corriendo') h['ana:' + r.clave] = 1; });
if (E.cola.activa) h.cola = 1; if (E.asistente.activo) h.asis = 1; if (E.respaldo.estado === 'corriendo') h.resp = 1;
if (E.traer && (E.traer.estado === 'copiando' || E.traer.estado === 'ordenando')) h.traer = 1; if (E.automatico && E.automatico.activo) h.auto = 1;
return h;
}
let SONDEO = null;
@@ -808,6 +822,81 @@ function limpiarTablas(){
}
$('#btnEscanear').onclick = () => accion($('#btnEscanear'), async () => { await api('/api/escanear', {raiz: raizActual()}); await cargarEstado(); });
// ---------------- pieza nueva ----------------
const MESES_LARGOS = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
$('#btnPieza').onclick = () => {
const raiz = raizActual(), hoy = new Date(), a0 = hoy.getFullYear();
modal('Nueva pieza', `<p>La app crea la carpeta de la pieza en su mes, con el número que le toca y sus subcarpetas, para que todas se llamen igual.</p>
<label for="pzTitulo">Título</label><input type="text" id="pzTitulo" style="width:100%;margin-top:4px" placeholder="Por ejemplo: Ozempic face" maxlength="80">
<div class="toolbar" style="margin-top:10px"><label>Mes <select id="pzMes">${MESES_LARGOS.map((m, i) => `<option value="${i + 1}" ${i === hoy.getMonth() ? 'selected' : ''}>${m}</option>`).join('')}</select></label><label>Año <select id="pzAnio">${[a0 - 1, a0, a0 + 1].map(a => `<option ${a === a0 ? 'selected' : ''}>${a}</option>`).join('')}</select></label></div>
<div id="pzPrevia" class="hint" style="margin-top:10px">Escribe el título para ver dónde va a quedar.</div>`,
() => accion($('#btnPieza'), async () => {
const r = await api('/api/pieza', {raiz, titulo: $('#pzTitulo').value, anio: +$('#pzAnio').value, mes: +$('#pzMes').value});
MAPA = null; PLAN = null;
aviso(`Creada «${r.rel}» con ${r.subcarpetas.length} subcarpetas.`, 'ok', {texto: 'Abrir la carpeta', ir: () => api('/api/abrir', {ruta: r.ruta}).catch(e => aviso(e.message, 'bad'))});
await cargarEstado(); cargarVista();
}));
$('#modalSi').textContent = 'Crear'; $('#modalSi').disabled = true;
let turno = 0;
const previa = async () => {
const t = $('#pzTitulo').value.trim(), mio = ++turno;
if (!t) { $('#pzPrevia').textContent = 'Escribe el título para ver dónde va a quedar.'; $('#modalSi').disabled = true; return; }
try {
const r = await api(`/api/pieza/previa?raiz=${encodeURIComponent(raiz)}&titulo=${encodeURIComponent(t)}&anio=${$('#pzAnio').value}&mes=${$('#pzMes').value}`);
if (mio !== turno) return;
$('#pzPrevia').innerHTML = r.existe ? `<span class="warn">Esa pieza ya existe: <span class="path">${esc(r.existe)}</span></span>` : `Va a quedar en <span class="path">${esc(r.rel)}</span>, con: ${r.subcarpetas.map(esc).join(' · ')}`;
$('#modalSi').disabled = !!r.existe;
} catch (e) { if (mio === turno) { $('#pzPrevia').innerHTML = `<span class="warn">${esc(e.message)}</span>`; $('#modalSi').disabled = true; } }
};
$('#pzTitulo').oninput = previa; $('#pzMes').onchange = previa; $('#pzAnio').onchange = previa;
setTimeout(() => $('#pzTitulo').focus(), 50);
};
// ---------------- bandeja de entrada: traer material de una tarjeta ----------------
$('#btnTraer').onclick = () => {
const raiz = raizActual();
modal('Traer material nuevo', `<p>La app copia lo que haya en la tarjeta (o en cualquier carpeta de fuera) a la bandeja de entrada de este cliente, <b>vuelve a leer cada archivo copiado para comprobar que quedó igual</b>, y lo reparte por mes, día de rodaje y cámara. <b>De la tarjeta no borra ni mueve nada.</b></p>
<div class="toolbar"><input type="text" id="trOrigen" placeholder="La tarjeta o la carpeta con el material" style="flex:1;min-width:220px" aria-label="De dónde traerlo"><button id="trElegir" type="button">Elegir…</button></div>
<label for="trNombre" style="display:block;margin-top:10px">Nombre de la sesión <span class="mut">(si quieres: «Entrevista», «Evento marzo»…)</span></label><input type="text" id="trNombre" style="width:100%;margin-top:4px" maxlength="60">
<label style="display:block;margin-top:10px"><input type="checkbox" id="trOrdenar" checked> repartirlo al terminar de copiar</label>
<div id="trPrevia" class="hint" style="margin-top:10px">Elige de dónde traerlo.</div>
<p class="mut" style="font-size:12.5px;margin:10px 0 0">También puedes dejar el material a mano en la bandeja: <a href="#" id="trAbrir">abrir la bandeja de entrada</a>. Lo que dejes ahí sale en la lista de la carpeta como «Repartir».</p>`,
() => accion($('#btnTraer'), async () => {
const r = await api('/api/entrada/traer', {raiz, origen: $('#trOrigen').value.trim(), nombre: $('#trNombre').value.trim(), ordenar: $('#trOrdenar').checked});
aviso(`Trayendo ${num(r.n)} archivo${r.n !== 1 ? 's' : ''} (${gb(r.bytes)}). El avance está en la barra de abajo.`, 'ok');
await cargarEstado();
}));
$('#modalSi').textContent = 'Traer'; $('#modalSi').disabled = true;
const previa = async () => {
const o = $('#trOrigen').value.trim();
if (!o) { $('#trPrevia').textContent = 'Elige de dónde traerlo.'; $('#modalSi').disabled = true; return; }
try {
const r = await api('/api/entrada/plan', {raiz, origen: o, nombre: $('#trNombre').value.trim()});
$('#trPrevia').innerHTML = `Son <b>${num(r.n)}</b> archivo${r.n !== 1 ? 's' : ''} · <b>${gb(r.bytes)}</b>. Se copian a <span class="path">_Entrada\\${esc(r.carpeta)}</span>${r.libre != null ? ` (en el disco quedan ${gb(r.libre)} libres)` : ''}.`;
$('#modalSi').disabled = false;
} catch (e) { $('#trPrevia').innerHTML = `<span class="warn">${esc(e.message)}</span>`; $('#modalSi').disabled = true; }
};
$('#trElegir').onclick = seguro(async () => { const r = await api('/api/elegir-carpeta', {para: 'traer'}); if (r.ruta) { $('#trOrigen').value = r.ruta; previa(); } });
$('#trOrigen').onchange = previa; $('#trNombre').onchange = previa;
$('#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')); };
};
// ---------------- 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(){
const caja = $('#portadaAuto'); if (!caja) return;
const a = await api('/api/automatico'); const u = a.ultimo, m = a.en_marcha || {};
const filas = u && u.carpetas ? u.carpetas.map(c => { const e = ESTADO_AUTO[c.estado] || ['', c.estado]; return `<tr><td><b>${esc(c.cliente || c.raiz)}</b></td><td><span class="${e[0]}">${esc(e[1])}</span></td><td>${c.movimientos ? num(c.movimientos) + ' movimiento' + (c.movimientos !== 1 ? 's' : '') : '—'}</td><td class="mut">${esc([c.dudosos ? c.dudosos + ' dudoso' + (c.dudosos !== 1 ? 's' : '') + ' por mirar' : '', c.detalle || '', (c.errores || []).length ? (c.errores || []).length + ' avisos' : ''].filter(Boolean).join(' · '))}</td></tr>`; }).join('') : '';
caja.innerHTML = `<h3 style="margin:22px 0 8px">Orden automático</h3><div class="box">
<label style="display:flex;gap:8px;align-items:center;flex-wrap:wrap"><input type="checkbox" id="autoActivo" ${a.activo ? 'checked' : ''}> Poner al día todas las carpetas cada día a las <input type="time" id="autoHora" value="${esc(a.hora)}" style="width:110px"></label>
<p class="mut" style="font-size:12.5px;margin:8px 0 0">Windows abre el programa a esa hora, lee cada carpeta y hace solo lo seguro; lo dudoso lo deja para ti, y todo se deshace desde el Historial. Hace falta el equipo encendido y tu sesión abierta. Si en una carpeta el plan mueve más de ${num(a.limite)} archivos, no la toca y lo dice.</p>
<div class="toolbar" style="margin-top:10px"><button id="autoAhora" ${m.activo ? 'disabled' : ''}>${m.activo ? 'En marcha…' : 'Hacerlo ahora'}</button><span class="mut">${u ? `Última pasada: ${fecha(u.ts)} (${u.origen === 'programado' ? 'programada' : 'a mano'})` : 'Todavía no ha corrido ninguna vez.'}</span></div>
${filas ? `<table style="margin-top:8px"><tbody>${filas}</tbody></table>` : ''}</div>`;
const guardar = seguro(async () => { try { await api('/api/automatico/config', {activo: $('#autoActivo').checked, hora: $('#autoHora').value || '02:00'}); aviso($('#autoActivo').checked ? `Orden automático encendido: cada día a las ${$('#autoHora').value}.` : 'Orden automático apagado.', 'ok'); } finally { pintarAuto().catch(() => {}); } });
$('#autoActivo').onchange = guardar; $('#autoHora').onchange = () => { if ($('#autoActivo').checked) guardar(); };
$('#autoAhora').onclick = () => modal('Poner al día todas las carpetas', `<p>La app va a leer las <b>${(ESTADO.raices || []).length}</b> carpetas registradas, una tras otra, y a hacer en cada una solo lo seguro. Lo dudoso se queda como está. Nada se borra, y cada cambio se deshace desde el Historial.</p>`, () => accion($('#autoAhora'), async () => { await api('/api/automatico/ahora', {}); aviso('En marcha. El avance está en la barra de abajo.', 'ok'); await cargarEstado(); pintarAuto().catch(() => {}); }));
}
// ---------------- barra de tareas ----------------
function tareasDe(E){
const T = []; const porNombre = k => (E.raices.find(r => r.clave === k) || {cliente: k}).cliente;
@@ -819,6 +908,8 @@ function tareasDe(E){
if (E.asistente.activo) { const a = E.asistente; T.push({raiz: a.raiz, q: 'Asistente: ' + (NOMBRE_PASO[a.paso] || a.paso || '…'), d: `${porNombre(a.raiz)} · ${a.total ? num(a.hechas) + ' de ' + num(a.total) + ' · ' : ''}${(a.texto || '').slice(-60)}`, pct: a.total ? a.pct : null}); }
else if (E.aplicando && E.aplicando.total && E.aplicando.hechas < E.aplicando.total) T.push({q: 'Moviendo', d: E.aplicando.actual || '', pct: Math.round(E.aplicando.hechas * 100 / E.aplicando.total)});
if (E.respaldo.estado === 'corriendo') { const e = E.respaldo; T.push({q: 'Copiando respaldo', d: `${num(e.hechos)} de ${num(e.total)} · ${gb(e.bytes)} de ${gb(e.bytes_total)}`, pct: e.bytes_total ? Math.round(e.bytes / e.bytes_total * 100) : null, cancelar: () => api('/api/respaldo/detener', {})}); }
if (E.traer && (E.traer.estado === 'copiando' || E.traer.estado === 'ordenando')) { const t = E.traer; const cop = t.estado === 'copiando'; T.push({raiz: t.raiz, q: cop ? 'Trayendo material' : 'Repartiendo lo traído', d: `${porNombre(t.raiz)} · ${num(t.hechos)} de ${num(t.total)} · ${gb(t.bytes)} de ${gb(t.bytes_total)}${t.actual ? ' · ' + t.actual.slice(-40) : ''}`, pct: cop && t.bytes_total ? Math.round(t.bytes / t.bytes_total * 100) : null, cancelar: cop ? () => api('/api/entrada/cancelar', {}) : null}); }
if (E.automatico && E.automatico.activo) { const a = E.automatico; T.push({raiz: a.raiz, q: 'Orden automático', d: `${a.raiz ? porNombre(a.raiz) + ' · ' : ''}${a.hechas} de ${a.total} carpetas`, pct: a.total ? Math.round(a.hechas * 100 / a.total) : null}); }
const act = E.actualizacion || {}; if (act.estado === 'descargando' || act.estado === 'verificando') T.push({q: 'Descargando versión nueva', d: `${(act.bytes / 1e6).toFixed(0)} de ${(act.total / 1e6).toFixed(0)} MB`, pct: act.pct});
return T;
}
@@ -896,7 +987,8 @@ function pintarPortada(){
// las carpetas que el programa ya conoce, para entrar en una con un clic; con la última vez que se
// escaneó cada una. Es el «historial» de la portada: nada se abre solo.
const caja = $('#portadaCarpetas'); const rs = ESTADO.raices || [];
if (!rs.length) { caja.innerHTML = ''; return; }
if (!rs.length) { caja.innerHTML = ''; $('#portadaAuto').innerHTML = ''; return; }
pintarAuto().catch(e => console.warn(e));
caja.innerHTML = '<h3 style="margin:22px 0 8px">Tus carpetas</h3><div class="portada">' + rs.map(r => {
const e = r.escaneo;
return `<div class="pc" data-r="${esc(r.clave)}" role="button" tabindex="0"><b>${esc(r.cliente)}</b><span class="path">${esc(r.ruta)}</span><span class="mut">${esc(ESTADO.tipos[r.tipo] || r.tipo)}</span><span class="mut">${e ? `${num(e.n)} archivos · ${gb(e.bytes)} · escaneada ${fecha(e.ts)}` : 'sin escanear todavía'}</span></div>`;
@@ -923,7 +1015,7 @@ async function cargarCarpeta(){
$('#inicioDiag').innerHTML = d.frases.slice(0, 3).map(f => `<div>${esc(f)}</div>`).join('') + (d.frases.length > 3 ? `<div class="mut">Más en Revisar y ordenar.</div>` : '');
}
const lotes = CARPETA.lotes || [];
$('#inicioLotes').innerHTML = lotes.length ? lotes.map(l => `<div class="ev"><span class="h">${esc(fechaCorta(l.ts))}</span><span class="q"><b>${esc(queSeHizo(l.lote))}</b> · ${l.movimientos} mov.${l.errores ? ` <span class="bad">· ${l.errores} avisos</span>` : ''}</span>${l.lote && l.lote.startsWith('optimizar') ? '' : `<button data-l="${esc(l.lote)}">Deshacer</button>`}</div>`).join('') : '<span class="mut">Todavía no se ha movido nada en esta carpeta.</span>';
$('#inicioLotes').innerHTML = lotes.length ? lotes.map(l => `<div class="ev"><span class="h">${esc(fechaCorta(l.ts))}</span><span class="q"><b>${esc(queSeHizo(l.lote))}</b> · ${l.movimientos} mov.${l.errores ? ` <span class="bad">· ${l.errores} avisos</span>` : ''}</span>${l.lote && (l.lote.startsWith('optimizar') || l.lote.startsWith('entrada-')) ? '' : `<button data-l="${esc(l.lote)}">Deshacer</button>`}</div>`).join('') : '<span class="mut">Todavía no se ha movido nada en esta carpeta.</span>';
$$('#inicioLotes button[data-l]').forEach(bt => bt.onclick = () => deshacer(bt.dataset.l));
}
// ---------------- lo que se puede hacer en esta carpeta ----------------
@@ -939,7 +1031,16 @@ const IR_ACCION = {
convertir: () => { irA('convertir'); $('#btnBuscarClips').click(); },
respaldar: () => irA('respaldar'),
escanear: () => $('#btnEscanear').click(),
entrada: () => { modal('Repartir la bandeja de entrada', '<p>La app vuelve a leer la carpeta y lleva lo que hay en <span class="path">_Entrada</span> a su mes, a su día de rodaje y a su cámara.</p><p>Solo mueve lo que da por seguro: lo dudoso se queda en la bandeja para que lo mires. Nada se borra y se deshace desde el Historial.</p>', repartirEntrada); $('#modalSi').textContent = 'Repartir'; },
};
const repartirEntrada = seguro(async () => {
const raiz = raizActual();
aviso('Leyendo la carpeta y repartiendo la bandeja…', 'ok');
const r = await api('/api/entrada/ordenar', {raiz});
PLAN = null; MAPA = null;
aviso(`${r.movimientos} movimiento${r.movimientos !== 1 ? 's' : ''} hecho${r.movimientos !== 1 ? 's' : ''}.` + (r.quedan ? ` Quedan ${num(r.quedan)} archivo${r.quedan !== 1 ? 's' : ''} en la bandeja: la app no supo dónde ponerlos, míralos en «Qué va a pasar».` : ' La bandeja quedó vacía.') + (r.errores.length ? ` ${r.errores.length} no se pudieron mover.` : ''), r.errores.length ? 'bad' : 'ok', r.lote ? {texto: 'Deshacer esto', ir: () => deshacer(r.lote)} : null, r.errores.join('\n'));
await cargarEstado(); cargarVista();
});
async function pintarAcciones(){
const raiz = raizActual(); if (!raiz) return;
const d = await api('/api/carpeta/acciones?raiz=' + encodeURIComponent(raiz));
@@ -971,6 +1072,8 @@ function queSeHizo(lote){
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';
if (lote.startsWith('pieza-')) return 'Creó una pieza nueva';
if (lote.startsWith('entrada-')) return 'Trajo material a la bandeja de entrada';
return 'Ordenó carpetas y archivos';
}
function pasosDe(C, r){
@@ -1070,6 +1173,24 @@ async function cargarMapa(){
$('#exts').innerHTML = barras(m.por_ext, r => r.ext || '(sin extensión)');
$('#dups').innerHTML = m.duplicados.ejemplos.length ? m.duplicados.ejemplos.map(d => `<div style="margin-bottom:8px"><b>${esc(d.nombre)}</b> <span class="mut">${d.tipo === 'version' ? 'dos formatos' : 'idénticos'} · ${d.copias} copias · ${gb(d.bytes)} de más</span><ul class="ejemplos" style="margin:2px 0 0 16px;padding:0"><li>se queda: ${esc(d.queda)}</li>${d.rutas.map(r => `<li>→ Duplicados: ${esc(r)}</li>`).join('')}</ul></div>`).join('') : '<span class="mut">ninguno relevante</span>';
}
// repetidos: la lista entera, comparando también los clips que se llaman igual y pesan distinto
const TIPO_DUP = {identico: 'idénticos', version: 'dos formatos', calidad: 'dos calidades'};
let DUPS = null;
$('#btnComparar').onclick = () => accion($('#btnComparar'), async () => {
const raiz = raizActual();
DUPS = await api('/api/duplicados?calidades=1&raiz=' + encodeURIComponent(raiz)); DUPS.raiz = raiz;
const rel = p => p.toLowerCase().startsWith(raiz.toLowerCase()) ? p.slice(raiz.length).replace(/^[\\/]/, '') : p;
$('#dups').innerHTML = DUPS.grupos.length ? DUPS.grupos.slice(0, 80).map(d => `<div style="margin-bottom:8px"><b>${esc(d.nombre)}</b> <span class="mut">${TIPO_DUP[d.tipo] || d.tipo} · ${gb(d.bytes)} de más${d.detalle ? ' · ' + esc(d.detalle) : ''}</span><ul class="ejemplos" style="margin:2px 0 0 16px;padding:0"><li>se queda: ${esc(rel(d.queda))}</li>${d.mueven.map(r => `<li>→ Duplicados: ${esc(rel(r))}</li>`).join('')}</ul></div>`).join('') + (DUPS.grupos.length > 80 ? `<div class="mut">… y ${DUPS.grupos.length - 80} grupos más</div>` : '') : '<span class="mut">Nada repetido: los clips que se llaman igual duran distinto, así que son clips distintos.</span>';
$('#dupsResumen').textContent = DUPS.grupos.length ? `${DUPS.n} de más · ${gb(DUPS.bytes)} · ${DUPS.identicos} idénticos, ${DUPS.versiones} en dos formatos, ${DUPS.calidades} con dos calidades` : '';
$('#btnJuntar').disabled = !DUPS.grupos.length;
});
$('#btnJuntar').onclick = () => { if (!DUPS || DUPS.raiz !== raizActual()) return; modal('Juntar material repetido', `<p>De cada grupo se queda un archivo donde está, y los demás (<b>${DUPS.n}</b>, ${gb(DUPS.bytes)}) van a una subcarpeta <span class="path">Duplicados</span> a su lado, para mirarlos a mano.</p><p><b>No se borra nada.</b> Las copias idénticas se comparan por contenido antes de moverlas, y de dos calidades del mismo clip se queda la de más imagen. Se deshace desde el Historial.</p>`, () => accion($('#btnJuntar'), async () => {
const r = await api('/api/duplicados/apartar', {raiz: raizActual()});
DUPS = null; MAPA = null; PLAN = null; $('#btnJuntar').disabled = true; $('#dupsResumen').textContent = '';
aviso(`${r.movimientos} archivo${r.movimientos !== 1 ? 's' : ''} a Duplicados.` + (r.errores.length ? ` ${r.errores.length} se dejaron donde estaban.` : ''), r.errores.length ? 'bad' : 'ok', r.lote ? {texto: 'Deshacer esto', ir: () => deshacer(r.lote)} : null, r.errores.join('\n'));
await cargarEstado(); cargarVista();
})); $('#modalSi').textContent = 'Juntar'; };
function treemap(el, items){
el.innerHTML = ''; const W = el.clientWidth || 600, H = el.clientHeight || 300;
const total = items.reduce((s,i) => s + i.bytes, 0) || 1;
@@ -1371,7 +1492,7 @@ async function cargarBit(){
// Avisos de error recientes (los globos rojos), con su detalle: para saber qué pasó después de que desaparecieron.
const av = avisosGuardados().filter(a => !solo || !raiz || (a.raiz || '').toLowerCase() === raiz);
const avHtml = av.length ? `<div class="dia">Avisos recientes <button class="lnk" id="btnBorrarAvisos" style="margin-left:8px;font-size:12px">borrar</button></div>` + av.slice(0, 20).map(a => `<div class="ev"><span class="h">${hora(a.ts)}</span><span class="q"><b>${esc(a.texto)}</b>${!solo && a.raiz ? ` · <span class="mut">${esc(a.raiz.split(/[\\/]/).filter(Boolean).pop())}</span>` : ''}${a.detalle ? `<details><summary>detalle</summary><pre class="cell-small" style="white-space:pre-wrap">${esc(a.detalle)}</pre></details>` : ''}</span><span></span></div>`).join('') : '';
$('#historial').innerHTML = avHtml + (Object.keys(dias).map(k => `<div class="dia">${esc(titulo(k))}</div>` + dias[k].map(l => `<div class="ev"><span class="h">${hora(l.ts)}</span><span class="q"><b>${esc(queSeHizo(l.lote))}</b> · ${l.movimientos} movimiento${l.movimientos !== 1 ? 's' : ''}${!solo ? ` · <span class="mut">${esc((l.raiz || '').split(/[\\/]/).filter(Boolean).pop() || l.raiz)}</span>` : ''}${l.deshecho ? ' <span class="tag">deshecho</span>' : ''}${l.errores ? ` <span class="tag baja">${l.errores} avisos</span>` : ''}${l.ejemplos.length ? `<details><summary>ejemplos</summary><div class="cell-small">${l.ejemplos.map(e => esc(e.de.split(/[\\/]/).slice(-2).join('/')) + ' → ' + esc(e.a.split(/[\\/]/).slice(-3).join('/'))).join('<br>')}</div></details>` : ''}</span>${l.deshecho ? '<span></span>' : `<button data-l="${esc(l.lote)}">Deshacer</button>`}</div>`).join('')).join('') || '<div class="mut">Todavía no se ha movido nada.</div>');
$('#historial').innerHTML = avHtml + (Object.keys(dias).map(k => `<div class="dia">${esc(titulo(k))}</div>` + dias[k].map(l => `<div class="ev"><span class="h">${hora(l.ts)}</span><span class="q"><b>${esc(queSeHizo(l.lote))}</b> · ${l.movimientos} movimiento${l.movimientos !== 1 ? 's' : ''}${!solo ? ` · <span class="mut">${esc((l.raiz || '').split(/[\\/]/).filter(Boolean).pop() || l.raiz)}</span>` : ''}${l.deshecho ? ' <span class="tag">deshecho</span>' : ''}${l.errores ? ` <span class="tag baja">${l.errores} avisos</span>` : ''}${l.ejemplos.length ? `<details><summary>ejemplos</summary><div class="cell-small">${l.ejemplos.map(e => esc(e.de.split(/[\\/]/).slice(-2).join('/')) + ' → ' + esc(e.a.split(/[\\/]/).slice(-3).join('/'))).join('<br>')}</div></details>` : ''}</span>${l.deshecho || (l.lote || '').startsWith('entrada-') ? '<span></span>' : `<button data-l="${esc(l.lote)}">Deshacer</button>`}</div>`).join('')).join('') || '<div class="mut">Todavía no se ha movido nada.</div>');
$$('#historial button[data-l]').forEach(b => b.onclick = () => deshacer(b.dataset.l));
const ba = $('#btnBorrarAvisos'); if (ba) ba.onclick = () => { try { localStorage.removeItem(AVISOS_CLAVE); } catch (e) {} cargarBit().catch(e => aviso(e.message, 'bad')); };
}
@@ -1762,7 +1883,7 @@ const asistente = seguro(async (raiz, escanearAntes) => {
const NOMBRE_PASO = {plan: 'pensando el plan', ordenar: 'ordenando carpetas y archivos', stock: 'apartando stock viejo', duplicados: 'juntando repetidos', analizar: 'leyendo los clips', convertir: 'convirtiendo a 10 bits', respaldo: 'copiando el respaldo', fin: 'listo'};
// ---------------- modal ----------------
function modal(titulo, html, onSi){ $('#modalTitulo').textContent = titulo; $('#modalCuerpo').innerHTML = html; $('#modal').classList.add('on'); $('#modalSi').style.display = onSi ? '' : 'none'; $('#modalSi').textContent = 'Confirmar'; $('#modalNo').textContent = onSi ? 'Cancelar' : 'Cerrar'; $('#modalSi').onclick = () => { $('#modal').classList.remove('on'); onSi && onSi(); }; $('#modalNo').onclick = () => $('#modal').classList.remove('on'); setTimeout(() => { const f = $('#modalCuerpo input, #modalCuerpo select') || $('#modalSi'); if (f && f.offsetParent) f.focus(); }, 30); }
function modal(titulo, html, onSi){ $('#modalTitulo').textContent = titulo; $('#modalCuerpo').innerHTML = html; $('#modal').classList.add('on'); $('#modalSi').style.display = onSi ? '' : 'none'; $('#modalSi').textContent = 'Confirmar'; $('#modalSi').disabled = false; $('#modalNo').textContent = onSi ? 'Cancelar' : 'Cerrar'; $('#modalSi').onclick = () => { $('#modal').classList.remove('on'); onSi && onSi(); }; $('#modalNo').onclick = () => $('#modal').classList.remove('on'); setTimeout(() => { const f = $('#modalCuerpo input, #modalCuerpo select') || $('#modalSi'); if (f && f.offsetParent) f.focus(); }, 30); }
document.addEventListener('keydown', e => { if (e.key === 'Escape' && $('#modal').classList.contains('on')) $('#modal').classList.remove('on'); });
$('#modal').addEventListener('click', e => { if (e.target === $('#modal')) $('#modal').classList.remove('on'); });