3.2.0: el orden mira la forma de las carpetas, no solo su nombre

Medido sobre una carpeta de cliente real (8.207 archivos, 920 GB) antes de tocar nada:

- «Por nombrar» solo pregunta por lo que el plan llega a ver y no sabe resolver; ya no por las
  carpetas de la app ni por lo de dentro de la papelera. Lo aprendido vale por el nombre entero.
- Una carpeta es una grabación por lo que tiene dentro: una pieza montada no se desarma, y lo
  que no es rodaje no acaba en 00_Grabacion\OTROS. Se conserva el nombre de cada sesión.
- Una carpeta que guarda meses o años dentro se abre y cada cosa va a su mes; si la pieza ya
  existe allí, se junta con ella.
- Repaso de lo ya ordenado: lo que está en un mes que no es el de su rodaje vuelve al suyo.
- El año de un clip de Blackmagic sale de los demás clips de su tarjeta y día; un rodaje que
  pasa de medianoche cuenta como uno.
- Un clip convertido sigue siendo de su cámara, y el convertidor no repite lo que ya convirtió.
- «Exportados» junto a «_Exportados» y «_Papelera» junto a «_Para borrar» se juntan.
- «Hacer de una vez» solo mueve lo marcado; lo dudoso que queda no cuenta como pendiente.
- El inventario lleva la versión de las reglas: uno anterior pide escanear otra vez.

Pruebas: prueba_orden.py (nueva, 19 casos), probar.py 19, prueba_funcional.py 26,
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 16:33:45 -05:00
padre 45ece8f2e7
commit c419d5d943
Se han modificado 8 ficheros con 1334 adiciones y 184 borrados

Ver fichero

