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:
36
README.md
36
README.md
@@ -461,6 +461,42 @@ asignada» lista el material que no tiene dónde ir con ese tipo de trabajo y de
|
|||||||
`POST /api/plantillas` pasa a fusionar cubeta a cubeta: con `update()` de primer nivel, guardar una sola
|
`POST /api/plantillas` pasa a fusionar cubeta a cubeta: con `update()` de primer nivel, guardar una sola
|
||||||
plantilla borraba las otras diecinueve.
|
plantilla borraba las otras diecinueve.
|
||||||
|
|
||||||
|
2.4.0, el mapa deja de ser un dibujo y pasa a ser la herramienta con la que se decide el orden:
|
||||||
|
|
||||||
|
**Cada carpeta lleva el color de lo que guarda** (`R.COLOR_CUBETA`, el mismo que ya usaba el gráfico de
|
||||||
|
peso). Una carpeta que mezcla tipos de distinto color se queda neutra; lo que cuelga de una con color
|
||||||
|
hereda el suyo, así una rama entera se lee de un vistazo.
|
||||||
|
|
||||||
|
**«¿Dónde va a parar este archivo?»** Se escribe el nombre de cualquier archivo, exista o no, y la app
|
||||||
|
dice qué es, de qué cámara o programa viene, de qué fecha, y en qué carpeta lo dejaría; el árbol
|
||||||
|
enciende el destino. Lo resuelve `donde_iria()` con el mismo clasificador que usa el escaneo, así que
|
||||||
|
no es una simulación aparte. Debajo salen nombres de archivo de verdad de la carpeta conectada para
|
||||||
|
probar con un clic. `GET /api/estructura/archivo`.
|
||||||
|
|
||||||
|
**Cambiarle el nombre a una carpeta.** Se toca una carpeta del árbol, se escribe otro nombre y el
|
||||||
|
cambio alcanza a **todas** las plantillas que pasan por ahí: renombrar `00_Grabacion` cambia también
|
||||||
|
`CELULAR`, que cuelga de ella. Si ya hay carpetas con ese nombre en el disco, dice cuántas y ofrece
|
||||||
|
renombrarlas también; es un movimiento normal, queda en la bitácora y se deshace desde Historial. Las
|
||||||
|
carpetas que nombra el material (el año, el mes, la pieza) no se pueden renombrar a mano, y los
|
||||||
|
nombres imposibles se rechazan. `POST /api/estructura/renombrar`, `GET /api/estructura/existen`.
|
||||||
|
De paso, `{anio}` y `{mes}` dejan de encajar con cualquier carpeta: solo con algo que de verdad parezca
|
||||||
|
un año o un mes («Efectos» salía como si fuera un año).
|
||||||
|
|
||||||
|
**Colores en el Explorador de Windows.** Windows no tiene «color de carpeta», pero sí icono por
|
||||||
|
carpeta. La app genera un `.ico` por color sin dependencias (el mismo codificador PNG/ICO del logo) en
|
||||||
|
`.organilab\iconos` dentro de la propia carpeta del cliente, y lo asigna con
|
||||||
|
`SHGetSetFolderCustomSettings`, que es la puerta que usa «Propiedades › Personalizar › Cambiar icono»:
|
||||||
|
escribe el `desktop.ini` como lo escribe Windows, marca la carpeta y refresca la caché de iconos.
|
||||||
|
Escribir el `desktop.ini` a mano no bastaba, y comprobarlo con `SHGetFileInfo` engaña, porque esa vía
|
||||||
|
no informa de la personalización ni cuando la ha puesto el propio Explorador; la comprobación buena es
|
||||||
|
releer con la misma API. La ruta del icono es relativa, así que los colores viajan con el disco.
|
||||||
|
Medido el 16-sep-2026: en C: se puede, en Z: solo con el atributo de sistema, y en W: el disco no lo
|
||||||
|
permite — en ese caso la app lo dice y no deja nada escrito. Se quita con un clic.
|
||||||
|
`POST /api/colores/pintar` y `/api/colores/quitar`.
|
||||||
|
|
||||||
|
La carpeta `.organilab` no se escanea ni se ordena, y los `desktop.ini` que pone la app no se cuentan
|
||||||
|
como basura: si no, el primer «apartar basura» se llevaba los colores por delante.
|
||||||
|
|
||||||
Probado el 9 sep 2026 sobre una copia de ensayo: plan → aplicar → deshacer → volver a aplicar deja el árbol
|
Probado el 9 sep 2026 sobre una copia de ensayo: plan → aplicar → deshacer → volver a aplicar deja el árbol
|
||||||
exacto; conversión ffmpeg verificada y reemplazo con original a `_Para borrar`. El puente de Resolve conectó
|
exacto; conversión ffmpeg verificada y reemplazo con original a `_Para borrar`. El puente de Resolve conectó
|
||||||
con Resolve Studio 21.1 pero no pudo abrir proyecto porque Resolve estaba en el Gestor de proyectos:
|
con Resolve Studio 21.1 pero no pudo abrir proyecto porque Resolve estaba en el Gestor de proyectos:
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
{
|
{
|
||||||
"nombre": "OrganiLAB",
|
"nombre": "OrganiLAB",
|
||||||
"version": "2.3.0",
|
"version": "2.4.0",
|
||||||
"builtAt": "2026-09-17T03:31:56.000Z",
|
"builtAt": "2026-09-17T03:49:29.000Z",
|
||||||
"archivo": "OrganizadorLabstream-portatil-2.3.0.zip",
|
"archivo": "OrganizadorLabstream-portatil-2.4.0.zip",
|
||||||
"size": 198716642,
|
"size": 198734267,
|
||||||
"sha256": "1fe85cae6053dca7b0b381312fcac570d4595a78b7fd55254662f61d2be8521b",
|
"sha256": "02a9dade711cab28953b8e110d3440e4c2262287354e20b0d99ea00e05198b8f",
|
||||||
"app": {
|
"app": {
|
||||||
"archivo": "OrganizadorLabstream-app-2.3.0.zip",
|
"archivo": "OrganizadorLabstream-app-2.4.0.zip",
|
||||||
"size": 14896889,
|
"size": 14914514,
|
||||||
"sha256": "731ea6a9477d0bf2c04cc26a8a92fe6615e5e45ed1313d43020de16c7599ca70",
|
"sha256": "fc5e250afb755748b3c8a227780fef44c0b231026087247328e7d723cb87135a",
|
||||||
"url": "/api/herramientas/OrganizadorLabstream-app-2.3.0.zip"
|
"url": "/api/herramientas/OrganizadorLabstream-app-2.4.0.zip"
|
||||||
},
|
},
|
||||||
"notes": "",
|
"notes": "",
|
||||||
"firma": {
|
"firma": {
|
||||||
"alg": "rsa2048-pkcs1v15-sha256",
|
"alg": "rsa2048-pkcs1v15-sha256",
|
||||||
"valor": "bd0526e1cb3db8a7a6f8ecd0ddac54d627dee221713adba628eb6bbde1e601952770aebcc8ca07c379047af960117c0403cb12b19d80bf8a61a607d330365c3facd34d49b6c615e7cd6c1bd92eb9878f0961fa7f85953d235368abdf8704a12066ed6b31f2588201ef175fef264db0c14b318f50b587afbe73246405cbe423fe403376e00ff7610f89948806169ea3e13cd4aaf9b30565bba184083b11e9e49c01157b340d2e019bc23aed6aaa7b992e5ec7a2408c476352c603b6e5c5601a282d9e0eb6e724036c01b12f06011e23d538dae83ded019475800cfe62876b3a6d42b23f4fbadd1371b5b039328a44e33a8cadb94e630c39a30883b33a78b2d3ab"
|
"valor": "41707cfc81c53d0f2aa3c2cd2180c49fa588beef835d159f4caf60ef9a3e32aefa56a02f29143867eba7902f1ddf71c9a92332911ee617367be5634daff2eb293bf9a11ab668e827e0012159785b7a1d0f37ac8d76ad588f9d1521bf89c0d2539e6a6812d47fb7beae027493f6b53f844eaa655c81415396413031bd8aff59a49a5592e9263af442a7c853e17a63cb6b274e12f88d2066295ed0f6b3a030a938921e513e539b6fbe968348de5618b6421b48d5b72758c8adef2cdaeec768bf75aa45fecd390392e693038a600dd051f62aa47a62910f5ded9601ba0a3f402feb0f9389b3a27786a1b6e7ca5f7f57c21f93e80d09ecd675b9c3200241ccb7aea4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
469
organizador.py
469
organizador.py
@@ -14,6 +14,9 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
import re
|
import re
|
||||||
import json
|
import json
|
||||||
|
import zlib
|
||||||
|
import struct
|
||||||
|
import ctypes
|
||||||
import time
|
import time
|
||||||
import queue
|
import queue
|
||||||
import shutil
|
import shutil
|
||||||
@@ -34,7 +37,7 @@ import actualizador
|
|||||||
asistente.O = sys.modules[__name__]
|
asistente.O = sys.modules[__name__]
|
||||||
actualizador.O = sys.modules[__name__]
|
actualizador.O = sys.modules[__name__]
|
||||||
|
|
||||||
VERSION = "2.3.0"
|
VERSION = "2.4.0"
|
||||||
|
|
||||||
CONGELADO = getattr(sys, "frozen", False)
|
CONGELADO = getattr(sys, "frozen", False)
|
||||||
SIN_VENTANA = getattr(subprocess, "CREATE_NO_WINDOW", 0) # ffmpeg/ffprobe/net sin abrir consolas negras # empaquetado como .exe con PyInstaller
|
SIN_VENTANA = getattr(subprocess, "CREATE_NO_WINDOW", 0) # ffmpeg/ffprobe/net sin abrir consolas negras # empaquetado como .exe con PyInstaller
|
||||||
@@ -437,8 +440,8 @@ def escanear(ruta):
|
|||||||
try:
|
try:
|
||||||
nombre = e.name
|
nombre = e.name
|
||||||
if e.is_dir(follow_symlinks=False):
|
if e.is_dir(follow_symlinks=False):
|
||||||
if nombre.lower() in ("#recycle", "@eadir"):
|
if nombre.lower() in ("#recycle", "@eadir", R.CARPETA_APP):
|
||||||
continue
|
continue # lo de la app (sus iconos de color) no es material del cliente
|
||||||
p = en_proxy or R.es_proxy_dir(nombre)
|
p = en_proxy or R.es_proxy_dir(nombre)
|
||||||
b = en_basura or R.es_basura_dir(nombre) or R.norm(nombre) in R.PAPELERAS_NORM
|
b = en_basura or R.es_basura_dir(nombre) or R.norm(nombre) in R.PAPELERAS_NORM
|
||||||
# una carpeta llamada «Elements» NO basta para dar por desechable lo que tiene dentro:
|
# una carpeta llamada «Elements» NO basta para dar por desechable lo que tiene dentro:
|
||||||
@@ -461,6 +464,8 @@ def escanear(ruta):
|
|||||||
continue
|
continue
|
||||||
walk(e.path, p, b, s, c2, n2, a2, m2, vistos)
|
walk(e.path, p, b, s, c2, n2, a2, m2, vistos)
|
||||||
else:
|
else:
|
||||||
|
if nombre.lower() == "desktop.ini" and _ini_nuestro(e.path):
|
||||||
|
continue # el color de la carpeta lo puso la app: ni se indexa ni se aparta
|
||||||
st = e.stat(follow_symlinks=False)
|
st = e.stat(follow_symlinks=False)
|
||||||
ext = os.path.splitext(nombre)[1].lower()
|
ext = os.path.splitext(nombre)[1].lower()
|
||||||
firma = R.firma_archivo(nombre)
|
firma = R.firma_archivo(nombre)
|
||||||
@@ -2898,19 +2903,32 @@ def mapa_estructura(tipo, raiz=None):
|
|||||||
apps[a][1] += f["b"]
|
apps[a][1] += f["b"]
|
||||||
con.close()
|
con.close()
|
||||||
|
|
||||||
|
# nombres de archivo de verdad, para poder probar el mapa con lo que hay en el disco
|
||||||
|
ejemplos = defaultdict(list)
|
||||||
|
if raiz:
|
||||||
|
con = db()
|
||||||
|
for f in con.execute("SELECT rol, nombre, MAX(bytes) b FROM archivos WHERE raiz = ? AND es_basura = 0 "
|
||||||
|
"AND es_proxy = 0 GROUP BY rol, ext ORDER BY b DESC", (raiz,)).fetchall():
|
||||||
|
cub = R.ROL_A_CUBETA.get(f["rol"], "REVISAR")
|
||||||
|
if len(ejemplos[cub]) < 4:
|
||||||
|
ejemplos[cub].append(f["nombre"])
|
||||||
|
con.close()
|
||||||
|
|
||||||
# cubetas -> roles que caen en ellas, para poder decir qué guarda cada carpeta
|
# cubetas -> roles que caen en ellas, para poder decir qué guarda cada carpeta
|
||||||
roles = defaultdict(list)
|
roles = defaultdict(list)
|
||||||
for rol, cub in R.ROL_A_CUBETA.items():
|
for rol, cub in R.ROL_A_CUBETA.items():
|
||||||
roles[cub].append(R.ROL_QUE.get(rol, rol))
|
roles[cub].append(R.ROL_QUE.get(rol, rol))
|
||||||
|
|
||||||
raiz_nodo = {"nombre": os.path.basename(raiz.rstrip(os.sep)) or raiz if raiz else "Carpeta del cliente",
|
raiz_nodo = {"nombre": os.path.basename(raiz.rstrip(os.sep)) or raiz if raiz else "Carpeta del cliente",
|
||||||
"hijos": [], "cubetas": [], "variable": "", "nota": "", "n": 0, "bytes": 0}
|
"hijos": [], "cubetas": [], "variable": "", "nota": "", "crudo": [], "color": "",
|
||||||
|
"n": 0, "bytes": 0}
|
||||||
|
|
||||||
def bajar(nodo, nombre, variable):
|
def bajar(nodo, nombre, variable, crudo):
|
||||||
for h in nodo["hijos"]:
|
for h in nodo["hijos"]:
|
||||||
if h["nombre"].lower() == nombre.lower():
|
if h["nombre"].lower() == nombre.lower():
|
||||||
return h
|
return h
|
||||||
h = {"nombre": nombre, "hijos": [], "cubetas": [], "variable": variable, "nota": "", "n": 0, "bytes": 0}
|
h = {"nombre": nombre, "hijos": [], "cubetas": [], "variable": variable, "nota": "",
|
||||||
|
"crudo": list(crudo), "color": "", "n": 0, "bytes": 0}
|
||||||
nodo["hijos"].append(h)
|
nodo["hijos"].append(h)
|
||||||
return h
|
return h
|
||||||
|
|
||||||
@@ -2920,16 +2938,19 @@ def mapa_estructura(tipo, raiz=None):
|
|||||||
n, b = hay.get(cub, [0, 0])
|
n, b = hay.get(cub, [0, 0])
|
||||||
cubetas.append({"clave": cub, "titulo": titulo, "plantilla": plantilla,
|
cubetas.append({"clave": cub, "titulo": titulo, "plantilla": plantilla,
|
||||||
"asignable": cub not in ("CONTENEDOR", "IGNORAR"),
|
"asignable": cub not in ("CONTENEDOR", "IGNORAR"),
|
||||||
|
"color": R.COLOR_CUBETA.get(cub, R.COLOR_CUBETA[""]),
|
||||||
|
"ejemplos": ejemplos.get(cub, []),
|
||||||
"defecto": R.PLANTILLAS.get(tipo, {}).get(cub, ""),
|
"defecto": R.PLANTILLAS.get(tipo, {}).get(cub, ""),
|
||||||
"guarda": sorted(set(roles.get(cub, []))), "n": n, "bytes": b,
|
"guarda": sorted(set(roles.get(cub, []))), "n": n, "bytes": b,
|
||||||
"apps": (sorted(({"nombre": k, "n": v[0], "bytes": v[1]} for k, v in apps.items()),
|
"apps": (sorted(({"nombre": k, "n": v[0], "bytes": v[1]} for k, v in apps.items()),
|
||||||
key=lambda x: -x["bytes"]) if cub == "PROYECTOS" else [])})
|
key=lambda x: -x["bytes"]) if cub == "PROYECTOS" else [])})
|
||||||
if not plantilla:
|
if not plantilla:
|
||||||
continue
|
continue
|
||||||
nodo = raiz_nodo
|
nodo, crudo = raiz_nodo, []
|
||||||
for seg in plantilla.split("/"):
|
for seg in plantilla.split("/"):
|
||||||
if seg == "{app}":
|
if seg == "{app}":
|
||||||
nodo = bajar(nodo, EJEMPLO_VAR["app"][0], EJEMPLO_VAR["app"][1])
|
crudo.append(seg)
|
||||||
|
nodo = bajar(nodo, EJEMPLO_VAR["app"][0], EJEMPLO_VAR["app"][1], crudo)
|
||||||
nodo["nota"] = ("solo cuando el proyecto va suelto; si ya viene dentro de su propia "
|
nodo["nota"] = ("solo cuando el proyecto va suelto; si ya viene dentro de su propia "
|
||||||
"carpeta, se queda con el nombre de esa carpeta")
|
"carpeta, se queda con el nombre de esa carpeta")
|
||||||
nodo["apps_de"] = cub # aquí es donde se busca «¿cuánto tengo de cada programa?»
|
nodo["apps_de"] = cub # aquí es donde se busca «¿cuánto tengo de cada programa?»
|
||||||
@@ -2943,7 +2964,8 @@ def mapa_estructura(tipo, raiz=None):
|
|||||||
if "{" + k + "}" in texto:
|
if "{" + k + "}" in texto:
|
||||||
texto = texto.replace("{" + k + "}", ej)
|
texto = texto.replace("{" + k + "}", ej)
|
||||||
hint = que if not hint else hint + " y " + que
|
hint = que if not hint else hint + " y " + que
|
||||||
nodo = bajar(nodo, texto, hint)
|
crudo.append(seg)
|
||||||
|
nodo = bajar(nodo, texto, hint, crudo)
|
||||||
nodo["cubetas"].append(cub)
|
nodo["cubetas"].append(cub)
|
||||||
nodo["n"] += n
|
nodo["n"] += n
|
||||||
nodo["bytes"] += b
|
nodo["bytes"] += b
|
||||||
@@ -2951,14 +2973,424 @@ def mapa_estructura(tipo, raiz=None):
|
|||||||
def ordenar(nodo):
|
def ordenar(nodo):
|
||||||
# una carpeta con nombre fijo antes que una variable, y dentro de cada grupo por nombre
|
# una carpeta con nombre fijo antes que una variable, y dentro de cada grupo por nombre
|
||||||
nodo["hijos"].sort(key=lambda h: (bool(h["variable"]), h["nombre"].lower()))
|
nodo["hijos"].sort(key=lambda h: (bool(h["variable"]), h["nombre"].lower()))
|
||||||
|
cols = {R.COLOR_CUBETA.get(c, "") for c in nodo["cubetas"]}
|
||||||
for h in nodo["hijos"]:
|
for h in nodo["hijos"]:
|
||||||
ordenar(h)
|
ordenar(h)
|
||||||
nodo["n"] += h["n"]
|
nodo["n"] += h["n"]
|
||||||
nodo["bytes"] += h["bytes"]
|
nodo["bytes"] += h["bytes"]
|
||||||
|
cols.add(h["color"])
|
||||||
|
cols.discard("")
|
||||||
|
# una carpeta toma el color de lo que guarda; si mezcla tipos de distinto color, se queda neutra
|
||||||
|
nodo["color"] = cols.pop() if len(cols) == 1 else ""
|
||||||
ordenar(raiz_nodo)
|
ordenar(raiz_nodo)
|
||||||
|
|
||||||
return {"tipo": tipo, "titulo": R.TIPOS_CLIENTE.get(tipo, tipo), "raiz": raiz or "", "cliente": cliente,
|
return {"tipo": tipo, "titulo": R.TIPOS_CLIENTE.get(tipo, tipo), "raiz": raiz or "", "cliente": cliente,
|
||||||
"arbol": raiz_nodo, "cubetas": cubetas, "variables": {k: v[1] for k, v in EJEMPLO_VAR.items()}}
|
"arbol": raiz_nodo, "cubetas": cubetas, "variables": {k: v[1] for k, v in EJEMPLO_VAR.items()},
|
||||||
|
"colores": R.COLOR_CUBETA, "pintado": _esta_pintada(raiz) if raiz else False}
|
||||||
|
|
||||||
|
|
||||||
|
def donde_iria(nombre, tipo="contenido"):
|
||||||
|
"""Qué es un archivo y en qué carpeta lo dejaría la app, solo por su nombre.
|
||||||
|
|
||||||
|
Es el mapa al revés: en vez de «esta carpeta guarda esto», «esto va a esta carpeta». Vale para
|
||||||
|
cualquier nombre, exista o no, porque lo decide el mismo clasificador que usa el escaneo."""
|
||||||
|
nombre = (nombre or "").strip().strip('"').replace("/", os.sep).split(os.sep)[-1]
|
||||||
|
if not nombre:
|
||||||
|
return {"error": "escribe el nombre de un archivo"}
|
||||||
|
ext = os.path.splitext(nombre)[1].lower()
|
||||||
|
firma = R.firma_archivo(nombre)
|
||||||
|
rol = R.rol_archivo(nombre, ext, firma)
|
||||||
|
cub = R.ROL_A_CUBETA.get(rol, "REVISAR")
|
||||||
|
fecha, origen = R.fecha_de_nombre(nombre, time.time())
|
||||||
|
kw = {"anio": int(fecha[:4]), "mes": int(fecha[5:7])}
|
||||||
|
if cub == "GRABACION":
|
||||||
|
kw["sub"] = R.FUENTE_CARPETA.get(firma, "OTROS")
|
||||||
|
if cub == "CELULAR" and tipo == "contenido":
|
||||||
|
cub, kw["sub"] = "GRABACION", "CELULAR"
|
||||||
|
if cub == "PROYECTOS":
|
||||||
|
kw["app"] = R.app_proyecto(ext) or ("Diseño" if rol == "diseno" else "Edicion")
|
||||||
|
destino = "_Revisar" if cub == "REVISAR" else destino_plantilla(tipo, cub, **kw)
|
||||||
|
return {"nombre": nombre, "rol": rol, "que": R.ROL_QUE.get(rol, rol), "cubeta": cub,
|
||||||
|
"titulo": R.CUBETAS.get(cub, cub), "color": R.COLOR_CUBETA.get(cub, R.COLOR_CUBETA[""]),
|
||||||
|
"destino": destino, "crudo": [x for x in (PLANTILLAS.get(tipo, {}).get(cub, "") or "").split("/") if x],
|
||||||
|
"fecha": fecha, "por_fecha": origen, "camara": firma or "",
|
||||||
|
"app": R.app_proyecto(ext) or ""}
|
||||||
|
|
||||||
|
|
||||||
|
def renombrar_nodo(tipo, ruta, nombre, raiz=None, en_disco=False):
|
||||||
|
"""Le cambia el nombre a una carpeta del mapa: en las plantillas y, si se pide, en el disco.
|
||||||
|
|
||||||
|
`ruta` son los segmentos CRUDOS del mapa («{anio}», «{mes}», «00_Grabacion»), así que el cambio
|
||||||
|
alcanza a todas las plantillas que pasan por esa carpeta, no solo a una."""
|
||||||
|
tipo = tipo if tipo in PLANTILLAS else "contenido"
|
||||||
|
nombre = (nombre or "").strip().strip(". ")
|
||||||
|
if not nombre:
|
||||||
|
return {"error": "el nombre no puede quedar vacío"}
|
||||||
|
if any(c in nombre for c in '\\/:*?"<>|'):
|
||||||
|
return {"error": 'un nombre de carpeta no puede llevar \\ / : * ? " < > |'}
|
||||||
|
if "{" in nombre or "}" in nombre:
|
||||||
|
return {"error": "aquí va un nombre, no una variable: las variables se editan en la plantilla"}
|
||||||
|
if not ruta or "{" in ruta[-1]:
|
||||||
|
return {"error": "esa carpeta la nombra el material (el año, el mes, la pieza): no se puede cambiar a mano"}
|
||||||
|
viejo = ruta[-1]
|
||||||
|
if R.norm(viejo) == R.norm(nombre):
|
||||||
|
return {"error": "ya se llama así"}
|
||||||
|
|
||||||
|
cambiadas = []
|
||||||
|
for cub, pl in list(PLANTILLAS[tipo].items()):
|
||||||
|
segs = pl.split("/")
|
||||||
|
if len(segs) >= len(ruta) and [x.lower() for x in segs[:len(ruta)]] == [x.lower() for x in ruta]:
|
||||||
|
segs[len(ruta) - 1] = nombre
|
||||||
|
PLANTILLAS[tipo][cub] = "/".join(segs)
|
||||||
|
cambiadas.append(cub)
|
||||||
|
if not cambiadas:
|
||||||
|
return {"error": "esa carpeta ya no está en ninguna plantilla; vuelve a abrir el mapa"}
|
||||||
|
guardar_json(PLANTILLAS_PATH, PLANTILLAS)
|
||||||
|
PLANES.clear()
|
||||||
|
|
||||||
|
movidas, errores = 0, []
|
||||||
|
if en_disco and raiz:
|
||||||
|
movidas, errores = _renombrar_en_disco(_clave(raiz), ruta, nombre)
|
||||||
|
return {"ok": True, "cubetas": cambiadas, "viejo": viejo, "nuevo": nombre,
|
||||||
|
"movidas": movidas, "errores": errores}
|
||||||
|
|
||||||
|
|
||||||
|
def _casan(segmentos, patron):
|
||||||
|
"""True si los segmentos de una carpeta real encajan con un patron de plantilla.
|
||||||
|
|
||||||
|
«{anio}» y «{mes}» solo valen por algo que de verdad parezca un ano o un mes: sin eso, «{anio}»
|
||||||
|
encajaba con cualquier carpeta del primer nivel («Efectos» salia como si fuera un ano)."""
|
||||||
|
if len(segmentos) != len(patron):
|
||||||
|
return False
|
||||||
|
for g, p in zip(segmentos, patron):
|
||||||
|
if p == "{anio}":
|
||||||
|
if not R.detectar_anio(g):
|
||||||
|
return False
|
||||||
|
elif p == "{mes}":
|
||||||
|
if not R.detectar_mes(g)[0]:
|
||||||
|
return False
|
||||||
|
elif p.startswith("{"):
|
||||||
|
continue
|
||||||
|
elif R.norm(p) != R.norm(g):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def carpetas_como(raiz, ruta):
|
||||||
|
"""Las carpetas que hay HOY en el disco en el sitio que describe `ruta` (segmentos crudos)."""
|
||||||
|
raiz = _clave(raiz)
|
||||||
|
con = db()
|
||||||
|
filas = [r["ruta"] for r in con.execute("SELECT ruta FROM carpetas WHERE raiz = ?", (raiz,)).fetchall()]
|
||||||
|
con.close()
|
||||||
|
out = []
|
||||||
|
for c in filas:
|
||||||
|
rel = os.path.relpath(c, raiz)
|
||||||
|
if rel == "." or not os.path.isdir(c):
|
||||||
|
continue
|
||||||
|
if _casan(rel.split(os.sep), ruta):
|
||||||
|
out.append(c)
|
||||||
|
return sorted(out, key=lambda x: x.lower())
|
||||||
|
|
||||||
|
|
||||||
|
def _renombrar_en_disco(raiz, ruta, nombre):
|
||||||
|
"""Renombra en el disco todas las carpetas que estaban en ese sitio del mapa. Queda en la bitácora
|
||||||
|
como un movimiento normal, así que se deshace desde Historial."""
|
||||||
|
lote = "renombrar-" + time.strftime("%Y%m%d-%H%M%S")
|
||||||
|
movidos, errores = [], []
|
||||||
|
for c in carpetas_como(raiz, ruta):
|
||||||
|
destino = os.path.join(os.path.dirname(c), nombre)
|
||||||
|
if os.path.normcase(c) == os.path.normcase(destino):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
mover(c, destino, lote, movidos)
|
||||||
|
except Exception as ex: # noqa
|
||||||
|
errores.append(f"{os.path.relpath(c, raiz)}: {ex}")
|
||||||
|
if movidos:
|
||||||
|
actualizar_inventario(movidos)
|
||||||
|
bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": len(movidos),
|
||||||
|
"movimientos": len(movidos), "errores": errores})
|
||||||
|
return len(movidos), errores
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Color de carpeta en el Explorador de Windows
|
||||||
|
# ==========================================================================
|
||||||
|
#
|
||||||
|
# Windows no tiene «color de carpeta», pero sí icono por carpeta: un desktop.ini que apunta a un .ico
|
||||||
|
# y la carpeta marcada como solo lectura para que el Explorador lo lea. La app genera un icono por
|
||||||
|
# color dentro de la propia carpeta del cliente y lo referencia con ruta relativa, así que los colores
|
||||||
|
# viajan con el disco y se ven igual desde cualquier PC que lo tenga montado.
|
||||||
|
|
||||||
|
F_READONLY, F_HIDDEN, F_SYSTEM = 0x1, 0x2, 0x4
|
||||||
|
FCSM_ICONFILE, FCS_READ, FCS_FORCEWRITE = 0x10, 0x1, 0x2
|
||||||
|
SHCNE_UPDATEDIR, SHCNF_PATHW = 0x1000, 0x0005
|
||||||
|
|
||||||
|
|
||||||
|
class _FCS(ctypes.Structure):
|
||||||
|
"""SHFOLDERCUSTOMSETTINGS: lo que hay en el desktop.ini de una carpeta, visto por el Explorador."""
|
||||||
|
_fields_ = [("dwSize", ctypes.c_uint32), ("dwMask", ctypes.c_uint32),
|
||||||
|
("pvid", ctypes.c_void_p), ("pszWebViewTemplate", ctypes.c_wchar_p),
|
||||||
|
("cchWebViewTemplate", ctypes.c_uint32), ("pszWebViewTemplateVersion", ctypes.c_wchar_p),
|
||||||
|
("pszInfoTip", ctypes.c_wchar_p), ("cchInfoTip", ctypes.c_uint32),
|
||||||
|
("pclsid", ctypes.c_void_p), ("dwFlags", ctypes.c_uint32),
|
||||||
|
("pszIconFile", ctypes.c_wchar_p), ("cchIconFile", ctypes.c_uint32),
|
||||||
|
("iIconIndex", ctypes.c_int), ("pszLogo", ctypes.c_wchar_p), ("cchLogo", ctypes.c_uint32)]
|
||||||
|
|
||||||
|
|
||||||
|
def _fcs():
|
||||||
|
f = ctypes.windll.shell32.SHGetSetFolderCustomSettings
|
||||||
|
f.argtypes = [ctypes.POINTER(_FCS), ctypes.c_wchar_p, ctypes.c_uint32]
|
||||||
|
f.restype = ctypes.c_long
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
def _poner_icono(carpeta, icono):
|
||||||
|
"""Le pone a la carpeta ese .ico (la ruta puede ser relativa, y así los colores viajan con el disco)."""
|
||||||
|
w = _FCS()
|
||||||
|
w.dwSize = ctypes.sizeof(_FCS)
|
||||||
|
w.dwMask = FCSM_ICONFILE
|
||||||
|
w.pszIconFile = icono
|
||||||
|
w.iIconIndex = 0
|
||||||
|
try:
|
||||||
|
if _fcs()(ctypes.byref(w), carpeta, FCS_FORCEWRITE) != 0:
|
||||||
|
return False
|
||||||
|
ctypes.windll.shell32.SHChangeNotify(SHCNE_UPDATEDIR, SHCNF_PATHW, ctypes.c_wchar_p(carpeta), None)
|
||||||
|
return True
|
||||||
|
except Exception: # noqa
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _icono_de(carpeta):
|
||||||
|
"""El .ico que tiene puesto la carpeta, o "" si no tiene ninguno."""
|
||||||
|
buf = ctypes.create_unicode_buffer(600)
|
||||||
|
r = _FCS()
|
||||||
|
r.dwSize = ctypes.sizeof(_FCS)
|
||||||
|
r.dwMask = FCSM_ICONFILE
|
||||||
|
r.pszIconFile = ctypes.cast(buf, ctypes.c_wchar_p)
|
||||||
|
r.cchIconFile = 600
|
||||||
|
try:
|
||||||
|
return buf.value if _fcs()(ctypes.byref(r), carpeta, FCS_READ) == 0 else ""
|
||||||
|
except Exception: # noqa
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _ini_nuestro(ruta):
|
||||||
|
"""Un desktop.ini es de la app si apunta a un icono de su propia carpeta."""
|
||||||
|
try:
|
||||||
|
with open(ruta, "rb") as fh:
|
||||||
|
return R.CARPETA_APP.encode() in fh.read(1024).lower()
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _atributos(ruta, poner=0, quitar=0):
|
||||||
|
"""Pone o quita atributos y devuelve si de verdad quedaron puestos.
|
||||||
|
|
||||||
|
En un NAS por SMB, SetFileAttributesW dice que sí y el atributo no queda: medido el 16-sep-2026,
|
||||||
|
Z: acepta SYSTEM pero no READONLY, y W: no acepta ninguno de los dos. Por eso se relee."""
|
||||||
|
try:
|
||||||
|
a = ctypes.windll.kernel32.GetFileAttributesW(str(ruta))
|
||||||
|
if a == -1:
|
||||||
|
return False
|
||||||
|
ctypes.windll.kernel32.SetFileAttributesW(str(ruta), (a | poner) & ~quitar)
|
||||||
|
if not poner:
|
||||||
|
return True
|
||||||
|
b = ctypes.windll.kernel32.GetFileAttributesW(str(ruta))
|
||||||
|
return b != -1 and bool(b & poner)
|
||||||
|
except Exception: # noqa
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _marcar_carpeta(carpeta):
|
||||||
|
"""El Explorador solo lee desktop.ini si la carpeta está marcada como solo lectura O como sistema.
|
||||||
|
Vale cualquiera de las dos: se intentan las dos y basta con que una quede."""
|
||||||
|
return (_atributos(carpeta, poner=F_READONLY) or _atributos(carpeta, poner=F_SYSTEM))
|
||||||
|
|
||||||
|
|
||||||
|
def _png_carpeta(n, rgb):
|
||||||
|
"""Una carpeta del color pedido, n×n RGBA, con la pestaña un punto más oscura."""
|
||||||
|
u = n / 32.0
|
||||||
|
base = rgb
|
||||||
|
tab = tuple(int(c * 0.78) for c in rgb)
|
||||||
|
|
||||||
|
def rect(px, py, x0, y0, x1, y1, r):
|
||||||
|
if not (x0 <= px <= x1 and y0 <= py <= y1):
|
||||||
|
return False
|
||||||
|
cx = min(max(px, x0 + r), x1 - r)
|
||||||
|
cy = min(max(py, y0 + r), y1 - r)
|
||||||
|
return (px - cx) ** 2 + (py - cy) ** 2 <= r * r
|
||||||
|
|
||||||
|
filas = []
|
||||||
|
for y in range(n):
|
||||||
|
fila = bytearray()
|
||||||
|
for x in range(n):
|
||||||
|
dentro_b = dentro_t = 0
|
||||||
|
for sx, sy in ((.25, .25), (.75, .25), (.25, .75), (.75, .75)):
|
||||||
|
px, py = (x + sx) / u, (y + sy) / u
|
||||||
|
if rect(px, py, 2, 8.5, 30, 27, 2.2):
|
||||||
|
dentro_b += 1
|
||||||
|
elif rect(px, py, 2, 5, 14.5, 11, 1.4):
|
||||||
|
dentro_t += 1
|
||||||
|
if dentro_b:
|
||||||
|
a, col = dentro_b / 4, base
|
||||||
|
elif dentro_t:
|
||||||
|
a, col = dentro_t / 4, tab
|
||||||
|
else:
|
||||||
|
fila += bytes((0, 0, 0, 0))
|
||||||
|
continue
|
||||||
|
fila += bytes((col[0], col[1], col[2], int(255 * a)))
|
||||||
|
filas.append(bytes(fila))
|
||||||
|
raw = b"".join(b"\x00" + f for f in filas)
|
||||||
|
|
||||||
|
def chunk(t, d):
|
||||||
|
return struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d) & 0xffffffff)
|
||||||
|
return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", n, n, 8, 6, 0, 0, 0))
|
||||||
|
+ chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b""))
|
||||||
|
|
||||||
|
|
||||||
|
def _ico_carpeta(hexcol):
|
||||||
|
rgb = tuple(int(hexcol.lstrip("#")[i:i + 2], 16) for i in (0, 2, 4))
|
||||||
|
tam = (256, 48, 32, 16)
|
||||||
|
imgs = [_png_carpeta(t, rgb) for t in tam]
|
||||||
|
cab = struct.pack("<HHH", 0, 1, len(tam))
|
||||||
|
off = 6 + 16 * len(tam)
|
||||||
|
entradas = b""
|
||||||
|
for t, im in zip(tam, imgs):
|
||||||
|
entradas += struct.pack("<BBBBHHII", t % 256, t % 256, 0, 0, 1, 32, len(im), off)
|
||||||
|
off += len(im)
|
||||||
|
return cab + entradas + b"".join(imgs)
|
||||||
|
|
||||||
|
|
||||||
|
def _dir_iconos(raiz):
|
||||||
|
return os.path.join(raiz, R.CARPETA_APP, "iconos")
|
||||||
|
|
||||||
|
|
||||||
|
def _esta_pintada(raiz):
|
||||||
|
return os.path.isdir(_dir_iconos(raiz))
|
||||||
|
|
||||||
|
|
||||||
|
def _nodos_pintables(nodo, out=None):
|
||||||
|
"""(segmentos crudos, color) de cada carpeta del mapa que tiene color, las más concretas primero:
|
||||||
|
«{anio}/{mes}/{nombre}» también encaja con «00_Grabacion», y ahí manda la que tiene nombre fijo."""
|
||||||
|
out = [] if out is None else out
|
||||||
|
if nodo.get("crudo") and nodo.get("color"):
|
||||||
|
out.append((nodo["crudo"], nodo["color"]))
|
||||||
|
for h in nodo.get("hijos", []):
|
||||||
|
_nodos_pintables(h, out)
|
||||||
|
out.sort(key=lambda x: -sum(0 if seg.startswith("{") else 1 for seg in x[0]))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _carpetas_a_pintar(raiz):
|
||||||
|
"""Cada carpeta real del cliente con el color de lo que guarda."""
|
||||||
|
mapa = mapa_estructura((raiz_cfg(raiz) or {}).get("tipo", "contenido"), raiz)
|
||||||
|
patrones = _nodos_pintables(mapa["arbol"])
|
||||||
|
con = db()
|
||||||
|
reales = [r["ruta"] for r in con.execute("SELECT ruta FROM carpetas WHERE raiz = ?", (raiz,)).fetchall()]
|
||||||
|
con.close()
|
||||||
|
out = {}
|
||||||
|
for c in reales:
|
||||||
|
rel = os.path.relpath(c, raiz)
|
||||||
|
if rel == "." or rel.split(os.sep)[0].lower() == R.CARPETA_APP:
|
||||||
|
continue
|
||||||
|
segs = rel.split(os.sep)
|
||||||
|
for crudo, color in patrones:
|
||||||
|
if _casan(segs, crudo):
|
||||||
|
out[c] = color
|
||||||
|
break
|
||||||
|
# lo que cuelga de una carpeta con color es más de lo mismo («URSA_BRAW» dentro de «00_Grabacion»):
|
||||||
|
# hereda su color, y así en el Explorador una rama entera se ve de un color
|
||||||
|
for c in sorted(reales, key=lambda x: len(x)):
|
||||||
|
if c in out or os.path.relpath(c, raiz) == ".":
|
||||||
|
continue
|
||||||
|
padre = os.path.dirname(c)
|
||||||
|
while padre.lower().startswith(raiz.lower()) and padre.lower() != raiz.lower():
|
||||||
|
if padre in out:
|
||||||
|
out[c] = out[padre]
|
||||||
|
break
|
||||||
|
padre = os.path.dirname(padre)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def pintar_carpetas(raiz):
|
||||||
|
"""Le pone a cada carpeta el icono del color de lo que guarda."""
|
||||||
|
raiz = _clave(raiz)
|
||||||
|
if not os.path.isdir(raiz):
|
||||||
|
return {"error": "la carpeta no está conectada"}
|
||||||
|
objetivo = _carpetas_a_pintar(raiz)
|
||||||
|
if not objetivo:
|
||||||
|
return {"error": "no hay ninguna carpeta reconocida todavía: escanea y ordena primero"}
|
||||||
|
iconos = _dir_iconos(raiz)
|
||||||
|
try:
|
||||||
|
os.makedirs(iconos, exist_ok=True)
|
||||||
|
_atributos(os.path.join(raiz, R.CARPETA_APP), poner=F_HIDDEN)
|
||||||
|
except OSError as ex:
|
||||||
|
return {"error": f"no se pudo crear la carpeta de iconos: {ex}"}
|
||||||
|
for color in set(objetivo.values()):
|
||||||
|
ico = os.path.join(iconos, color.lstrip("#").lower() + ".ico")
|
||||||
|
if not os.path.exists(ico):
|
||||||
|
with open(ico, "wb") as fh:
|
||||||
|
fh.write(_ico_carpeta(color))
|
||||||
|
pintadas, sin_marca, errores = 0, 0, []
|
||||||
|
for carpeta, color in sorted(objetivo.items()):
|
||||||
|
try:
|
||||||
|
rel = os.path.join(os.path.relpath(iconos, carpeta), color.lstrip("#").lower() + ".ico")
|
||||||
|
_atributos(os.path.join(carpeta, "desktop.ini"), quitar=F_READONLY)
|
||||||
|
if not _poner_icono(carpeta, rel):
|
||||||
|
sin_marca += 1
|
||||||
|
continue
|
||||||
|
# la API marca la carpeta ella sola, pero algunos discos de red no dejan: se comprueba
|
||||||
|
if _icono_de(carpeta).lower() == rel.lower() and _marcar_carpeta(carpeta):
|
||||||
|
pintadas += 1
|
||||||
|
else:
|
||||||
|
sin_marca += 1
|
||||||
|
except OSError as ex:
|
||||||
|
if len(errores) < 5:
|
||||||
|
errores.append(f"{os.path.relpath(carpeta, raiz)}: {ex}")
|
||||||
|
if not pintadas and sin_marca:
|
||||||
|
# el disco no deja marcar las carpetas: el desktop.ini se quedaría ahí sin hacer nada
|
||||||
|
despintar_carpetas(raiz)
|
||||||
|
return {"error": "este disco no deja marcar las carpetas, y sin esa marca el Explorador ni mira el "
|
||||||
|
"color. Pasa en algunos NAS y discos de red. No se ha dejado nada escrito.",
|
||||||
|
"pintadas": 0, "errores": []}
|
||||||
|
try:
|
||||||
|
ctypes.windll.shell32.SHChangeNotify(0x08000000, 0x0000, None, None) # SHCNE_ASSOCCHANGED
|
||||||
|
except Exception: # noqa
|
||||||
|
pass
|
||||||
|
bitacora({"lote": "colores-" + time.strftime("%Y%m%d-%H%M%S"), "op": "colores", "raiz": raiz,
|
||||||
|
"pintadas": pintadas, "errores": errores})
|
||||||
|
return {"pintadas": pintadas, "sin_marca": sin_marca, "colores": len(set(objetivo.values())),
|
||||||
|
"errores": errores}
|
||||||
|
|
||||||
|
|
||||||
|
def despintar_carpetas(raiz):
|
||||||
|
"""Quita los colores: borra los desktop.ini que puso la app y su carpeta de iconos."""
|
||||||
|
raiz = _clave(raiz)
|
||||||
|
quitadas, errores = 0, []
|
||||||
|
for base, dirs, files in os.walk(raiz):
|
||||||
|
if os.path.basename(base).lower() == R.CARPETA_APP:
|
||||||
|
dirs[:] = []
|
||||||
|
continue
|
||||||
|
for f in files:
|
||||||
|
if f.lower() != "desktop.ini":
|
||||||
|
continue
|
||||||
|
ini = os.path.join(base, f)
|
||||||
|
if not _ini_nuestro(ini):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
_atributos(ini, quitar=F_READONLY | F_HIDDEN | F_SYSTEM)
|
||||||
|
os.remove(ini)
|
||||||
|
_atributos(base, quitar=F_READONLY)
|
||||||
|
quitadas += 1
|
||||||
|
except OSError as ex:
|
||||||
|
if len(errores) < 5:
|
||||||
|
errores.append(f"{os.path.relpath(base, raiz)}: {ex}")
|
||||||
|
try:
|
||||||
|
shutil.rmtree(os.path.join(raiz, R.CARPETA_APP), ignore_errors=True)
|
||||||
|
ctypes.windll.shell32.SHChangeNotify(0x08000000, 0x0000, None, None)
|
||||||
|
except Exception: # noqa
|
||||||
|
pass
|
||||||
|
return {"quitadas": quitadas, "errores": errores}
|
||||||
|
|
||||||
|
|
||||||
def acciones(raiz):
|
def acciones(raiz):
|
||||||
@@ -3308,6 +3740,16 @@ class Manejador(BaseHTTPRequestHandler):
|
|||||||
return self._json(papelera(os.path.normpath(q["raiz"])))
|
return self._json(papelera(os.path.normpath(q["raiz"])))
|
||||||
if ruta == "/api/vacias":
|
if ruta == "/api/vacias":
|
||||||
return self._json(carpetas_vacias(os.path.normpath(q["raiz"])))
|
return self._json(carpetas_vacias(os.path.normpath(q["raiz"])))
|
||||||
|
if ruta == "/api/estructura/archivo":
|
||||||
|
return self._json(donde_iria(q.get("nombre", ""), q.get("tipo") or "contenido"))
|
||||||
|
if ruta == "/api/estructura/existen":
|
||||||
|
try:
|
||||||
|
segs = json.loads(q.get("ruta") or "[]")
|
||||||
|
except ValueError:
|
||||||
|
segs = []
|
||||||
|
cs = carpetas_como(os.path.normpath(q["raiz"]), segs) if segs else []
|
||||||
|
base = os.path.normpath(q["raiz"])
|
||||||
|
return self._json({"n": len(cs), "carpetas": [os.path.relpath(c, base) for c in cs[:20]]})
|
||||||
if ruta == "/api/estructura":
|
if ruta == "/api/estructura":
|
||||||
raiz = q.get("raiz")
|
raiz = q.get("raiz")
|
||||||
tipo = q.get("tipo")
|
tipo = q.get("tipo")
|
||||||
@@ -3500,6 +3942,13 @@ class Manejador(BaseHTTPRequestHandler):
|
|||||||
return self._json({"resultados": reemplazar(d.get("ids", []))})
|
return self._json({"resultados": reemplazar(d.get("ids", []))})
|
||||||
if ruta == "/api/stock/papelera":
|
if ruta == "/api/stock/papelera":
|
||||||
return self._json(stock_a_papelera(os.path.normpath(d["raiz"]), d.get("rutas", [])))
|
return self._json(stock_a_papelera(os.path.normpath(d["raiz"]), d.get("rutas", [])))
|
||||||
|
if ruta == "/api/estructura/renombrar":
|
||||||
|
return self._json(renombrar_nodo(d.get("tipo") or "contenido", d.get("ruta") or [],
|
||||||
|
d.get("nombre", ""), d.get("raiz"), bool(d.get("en_disco"))))
|
||||||
|
if ruta == "/api/colores/pintar":
|
||||||
|
return self._json(pintar_carpetas(os.path.normpath(d["raiz"])))
|
||||||
|
if ruta == "/api/colores/quitar":
|
||||||
|
return self._json(despintar_carpetas(os.path.normpath(d["raiz"])))
|
||||||
if ruta == "/api/vacias/borrar":
|
if ruta == "/api/vacias/borrar":
|
||||||
return self._json(borrar_vacias(os.path.normpath(d["raiz"]), d.get("rutas", [])))
|
return self._json(borrar_vacias(os.path.normpath(d["raiz"]), d.get("rutas", [])))
|
||||||
if ruta == "/api/duplicados/apartar":
|
if ruta == "/api/duplicados/apartar":
|
||||||
|
|||||||
50
probar.py
50
probar.py
@@ -233,6 +233,56 @@ class Servidor(unittest.TestCase):
|
|||||||
self.assertEqual(self.post("/api/plantillas", {"plantillas": {"contenido": {"MUSICA": "_Recursos/Musica/{sub}"}}})[0], 200)
|
self.assertEqual(self.post("/api/plantillas", {"plantillas": {"contenido": {"MUSICA": "_Recursos/Musica/{sub}"}}})[0], 200)
|
||||||
self.assertEqual(len([c for c in self.get("/api/estructura" + q)["cubetas"] if c["plantilla"]]), antes_pl)
|
self.assertEqual(len([c for c in self.get("/api/estructura" + q)["cubetas"] if c["plantilla"]]), antes_pl)
|
||||||
|
|
||||||
|
# 4b-bis. el mapa al revés: dónde iría un archivo concreto
|
||||||
|
def donde(nombre):
|
||||||
|
return self.get("/api/estructura/archivo?tipo=contenido&nombre=" + urllib.parse.quote(nombre))
|
||||||
|
d = donde("A006_12171629_C001.braw")
|
||||||
|
self.assertEqual(d["cubeta"], "GRABACION")
|
||||||
|
self.assertIn("URSA_BRAW", d["destino"])
|
||||||
|
self.assertIn("12- Diciembre", d["destino"]) # la fecha sale del nombre de la cámara
|
||||||
|
self.assertEqual(donde("Proyecto.aep")["destino"], os.path.join("01_Proyectos", "After Effects"))
|
||||||
|
self.assertEqual(donde("cancion.wav")["cubeta"], "MUSICA")
|
||||||
|
self.assertTrue(donde("")["error"])
|
||||||
|
|
||||||
|
# 4b-ter. renombrar una carpeta del mapa cambia TODAS las plantillas que pasan por ella,
|
||||||
|
# y las carpetas que ya existen en el disco si se pide
|
||||||
|
os.makedirs(os.path.join(self.raiz, "2026", "03- Marzo", "00_Grabacion"), exist_ok=True)
|
||||||
|
self.post("/api/escanear", {"raiz": self.raiz})
|
||||||
|
self.esperar_escaneo()
|
||||||
|
crudo = ["{anio}", "{mes}", "00_Grabacion"]
|
||||||
|
ex = self.get("/api/estructura/existen?raiz=" + urllib.parse.quote(self.raiz)
|
||||||
|
+ "&ruta=" + urllib.parse.quote(json.dumps(crudo)))
|
||||||
|
self.assertEqual(ex["n"], 1)
|
||||||
|
# «{anio}» solo vale por algo que parezca un año
|
||||||
|
self.assertEqual(self.get("/api/estructura/existen?raiz=" + urllib.parse.quote(self.raiz)
|
||||||
|
+ "&ruta=" + urllib.parse.quote(json.dumps(["{anio}"])))["n"], 1)
|
||||||
|
c, r = self.post("/api/estructura/renombrar", {"tipo": "contenido", "ruta": crudo,
|
||||||
|
"nombre": "Grabacion", "raiz": self.raiz, "en_disco": True})
|
||||||
|
self.assertEqual(c, 200)
|
||||||
|
self.assertTrue(r.get("ok"), r)
|
||||||
|
self.assertEqual(r["movidas"], 1)
|
||||||
|
self.assertIn("CELULAR", r["cubetas"]) # la de celular cuelga de la misma carpeta
|
||||||
|
self.assertTrue(os.path.isdir(os.path.join(self.raiz, "2026", "03- Marzo", "Grabacion")))
|
||||||
|
for malo in ("", "con/barra", "{anio}"):
|
||||||
|
self.assertTrue(self.post("/api/estructura/renombrar",
|
||||||
|
{"tipo": "contenido", "ruta": crudo, "nombre": malo})[1].get("error"))
|
||||||
|
shutil.rmtree(os.path.join(self.raiz, "2026"), ignore_errors=True)
|
||||||
|
|
||||||
|
# 4b-quáter. colores de carpeta en Windows: se ponen y se quitan sin tocar ningún archivo
|
||||||
|
self.post("/api/escanear", {"raiz": self.raiz})
|
||||||
|
self.esperar_escaneo()
|
||||||
|
arbol_color = self._arbol()
|
||||||
|
c, col = self.post("/api/colores/pintar", {"raiz": self.raiz})
|
||||||
|
self.assertEqual(c, 200)
|
||||||
|
if not col.get("error"): # algunos discos no lo permiten: es válido
|
||||||
|
self.assertGreater(col["pintadas"], 0)
|
||||||
|
self.assertTrue(os.path.isdir(os.path.join(self.raiz, R.CARPETA_APP, "iconos")))
|
||||||
|
self.assertEqual(arbol_color, self._arbol()) # ni un archivo del cliente tocado
|
||||||
|
c, q = self.post("/api/colores/quitar", {"raiz": self.raiz})
|
||||||
|
self.assertEqual(q["quitadas"], col["pintadas"])
|
||||||
|
self.assertFalse(os.path.exists(os.path.join(self.raiz, R.CARPETA_APP)))
|
||||||
|
self.assertEqual(arbol_color, self._arbol())
|
||||||
|
|
||||||
# 4c. carpetas vacías: se encuentran, se borran sin tocar un archivo y deshacer las devuelve
|
# 4c. carpetas vacías: se encuentran, se borran sin tocar un archivo y deshacer las devuelve
|
||||||
for rel in ("Huecos/uno", "Huecos/dos", "Sola"):
|
for rel in ("Huecos/uno", "Huecos/dos", "Sola"):
|
||||||
os.makedirs(os.path.join(self.raiz, rel.replace("/", os.sep)), exist_ok=True)
|
os.makedirs(os.path.join(self.raiz, rel.replace("/", os.sep)), exist_ok=True)
|
||||||
|
|||||||
16
reglas.py
16
reglas.py
@@ -615,9 +615,23 @@ DUPLICADOS = "Duplicados"
|
|||||||
CARPETAS_PROPIAS = {norm(x) for x in (
|
CARPETAS_PROPIAS = {norm(x) for x in (
|
||||||
"_Para borrar", "_Papelera", "Duplicados", "_Revisar", "_Recursos", "_Apoyos", "_Documentos", "00_Marca", "01_Proyectos", "Campañas",
|
"_Para borrar", "_Papelera", "Duplicados", "_Revisar", "_Recursos", "_Apoyos", "_Documentos", "00_Marca", "01_Proyectos", "Campañas",
|
||||||
"_Exportados", "_Portadas", "00_Grabacion", "_Voces", "_IA", "_Guiones", "_Fotos", "_Musica", "_Archivo",
|
"_Exportados", "_Portadas", "00_Grabacion", "_Voces", "_IA", "_Guiones", "_Fotos", "_Musica", "_Archivo",
|
||||||
"00_Brief", "01_Material", "02_Grabacion", "03_Proyecto", "04_Exportados")}
|
"00_Brief", "01_Material", "02_Grabacion", "03_Proyecto", "04_Exportados", ".organilab")}
|
||||||
|
|
||||||
# Rol de archivo suelto -> cubeta (para archivos que están fuera de sitio)
|
# Rol de archivo suelto -> cubeta (para archivos que están fuera de sitio)
|
||||||
|
# Un color por tipo de material. Se usa en el mapa, en el gráfico de peso y —si se quiere— en el
|
||||||
|
# propio Explorador de Windows, poniéndole a cada carpeta un icono de ese color.
|
||||||
|
COLOR_CUBETA = {
|
||||||
|
"ANIO": "#4f8cff", "GRABACION": "#e0574f", "PIEZA": "#7c5cff", "CAMPANA": "#ff8c42",
|
||||||
|
"EXPORTADOS": "#3ecf8e", "PORTADAS": "#f5b84b", "APOYOS": "#d96fd9", "MARCA": "#39b8c9",
|
||||||
|
"PROYECTOS": "#8fa1b8", "MUSICA": "#c9b339", "SFX": "#c9b339", "VOZ": "#c9b339",
|
||||||
|
"IA": "#a78bfa", "STOCK": "#5aa8e0", "GUION": "#9aa3ad", "FOTOS": "#f5b84b",
|
||||||
|
"DOCUMENTOS": "#9aa3ad", "CELULAR": "#e0574f", "CONTENEDOR": "#6b7280", "REVISAR": "#ff6b6b",
|
||||||
|
"PROXY": "#555c6b", "BASURA": "#3a3f4a", "APARTADO": "#9aa3ad", "": "#4b5563",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Todo lo de la app en la carpeta del cliente vive aquí: los iconos de color, y lo que venga.
|
||||||
|
CARPETA_APP = ".organilab"
|
||||||
|
|
||||||
# Qué es cada rol dicho en llano, para que el mapa pueda explicar qué cae en cada carpeta.
|
# Qué es cada rol dicho en llano, para que el mapa pueda explicar qué cae en cada carpeta.
|
||||||
ROL_QUE = {
|
ROL_QUE = {
|
||||||
"camara_raw": "originales RAW de cámara (.braw, .r3d, .ari)",
|
"camara_raw": "originales RAW de cámara (.braw, .r3d, .ari)",
|
||||||
|
|||||||
@@ -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 .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 .hijos{margin-left:9px;border-left:1px solid var(--line);padding-left:11px}
|
||||||
.arbol .vacio{color:var(--mut);font-weight:400}
|
.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 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 .ruta{font-family:ui-monospace,Consolas,monospace;font-size:11.5px;word-break:break-all;color:var(--mut);margin-bottom:10px}
|
||||||
.det ul{margin:4px 0 0;padding-left:18px;font-size:12.5px}
|
.det 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>
|
<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">
|
<div class="toolbar">
|
||||||
<label>tipo de trabajo <select id="mapaTipo"></select></label>
|
<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>
|
||||||
<div class="row" style="grid-template-columns:1.35fr 1fr">
|
<div class="row" style="grid-template-columns:1.35fr 1fr">
|
||||||
<div class="box"><h3>Cómo van a quedar las carpetas</h3><div id="mapaArbol" class="arbol mut">cargando…</div></div>
|
<div class="box"><h3>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 ----------------
|
// ---------------- mapa: cómo van a quedar las carpetas ----------------
|
||||||
const SEP = '\\';
|
const SEP = '\\';
|
||||||
let MAPAE = null, MAPAE_SEL = '', MAPAE_NODOS = {};
|
let MAPAE = null, MAPAE_SEL = '', MAPAE_NODOS = {}, MAPAE_DESTINO = '';
|
||||||
const cargarMapaEst = seguro(async (tipo) => {
|
const cargarMapaEst = seguro(async (tipo) => {
|
||||||
const raiz = raizActual();
|
const raiz = raizActual();
|
||||||
const sel = $('#mapaTipo');
|
const sel = $('#mapaTipo');
|
||||||
@@ -1315,7 +1335,9 @@ function anotarMapa(n, base){
|
|||||||
function nodoHtml(n){
|
function nodoHtml(n){
|
||||||
const cif = n.n ? `${num(n.n)} · ${gb(n.bytes)}` : '';
|
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('. ');
|
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>` +
|
`<span class="nm">${esc(n.nombre)}${SEP}</span>` +
|
||||||
`${cif ? `<span class="cif">${cif}</span>` : ''}</div>` +
|
`${cif ? `<span class="cif">${cif}</span>` : ''}</div>` +
|
||||||
`${n.hijos && n.hijos.length ? `<div class="hijos">${n.hijos.map(nodoHtml).join('')}</div>` : ''}</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;
|
if (!MAPAE) return;
|
||||||
$('#mapaSub').textContent = (MAPAE.cliente ? MAPAE.cliente + ' · ' : '') + MAPAE.titulo;
|
$('#mapaSub').textContent = (MAPAE.cliente ? MAPAE.cliente + ' · ' : '') + MAPAE.titulo;
|
||||||
$('#mapaArbol').classList.remove('mut');
|
$('#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) +
|
$('#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 => {
|
$$('#mapaArbol .nd').forEach(el => {
|
||||||
const ir = () => { MAPAE_SEL = el.dataset.id; pintarMapaEst(); };
|
const ir = () => { MAPAE_SEL = el.dataset.id; pintarMapaEst(); };
|
||||||
el.onclick = ir; el.onkeydown = e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); ir(); } };
|
el.onclick = ir; el.onkeydown = e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); ir(); } };
|
||||||
@@ -1341,6 +1370,8 @@ function pintarMapaDetalle(){
|
|||||||
caja.className = 'det';
|
caja.className = 'det';
|
||||||
const cubs = (n.cubetas || []).map(c => MAPAE.cubetas.find(x => x.clave === c)).filter(Boolean);
|
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>`;
|
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.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.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){
|
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>`;
|
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;
|
caja.innerHTML = h;
|
||||||
engancharPlantillas(caja);
|
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(){
|
function pintarSinSitio(){
|
||||||
const sin = MAPAE.cubetas.filter(c => c.asignable && !c.plantilla);
|
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
|
// accion() pinta un spinner dentro del elemento: en un <select> eso se llevaría por delante sus opciones
|
||||||
$('#mapaTipo').onchange = () => cargarMapaEst($('#mapaTipo').value);
|
$('#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 ----------------
|
// ---------------- soltar carpeta para añadir ----------------
|
||||||
const zona = $('#zonaSoltar');
|
const zona = $('#zonaSoltar');
|
||||||
['dragenter','dragover'].forEach(ev => document.addEventListener(ev, e => { e.preventDefault(); zona.classList.add('sobre'); $('#vacioCaja').classList.add('sobre'); }));
|
['dragenter','dragover'].forEach(ev => document.addEventListener(ev, e => { e.preventDefault(); zona.classList.add('sobre'); $('#vacioCaja').classList.add('sobre'); }));
|
||||||
|
|||||||
Referencia en una nueva incidencia
Block a user