""" 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))