caudal/programa/cartera/marcas.py
2026-11-01 05:56:12 -06:00

164 lines
7.3 KiB
Python

"""
marcas.py — Ajustes que se mezclan pieza a pieza al sincronizar.
Los ficheros de ajustes que viajan (bitcoin.json y extractos.json) se
partían «entero o nada»: ganaba el de fecha más reciente. Así, con dos copias
abiertas en días distintos, lo cambiado en una se perdía si la otra guardaba
algo después, aunque fuera la app sola (los saldos de la comprobación diaria).
Ahora cada pieza lleva en «_marcas» cuándo la cambió el usuario por última vez:
· cada clave de primer nivel («servidores», «comprobar_al_abrir»…);
· si es un diccionario, cada entrada («fiscal» → «territorio», «resueltas» → una decisión);
· si es una lista de elementos con «id», cada elemento (cada monedero).
Lo que escribe la app sola (saldos, última comprobación…) no cuenta: no cambia
las marcas. Al mezclar gana, pieza a pieza, la de marca más reciente; lo
borrado deja su marca y así no vuelve.
Un fichero de una versión anterior (sin «_marcas») cuenta como si todas sus
piezas tuvieran su «modificado»: mezclado con otro igual de antiguo, el
resultado es el de siempre (gana el más reciente entero).
"""
from __future__ import annotations
import datetime as dt
import json
MARCAS = "_marcas"
_INTERNAS = ("modificado", MARCAS)
# Lo que escribe la app sola en cada fichero: (claves de primer nivel, campos de cada elemento)
MAQUINA = {
"bitcoin.json": ({"ultima"}, {"saldo", "direcciones_usadas", "comprobado", "inicial", "vistas", "revision"}),
}
def ahora() -> str:
return dt.datetime.now().isoformat(timespec="microseconds")
def _canon(v) -> str:
return json.dumps(v, sort_keys=True, ensure_ascii=False)
def _ruta(*partes) -> str:
return json.dumps(list(partes), ensure_ascii=False)
def _es_lista_con_ids(v) -> bool:
return isinstance(v, list) and all(isinstance(x, dict) and isinstance(x.get("id"), str) for x in v)
def piezas(ajuste: dict | None, nombre: str) -> dict[str, object]:
"""{ruta: valor} de lo que decide el usuario. Un diccionario o una lista
con ids dejan además una pieza con su forma, para no perderlos vacíos."""
claves_maquina, campos_maquina = MAQUINA.get(nombre, (set(), set()))
salida: dict[str, object] = {}
for k, v in (ajuste or {}).items():
if k in _INTERNAS or k in claves_maquina:
continue
if isinstance(v, dict):
salida[_ruta(k)] = {"__forma": "dict"}
for sub, x in v.items():
salida[_ruta(k, "d", sub)] = x
elif _es_lista_con_ids(v) and v:
salida[_ruta(k)] = {"__forma": "lista"}
for x in v:
salida[_ruta(k, "l", x["id"])] = {c: y for c, y in x.items() if c not in campos_maquina}
else:
salida[_ruta(k)] = v
return salida
def _marcas_de(ajuste: dict | None, nombre: str) -> dict[str, str]:
ajuste = ajuste or {}
if isinstance(ajuste.get(MARCAS), dict):
return dict(ajuste[MARCAS])
base = str(ajuste.get("modificado") or "")
return {p: base for p in piezas(ajuste, nombre)}
def _marca(ajuste: dict | None, marcas: dict[str, str], ruta: str) -> str:
if ruta in marcas:
return marcas[ruta]
if isinstance((ajuste or {}).get(MARCAS), dict):
return "" # nunca la tuvo: cualquier otra gana
return str((ajuste or {}).get("modificado") or "")
def marcar(anterior: dict | None, nuevo: dict, nombre: str, momento: str | None = None) -> dict:
"""El fichero que se va a guardar, con las marcas al día: las piezas que el
usuario ha cambiado respecto a «anterior» llevan «momento». Si solo ha
cambiado lo de la app, «modificado» no se mueve."""
momento = momento or ahora()
marcas = _marcas_de(anterior, nombre)
antes, despues = piezas(anterior, nombre), piezas(nuevo, nombre)
cambiadas = [p for p in set(antes) | set(despues)
if (p in antes) != (p in despues) or _canon(antes[p]) != _canon(despues[p])]
for p in cambiadas:
marcas[p] = momento
salida = {k: v for k, v in nuevo.items() if k not in _INTERNAS}
salida["modificado"] = momento if cambiadas or not (anterior or {}).get("modificado") else anterior["modificado"]
salida[MARCAS] = marcas
return salida
def mezclar(a: dict, b: dict, nombre: str) -> dict:
"""La mezcla de dos versiones del mismo fichero de ajustes (no toca ninguna).
A igualdad de marca se queda «a» (la copia local)."""
if not isinstance(a.get(MARCAS), dict) and not isinstance(b.get(MARCAS), dict):
# Las dos de antes de las marcas: gana la más reciente entera, como siempre
return json.loads(json.dumps(b if str(b.get("modificado") or "") > str(a.get("modificado") or "") else a))
claves_maquina, campos_maquina = MAQUINA.get(nombre, (set(), set()))
pa, pb = piezas(a, nombre), piezas(b, nombre)
ma, mb = _marcas_de(a, nombre), _marcas_de(b, nombre)
elegidas: dict[str, tuple] = {} # ruta: (lado, valor) de lo que queda
marcas: dict[str, str] = {}
for p in set(pa) | set(pb) | set(ma) | set(mb):
xa, xb = _marca(a, ma, p), _marca(b, mb, p)
gana_b = xb > xa
marcas[p] = xb if gana_b else xa
lado, piezas_lado = (b, pb) if gana_b else (a, pa)
if p in piezas_lado:
elegidas[p] = (lado, piezas_lado[p])
salida: dict = {}
# El orden de las claves y de los elementos: el de «a» y, detrás, lo nuevo de «b»
claves = list(dict.fromkeys([*a.keys(), *b.keys()]))
for k in claves:
if k in _INTERNAS:
continue
if k in claves_maquina:
va, vb = a.get(k), b.get(k)
fa = va.get("fecha") if isinstance(va, dict) else None
fb = vb.get("fecha") if isinstance(vb, dict) else None
salida[k] = vb if (k not in a or (fb and str(fb) > str(fa or ""))) else va
continue
raiz = elegidas.get(_ruta(k))
if raiz is None:
continue # borrada
valor = raiz[1]
if isinstance(valor, dict) and valor.get("__forma") == "dict":
subs = list(dict.fromkeys([*(a.get(k) or {}).keys(), *(b.get(k) or {}).keys()])) \
if isinstance(a.get(k), dict) or isinstance(b.get(k), dict) else []
salida[k] = {s: elegidas[_ruta(k, "d", s)][1] for s in subs if _ruta(k, "d", s) in elegidas}
elif isinstance(valor, dict) and valor.get("__forma") == "lista":
todos = {}
for lado in (a, b):
for x in (lado.get(k) if _es_lista_con_ids(lado.get(k)) else []):
todos.setdefault(x["id"], []).append(x)
lista = []
for ident, versiones in todos.items():
e = elegidas.get(_ruta(k, "l", ident))
if e is None:
continue
# Lo de la app (saldos…): de la versión comprobada más tarde
maquina = max(versiones, key=lambda x: str(x.get("comprobado") or ""))
lista.append({**e[1], **{c: maquina[c] for c in campos_maquina if c in maquina}})
salida[k] = lista
else:
salida[k] = json.loads(json.dumps(valor))
modificado = max(str(a.get("modificado") or ""), str(b.get("modificado") or ""))
if modificado:
salida["modificado"] = modificado
salida[MARCAS] = marcas
return json.loads(json.dumps(salida))