@@ -264,6 +264,7 @@
.nombrar li .r{display:block}
.nombrar li .sg{display:block;font-size:12px;color:var(--acc);margin-top:3px;white-space:normal}
.nombrar li .sg b{display:inline}
.nombrar li .nt{display:block;font-size:12px;color:var(--mut);margin-top:3px;white-space:normal}
.nombrar li .acts button{white-space:nowrap}
.nombrar li .acts{display:flex;gap:6px;align-items:center;justify-content:flex-end}
.nombrar li .acts select{max-width:230px}
@@ -425,8 +426,8 @@
</div>
<div class="sub" id="s-nombrar">
<p class="ayuda">La app mira qué hay dentro de cada carpeta y propone qué es. Confirma con un clic o elige otra cosa; se lo aprende para todos los discos. <span class="q">?</span><span class="mas">Solo se ordenan solas las carpetas reconocidas. Las que dejes sin nombrar no se mueven. Una sugerencia «segura» sale cuando casi todo el contenido es de un mismo tipo (por ejemplo, 90 % clips de cámara).</span></p>
<div class="toolbar"><button id="btnAceptarSug" class="p" disabled>Aceptar las sugerencias seguras</button><input type="text" id="filtroVoc" placeholder="buscar carpeta…" style="min-width:200px"><label><input type="checkbox" id="soloSin" checked> solo sin reconocer</label><label><input type="checkbox" id="sinPiezas" checked> ocultar lo de dentro de piezas</label><span class="sep"></span><span id="vocResumen" class="mut"></span></div>
<p class="ayuda">Aquí solo salen las carpetas sobre las que hay algo que decidir: las que la app no sabe qué son, y que no va a mover hasta que se lo digas. Confirma la propuesta con un clic o elige otra cosa. <span class="q">?</span><span class="mas">No salen las carpetas de la propia app, ni lo de dentro de las piezas, ni lo que ya se ordena solo: una carpeta llena de clips de cámara se reparte sin preguntar, y un cajón con meses dentro se abre. La propuesta sale de la forma de la carpeta (si trae sus exportados, su portada y su música es una pieza; si guarda meses, es un cajón) y de lo que tiene dentro contando archivos, no peso. Lo que confirmes vale para las carpetas que se llamen exactamente igual, en cualquier disco. Quita «solo sin reconocer» para ver y corregir lo que ya reconoce.</span></p>
<div class="toolbar"><button id="btnAceptarSug" class="p" disabled>Aceptar las sugerencias seguras</button><input type="text" id="filtroVoc" placeholder="buscar carpeta…" style="min-width:200px"><label><input type="checkbox" id="soloSin" checked> solo sin reconocer</label><span class="sep"></span><span id="vocResumen" class="mut"></span></div>
<div class="box"><ul class="nombrar" id="nombrar"><li class="mut">cargando…</li></ul></div>
<details class="mas"><summary>Reglas exactas aprendidas al editar un destino en el plan</summary><div id="reglas" class="mut" style="margin-top:8px">ninguna todavía</div></details>
</div>
@@ -565,15 +566,18 @@
<dl>
<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: lo dudoso no entra nunca; convertir, respaldar y borrar carpetas vacías tienen su propio botón.</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>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>
<h3>Revisar y ordenar</h3>
<dl>
<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>
<dt>Por nombrar</dt><dd>Las carpetas cuyo nombre no reconoce. Propone qué son y tú lo confirmas. <i>Cómo: mira qué tienen dentro; lo confirmado se guarda en un diccionario y vale para todas las carpetas y discos.</i></dd>
<dt>Por nombrar</dt><dd>Las carpetas que no sabe qué son y que no moverá hasta que se lo digas. Propone qué es cada una y tú lo confirmas. No pregunta por las carpetas de la propia app, ni por lo de dentro de las piezas, ni por lo que ya ordena solo. <i>Cómo: mira la forma (si trae sus exportados, su portada y su música es una pieza; si guarda meses dentro, un cajón) y lo que tiene dentro contando archivos, no peso. Lo confirmado se guarda en un diccionario y vale para las carpetas que se llamen exactamente igual, en cualquier disco.</i></dd>
<dt>Qué va a pasar</dt><dd>El plan: a dónde iría cada cosa, sin mover nada. Va en tres pasos que se aplican por separado: apartar basura y proxies, ordenar por año y mes (y por día de rodaje si ese mes hubo más de uno), y ordenar lo demás (proyectos por programa, exportados, música…). <i>Cómo: usa la plantilla de la clase de trabajo y la fecha de cada archivo, la más antigua entre creación y modificación; un render hereda la fecha de su original. Cada fila va marcada como segura, por revisar o dudosa.</i></dd>
<dt>Grabaciones</dt><dd>Una carpeta de grabación se reparte por mes, día y cámara, y conserva el nombre de cada sesión («Tomas consultorio»). Un clip ya convertido sigue contando como de su cámara. <i>Cómo: solo se reparte lo que de verdad es una tarjeta volcada; si dentro hay una pieza montada (exportados, portada, proyecto), no se desarma. El año de un clip de Blackmagic, que el nombre no trae, sale del resto de clips de la misma tarjeta y día, y un rodaje que pasa de medianoche cuenta como uno solo.</i></dd>
<dt>Cajones</dt><dd>Una carpeta que guarda meses o años dentro («Orden», «Proyectos\Mayo») no se mueve entera: se abre, y cada cosa va a su mes. Si una pieza ya existe en ese mes, se junta con ella en vez de quedar una carpeta gemela al lado.</dd>
<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>
@@ -591,7 +595,7 @@
<h3>Convertidor</h3>
<dl>
<dt>Buscar clips</dt><dd>Encuentra los clips de cámara viejos y pesados (de fábrica, de más de 3 meses y 300 MB) y calcula cuánto pesarían convertidos. <i>Cómo: lee códec, duración y resolución con ffprobe; se salta lo que ya es ligero y los exportados.</i></dd>
<dt>Buscar clips</dt><dd>Encuentra los clips de cámara viejos y pesados (de fábrica, de más de 3 meses y 300 MB) y calcula cuánto pesarían convertidos. <i>Cómo: lee códec, duración y resolución con ffprobe; se salta lo que ya es ligero, los exportados y lo que ya convirtió este programa, para no pasar un clip dos veces.</i></dd>
<dt>Formato y calidad</dt><dd>H.265 10 bits (el recomendado, unas diez veces más ligero), H.265 ligero, H.264 o ProRes proxy, LT y HQ; calidad de ahorro, equilibrada o máxima. «Motores» te dice qué acelera tu tarjeta gráfica.</dd>
<dt>Convertir</dt><dd>Mete los clips en una cola que va uno tras otro en segundo plano. Se puede detener, reanudar, reintentar y vaciar. <i>Cómo: con ffmpeg, por tarjeta gráfica si la hay y si no por procesador; los BRAW pasan por DaVinci Resolve Studio, que tiene que estar abierto en un proyecto.</i></dd>
<dt>Verificar</dt><dd>Cada clip se comprueba solo al terminar: duración, códec y los 10 bits. Lo que no cuadra no se da por bueno.</dd>
@@ -1088,16 +1092,17 @@ async function cargarVoc(){
const d = await api('/api/vocabulario?raiz=' + encodeURIComponent(raizActual()));
VOC = d.tokens; pintarNombrar(); pintarReglas(d.diccionario.reglas);
}
const opcionesCubeta = c => `<option value="">— no sé / dejar como está —</option>` + Object.entries(ESTADO.cubetas).map(([k,v]) => `<option value="${k}" ${k===c?'selected':''}>${esc(NOMBRE_CUBETA[k] || k)} · ${esc(v)}</option>`).join('');
const PARECE = {CONTENEDOR: 'un cajón con cosas de varias fechas', PIEZA: 'una pieza montada'};
const opcionesCubeta = c =>`<option value="">— no sé / dejar como está —</option>` + Object.entries(ESTADO.cubetas).map(([k,v]) => `<option value="${k}" ${k===c?'selected':''}>${esc(NOMBRE_CUBETA[k] || k)} · ${esc(v)}</option>`).join('');
function pintarNombrar(){
const f = ($('#filtroVoc').value || '').toLowerCase(); const solo = $('#soloSin').checked; const sinP = $('#sinPiezas').checked;
const rows = VOC.filter(t => (!f || t.token.includes(f) || (t.rutas || []).join(' ').toLowerCase().includes(f)) && (!solo || !t.cubeta) && (!sinP || !t.dentro_pieza)).sort((a, b) => (!!a.cubeta - !!b.cubeta) || ((b.sug_conf === 'alta') - (a.sug_conf === 'alta')) || b.bytes - a.bytes);
const f = ($('#filtroVoc').value || '').toLowerCase(); const solo = $('#soloSin').checked;
const rows = VOC.filter(t => (!f || t.token.includes(f) || (t.rutas || []).join(' ').toLowerCase().includes(f)) && (!solo || !t.cubeta)).sort((a, b) => (!!a.cubeta - !!b.cubeta) || ((b.sug_conf === 'alta') - (a.sug_conf === 'alta')) || b.bytes - a.bytes);
const sin = VOC.filter(t => !t.cubeta && !t.dentro_pieza).length;
const seguras = VOC.filter(t => !t.cubeta && !t.dentro_pieza && t.sugerencia && t.sug_conf === 'alta');
$('#vocResumen').textContent = `${rows.length} carpetas · ${sin} sin reconocer${seguras.length ? ` · ${seguras.length} con sugerencia segura` : ''}`;
const bd = $('#badgeNombrar'); bd.textContent = sin; bd.classList.toggle('cero', !sin);
bloquear('btnAceptarSug', seguras.length ? '' : 'x'); $('#btnAceptarSug').textContent = seguras.length ? `Aceptar ${seguras.length} sugerencia${seguras.length > 1 ? 's' : ''} segura${seguras.length > 1 ? 's' : ''}` : 'Aceptar las sugerencias seguras';
$('#nombrar').innerHTML = rows.slice(0, 300).map(t => `<li><span><b title="${esc((t.rutas || []).join('\n'))}">${esc(t.ejemplo)}${t.aprendido ? ' <span class="tag alta">aprendido</span>' : ''}${t.n > 1 ? ` <span class="mut" style="font-weight:500">· ${t.n} carpetas con este nombre</span>` : ''}</b><span class="r">${esc((t.rutas || []).slice(0, 2).join(' · '))}</span>${t.contenido ? `<span class="r">Dentro: ${esc(t.contenido)}</span>` : ''}${!t.cubeta && t.sugerencia ? `<span class="sg">Parece <b>${esc(NOMBRE_CUBETA[t.sugerencia] || t.sugerencia)}</b> (${SEG_TXT[t.sug_conf] || t.sug_conf}): ${esc(t.por_que)}</span>` : !t.cubeta ? `<span class="sg" style="color:var(--mut)">Sin pista: elige qué es, o déjala y no se moverá.</span>` : ''}</span><span class="acts">${!t.cubeta && t.sugerencia ? `<button class="p" data-si="${esc(t.token)}" data-cub="${esc(t.sugerencia)}">Sí, es ${esc(NOMBRE_CUBETA[t.sugerencia] || t.sugerencia)}</button>` : ''}<select data-tok="${esc(t.token)}" aria-label="qué es">${opcionesCubeta(t.cubeta)}</select></span><small>${gb(t.bytes)}</small></li>`).join('') + (rows.length > 300 ? `<li class="mut">… y ${rows.length - 300} más: usa el buscador</li>` : '') || (sin ? '<li class="mut">Nada con ese filtro.</li>' : '<li class="ok">Todas las carpetas están reconocidas.</li>');
$('#nombrar').innerHTML = rows.slice(0, 300).map(t => `<li><span><b title="${esc((t.rutas || []).join('\n'))}">${esc(t.ejemplo)}${t.aprendido ? ' <span class="tag alta">aprendido</span>' : ''}${t.n > 1 ? ` <span class="mut" style="font-weight:500">· ${t.n} carpetas con este nombre</span>` : ''}</b><span class="r">${esc((t.rutas || []).slice(0, 2).join(' · '))}</span>${t.contenido ? `<span class="r">Dentro: ${esc(t.contenido)}</span>` : ''}${t.nota ? `<span class="nt">Ojo: ${esc(t.nota)}.</span>` : ''}${!t.cubeta && t.sugerencia ? `<span class="sg">Parece <b>${esc(PARECE[t.sugerencia] || NOMBRE_CUBETA[t.sugerencia] || t.sugerencia)}</b> (${SEG_TXT[t.sug_conf] || t.sug_conf}): ${esc(t.por_que)}</span>` : !t.cubeta ? `<span class="sg" style="color:var(--mut)">Sin pista: elige qué es, o déjala y no se moverá.</span>` : ''}</span><span class="acts">${!t.cubeta && t.sugerencia ? `<button class="p" data-si="${esc(t.token)}" data-cub="${esc(t.sugerencia)}" title="${t.sugerencia === 'CONTENEDOR' ? 'La app la abre y ordena lo de dentro, cada cosa a su mes' : ''}">${esc(t.sugerencia === 'CONTENEDOR' ? 'Sí, abrirla' : 'Sí, es ' + (NOMBRE_CUBETA[t.sugerencia] || t.sugerencia))}</button>` : ''}<select data-tok="${esc(t.token)}" aria-label="qué es">${opcionesCubeta(t.cubeta)}</select></span><small>${gb(t.bytes)}</small></li>`).join('') + (rows.length > 300 ? `<li class="mut">… y ${rows.length - 300} más: usa el buscador</li>` : '') || (sin ? '<li class="mut">Nada con ese filtro.</li>' : '<li class="ok">Todas las carpetas están reconocidas.</li>');
$$('#nombrar select').forEach(s => s.onchange = () => nombrarToken(s.dataset.tok, s.value));
$$('#nombrar [data-si]').forEach(b => b.onclick = () => nombrarToken(b.dataset.si, b.dataset.cub));
}
@@ -1114,7 +1119,7 @@ const nombrarToken = seguro(async (tok, cub) => {
pintarNombrar();
if (cub) aviso(`«${t ? t.ejemplo : tok}» ahora es ${NOMBRE_CUBETA[cub] || cub}. Calcula el plan de nuevo para verlo.`, 'ok');
});
$('#filtroVoc').oninput = pintarNombrar; $('#soloSin').onchange = pintarNombrar; $('#sinPiezas').onchange = pintarNombrar;
$('#filtroVoc').oninput = pintarNombrar; $('#soloSin').onchange = pintarNombrar;
function pintarReglas(reglas){
$('#reglas').innerHTML = reglas.length ? `<table>${reglas.map((r,i) => `<tr><td class="mut">${esc(r.cliente||'todos')}</td><td class="path">${esc(r.origen)}</td><td class="arrow">→</td><td class="path">${esc(r.destino||'(ignorar)')}</td><td><button class="bad" data-i="${i}">quitar</button></td></tr>`).join('')}</table>` : 'ninguna todavía';
$$('#reglas button').forEach(b => b.onclick = () => accion(b, async () => { const r = reglas[+b.dataset.i]; const d = await api('/api/regla', {cliente: r.cliente, origen: r.origen, destino: null}); pintarReglas(d.reglas); PLAN = null; estadoPlan(); }));
@@ -1123,7 +1128,7 @@ function pintarReglas(reglas){
// ---------------- revisar: plan (paso a paso, en frases) ----------------
const PASOS_PLAN = [
{k: 'limpiar', t: 'Apartar basura y proxies', q: 'Archivos de sistema (.DS_Store, Thumbs.db, cachés) y proxies se apartan en «_Para borrar». Los proxies se regeneran desde Resolve o Premiere. Nada se borra.', vacio: 'No hay basura ni proxies que apartar.'},
{k: 'fechas', t: 'Ordenar por año y mes', q: 'Los meses toman el nombre «03- Marzo», las grabaciones se reparten por mes y por cámara, y las piezas quedan numeradas dentro de su mes.', vacio: 'Las fechas ya están en orden.'},
{k: 'fechas', t: 'Ordenar por año y mes', q: 'Los meses toman el nombre «03- Marzo», las grabaciones se reparten por mes y por cámara, y las piezas quedan numeradas dentro de su mes. Lo que ya estaba ordenado en un mes que no es el de su rodaje vuelve al suyo.', vacio: 'Las fechas ya están en orden.'},
{k: 'resto', t: 'Ordenar lo demás', q: 'Exportados, proyectos, música, marca y demás van a su carpeta. Lo dudoso queda sin marcar: la app no lo mueve salvo que lo marques tú.', vacio: 'Nada más que ordenar.'},
];
const SEG_TXT = {alta: 'seguro', media: 'probable', baja: 'dudoso'};