164 lines
7.3 KiB
Python
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))
|