feat: DuckDB como fuente de verdad (multi-valor, ownership selectivo, escritura, libretas)
F1 — migraciones: 002_multivalue (persons +telefonos/emails/direcciones/extra_fm JSON,
backfill desde singulares con to_json) + 003_addressbooks (tabla addressbooks + seed
idempotente de la libreta por defecto). Conteos intactos (697/1065/98).
F2 — ingest_vault selectivo (anti-pisado): personas que ya existen en DB solo actualizan
note_path + extra_fm vía duckdb_upsert(update_cols=...), NO pisan los campos OWNED por la
DB; personas nuevas = bootstrap completo. _link_contacts enlaza por listas telefonos[]/
emails[] además del singular. ingest_dav itera todas las libretas de la tabla addressbooks.
F3 — escritura estructurada (server/writes.py + endpoints en main.py): CRUD
/api/person|contact|event, /api/addressbook, /api/calendar, /api/person/{slug}/render
(DB→nota preservando la prosa del cuerpo), /api/push/dav (reconcilia DB→Xandikos). El push
DAV y el render ocurren fuera de la transacción de escritura para no bloquear la DB con
latencia de red. registry_bridge.py importa las funciones nuevas; app.md actualizado.
Verificado: 18 tests verdes; ownership probado sobre datos reales (un centinela DB-owned
sobrevivió a POST /api/ingest/vault sobre las 697 fichas); person CRUD + materialización
de la ficha .md en vivo, con cleanup sin residuo.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,9 +13,18 @@ uses_functions:
|
||||
- slugify_obsidian_name_py_obsidian
|
||||
- dav_get_collection_py_infra
|
||||
- dav_list_calendars_py_infra
|
||||
- dav_list_addressbooks_py_infra
|
||||
- dav_collection_ctag_py_infra
|
||||
- carddav_put_vcard_py_infra
|
||||
- caldav_put_event_py_infra
|
||||
- dav_delete_resource_py_infra
|
||||
- dav_make_addressbook_py_infra
|
||||
- dav_make_calendar_py_infra
|
||||
- pass_get_secret_py_infra
|
||||
- duckdb_query_readonly_py_infra
|
||||
- duckdb_execute_py_infra
|
||||
- duckdb_upsert_py_infra
|
||||
- build_vcard_py_core
|
||||
- render_markdown_table_py_core
|
||||
- upsert_sentinel_block_py_core
|
||||
uses_types: []
|
||||
@@ -59,7 +68,14 @@ en el body (el plugin parsea el body, no el código HTTP).
|
||||
completo del vault) + `persons`, `organizations`, `domains`, `cases`,
|
||||
`places` (fichas de nivel-1 de cada carpeta de entidades, excluyendo las
|
||||
notas con prefijo `_`). Cada una lleva `note_path`: el path relativo de la
|
||||
nota dentro del vault.
|
||||
nota dentro del vault. **`persons` es dueña de sus campos estructurados**
|
||||
(multi-valor `telefonos`/`emails`/`direcciones` JSON + singulares de compat
|
||||
`telefono`/`email`/`direccion`): la API los edita y los materializa a la
|
||||
nota. El ingest del vault es **selectivo** para `persons` — una ficha que ya
|
||||
existe en la DB solo refresca `note_path` + `extra_fm` (el frontmatter
|
||||
no-owned), conservando los campos OWNED; una ficha nueva se inserta completa
|
||||
(bootstrap desde el frontmatter). `addressbooks` (schema `main`) registra
|
||||
las libretas CardDAV: el ingest DAV las recorre todas (no solo la fija).
|
||||
2. **Maestras DAV** (schema `main`): `contacts` y `events` importados de
|
||||
Xandikos — fuente de verdad del lado agenda/calendario. `contacts.note_path`
|
||||
se enlaza contra `persons` matcheando por UID `osint-<slug>`, por el
|
||||
@@ -104,9 +120,16 @@ Health check: `curl http://127.0.0.1:8771/api/health`.
|
||||
| POST | `/api/query` | `{sql, params, max_rows}` → respuesta exacta de `duckdb_query_readonly` (solo lectura) |
|
||||
| GET | `/api/queries` | catálogo de queries con nombre (`server/named_queries.py`) |
|
||||
| POST | `/api/query/named` | `{name, max_rows}` → misma shape que `/api/query` |
|
||||
| POST | `/api/ingest/vault` | escanea el vault completo y reconstruye notes + entidades + derivadas |
|
||||
| POST | `/api/ingest/dav` | baja Xandikos (CardDAV + cada calendario CalDAV), reconstruye contacts/events, enlaza y reconstruye derivadas |
|
||||
| POST | `/api/ingest/vault` | escanea el vault completo; notes y entidades de espejo puro se reemplazan, persons se ingesta SELECTIVO (existentes solo `note_path`+`extra_fm`, nuevas bootstrap completo) |
|
||||
| POST | `/api/ingest/dav` | baja TODAS las libretas registradas en `addressbooks` + cada calendario CalDAV, reconstruye contacts/events, enlaza y reconstruye derivadas |
|
||||
| POST | `/api/render/note` | `{note_path, block_id, sql\|query, title?}` → tabla Markdown upsertada como bloque sentinel `osintdb` en la nota (la crea si no existe) |
|
||||
| POST/PUT/DELETE | `/api/person[/{slug}]` | CRUD de personas multi-valor (`telefonos`/`emails`/`direcciones` listas). Tras escribir la DB, materializa la ficha DB→nota (singulares = `lista[0]`) sin tocar la prosa |
|
||||
| POST | `/api/person/{slug}/render` | re-materializa la ficha DB→nota (frontmatter OWNED + merge `extra_fm`, preserva el body) |
|
||||
| POST/PUT/DELETE | `/api/contact[/{uid}]` | CRUD de contactos CardDAV (`tels`/`emails` listas). Tras la DB, push DB→Xandikos (`build_vcard`+`carddav_put_vcard`, o `dav_delete_resource` en delete) fuera de la transacción |
|
||||
| POST/PUT/DELETE | `/api/event[/{uid}]` | CRUD de eventos CalDAV. Push `caldav_put_event`/`dav_delete_resource` |
|
||||
| POST | `/api/addressbook` | `{slug, display_name?, description?, color?}` → `dav_make_addressbook` + INSERT en `addressbooks` |
|
||||
| POST | `/api/calendar` | `{slug, display_name?, color?}` → `dav_make_calendar` (paridad) |
|
||||
| POST | `/api/push/dav` | reconcilia en bloque: recorre `contacts` y `events` de la DB y los empuja a Xandikos (PUT, sin borrar). Útil tras la migración |
|
||||
|
||||
Queries con nombre incluidas: `personas_por_contexto`, `personas_recientes`,
|
||||
`eventos_proximos`, `contactos_sin_nota`, `stats_personas`,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Migración 002: campos multi-valor en persons + frontmatter no-owned.
|
||||
--
|
||||
-- La inversión "DuckDB como fuente de verdad" hace que las fichas de persona
|
||||
-- puedan tener varios teléfonos, emails y direcciones (no solo el singular del
|
||||
-- frontmatter). Se añaden columnas JSON con la lista completa y se mantienen las
|
||||
-- columnas singulares (telefono/email/direccion) por compatibilidad: el service
|
||||
-- las rellena con el primer elemento de cada lista al materializar la ficha.
|
||||
--
|
||||
-- extra_fm guarda las claves del frontmatter de la nota que NO son campos OWNED
|
||||
-- de la DB ni claves de control (slug/tipo/fuente/note_path): así un re-ingest
|
||||
-- del vault puede refrescarlas sin pisar lo que la DB posee.
|
||||
--
|
||||
-- DuckDB 1.5.3: soporta ALTER TABLE ADD COLUMN IF NOT EXISTS y to_json([...]).
|
||||
-- Aditiva e idempotente.
|
||||
|
||||
ALTER TABLE persons ADD COLUMN IF NOT EXISTS telefonos JSON;
|
||||
ALTER TABLE persons ADD COLUMN IF NOT EXISTS emails JSON;
|
||||
ALTER TABLE persons ADD COLUMN IF NOT EXISTS direcciones JSON;
|
||||
ALTER TABLE persons ADD COLUMN IF NOT EXISTS extra_fm JSON;
|
||||
|
||||
-- Backfill: deriva las listas desde los singulares existentes la primera vez.
|
||||
-- COALESCE deja '[]' donde no había singular, para no dejar NULLs en las listas.
|
||||
UPDATE persons SET
|
||||
telefonos = CASE WHEN telefonos IS NULL AND telefono IS NOT NULL THEN to_json([telefono]) ELSE COALESCE(telefonos, '[]') END,
|
||||
emails = CASE WHEN emails IS NULL AND email IS NOT NULL THEN to_json([email]) ELSE COALESCE(emails, '[]') END,
|
||||
direcciones = CASE WHEN direcciones IS NULL AND direccion IS NOT NULL THEN to_json([direccion]) ELSE COALESCE(direcciones,'[]') END;
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Migración 003: tabla de libretas CardDAV (addressbooks).
|
||||
--
|
||||
-- La DB es la fuente de verdad de las libretas de contactos: el ingest DAV
|
||||
-- itera todas las libretas registradas aquí (no solo la colección fija) y cada
|
||||
-- contacto guarda su collection real. Los endpoints de escritura crean libretas
|
||||
-- nuevas en Xandikos y las registran aquí.
|
||||
--
|
||||
-- Aditiva e idempotente: CREATE TABLE IF NOT EXISTS + seed ON CONFLICT DO NOTHING.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS addressbooks (
|
||||
slug VARCHAR PRIMARY KEY,
|
||||
display_name VARCHAR,
|
||||
collection_path VARCHAR,
|
||||
description VARCHAR,
|
||||
color VARCHAR,
|
||||
created_at TIMESTAMP DEFAULT now()
|
||||
);
|
||||
|
||||
-- Seed idempotente de la libreta por defecto (la que apunta config.py por
|
||||
-- defecto). Re-aplicar la migración no la duplica.
|
||||
INSERT INTO addressbooks (slug, display_name, collection_path, description, color)
|
||||
VALUES ('addressbook', 'Contactos', '/enmanuel/contacts/addressbook/', NULL, NULL)
|
||||
ON CONFLICT (slug) DO NOTHING;
|
||||
+344
-78
@@ -1,9 +1,21 @@
|
||||
"""Ingests del service osint_db: vault Obsidian y servidor DAV (Xandikos).
|
||||
|
||||
Las tablas maestras se reconstruyen por reemplazo completo (DELETE + INSERT en
|
||||
una transacción): el vault y Xandikos son las fuentes de verdad, así que cada
|
||||
ingest deja la base exactamente como el origen. Tras cada ingest se re-enlazan
|
||||
los contactos con sus fichas y se reconstruyen las tablas derivadas.
|
||||
Inversión "DuckDB como fuente de verdad": las tablas de espejo puro (notes, y
|
||||
las entidades que solo guardan frontmatter JSON: organizations/domains/cases/
|
||||
places) se reconstruyen por reemplazo completo (DELETE + INSERT). Pero persons
|
||||
ya NO es un espejo del frontmatter: la DB es la dueña de sus campos estructurados
|
||||
(teléfonos, emails, direcciones, dni, ...), que se editan por la API y se
|
||||
materializan a la nota. Por eso el ingest del vault es SELECTIVO para persons:
|
||||
|
||||
- slug que YA existe en la DB -> solo refresca note_path + extra_fm (el
|
||||
frontmatter no-owned); los campos OWNED de la DB se conservan.
|
||||
- slug NUEVO -> INSERT completo (bootstrap): adopta el frontmatter como valor
|
||||
inicial, poblando las listas multi-valor desde los singulares o las listas
|
||||
del frontmatter.
|
||||
|
||||
contacts y events siguen siendo espejo puro de Xandikos (DELETE + INSERT). Tras
|
||||
cada ingest se re-enlazan los contactos con sus fichas y se reconstruyen las
|
||||
derivadas. Todo bajo el lock single-writer del service.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -19,12 +31,65 @@ from .db import write_conn
|
||||
from .derived import rebuild_derived
|
||||
from .registry_bridge import (
|
||||
dav_get_collection,
|
||||
dav_list_addressbooks,
|
||||
dav_list_calendars,
|
||||
duckdb_upsert,
|
||||
list_obsidian_notes,
|
||||
pass_get_secret,
|
||||
read_obsidian_note,
|
||||
)
|
||||
|
||||
# Campos de persons de los que la DB es dueña: un re-ingest del vault NO los
|
||||
# pisa cuando la ficha ya existe. Incluye los singulares (derivados de las
|
||||
# listas) y fuente (origen de la ficha). dav_uid se deriva de fuente.
|
||||
PERSON_OWNED = (
|
||||
"nombre",
|
||||
"aliases",
|
||||
"sexo",
|
||||
"fecha_nacimiento",
|
||||
"dni",
|
||||
"telefono",
|
||||
"email",
|
||||
"direccion",
|
||||
"telefonos",
|
||||
"emails",
|
||||
"direcciones",
|
||||
"pais",
|
||||
"contexto",
|
||||
"fuente",
|
||||
"dav_uid",
|
||||
"tags",
|
||||
)
|
||||
|
||||
# Claves de control del frontmatter que no van a extra_fm (ni son OWNED): las
|
||||
# gestiona el propio ingest o son metadata de la nota.
|
||||
PERSON_CONTROL = ("slug", "tipo", "note_path")
|
||||
|
||||
# Orden de columnas de la tabla persons (debe casar con migrations 001 + 002).
|
||||
PERSON_COLUMNS = (
|
||||
"slug",
|
||||
"note_path",
|
||||
"nombre",
|
||||
"aliases",
|
||||
"sexo",
|
||||
"fecha_nacimiento",
|
||||
"dni",
|
||||
"telefono",
|
||||
"email",
|
||||
"direccion",
|
||||
"pais",
|
||||
"contexto",
|
||||
"fuente",
|
||||
"dav_uid",
|
||||
"tags",
|
||||
"updated_at",
|
||||
"telefonos",
|
||||
"emails",
|
||||
"direcciones",
|
||||
"extra_fm",
|
||||
)
|
||||
|
||||
|
||||
def _norm(value):
|
||||
"""Normaliza 'null'/''/None del frontmatter a None real."""
|
||||
if value is None:
|
||||
@@ -48,6 +113,22 @@ def _as_list(value) -> list:
|
||||
return v if isinstance(v, list) else [v]
|
||||
|
||||
|
||||
def _multi(fm: dict, plural: str, singular: str) -> list:
|
||||
"""Lista multi-valor desde el frontmatter: prioriza la clave plural.
|
||||
|
||||
Si el frontmatter trae la clave plural (telefonos/emails/direcciones) la
|
||||
usa; si no, envuelve el singular (telefono/email/direccion) en lista. Cada
|
||||
valor se normaliza a str y se descartan los vacíos.
|
||||
"""
|
||||
raw = _as_list(fm.get(plural)) or _as_list(fm.get(singular))
|
||||
out = []
|
||||
for item in raw:
|
||||
s = _as_str(item)
|
||||
if s:
|
||||
out.append(s)
|
||||
return out
|
||||
|
||||
|
||||
def _json(value) -> str:
|
||||
"""Serializa un valor a JSON compacto (sin escapar acentos)."""
|
||||
return json.dumps(value, ensure_ascii=False, default=str)
|
||||
@@ -61,17 +142,75 @@ def _dav_uid_from_fuente(fuente) -> str | None:
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def _extra_fm(fm: dict) -> str:
|
||||
"""JSON con las claves del frontmatter que NO son OWNED ni de control.
|
||||
|
||||
Permite que un re-ingest refresque el frontmatter no estructurado de la
|
||||
ficha (campos libres que la DB no posee) sin pisar lo que la DB sí posee.
|
||||
Las claves plurales multi-valor también se excluyen (son OWNED).
|
||||
"""
|
||||
skip = set(PERSON_OWNED) | set(PERSON_CONTROL)
|
||||
extra = {k: v for k, v in fm.items() if k not in skip}
|
||||
return _json(extra)
|
||||
|
||||
|
||||
def _person_row_from_fm(slug: str, rel_path: str, fm: dict, mtime, base: str) -> dict:
|
||||
"""Construye la fila completa de persons desde el frontmatter (bootstrap).
|
||||
|
||||
Se usa SOLO para fichas cuyo slug aún no existe en la DB: adopta el
|
||||
frontmatter como valor inicial de los campos OWNED, derivando las listas
|
||||
multi-valor desde el plural o el singular del frontmatter y rellenando los
|
||||
singulares con el primer elemento de cada lista.
|
||||
"""
|
||||
telefonos = _multi(fm, "telefonos", "telefono")
|
||||
emails = _multi(fm, "emails", "email")
|
||||
direcciones = _multi(fm, "direcciones", "direccion")
|
||||
return {
|
||||
"slug": slug,
|
||||
"note_path": rel_path,
|
||||
"nombre": _as_str(fm.get("nombre")) or base,
|
||||
"aliases": _json(_as_list(fm.get("aliases"))),
|
||||
"sexo": _as_str(fm.get("sexo")),
|
||||
"fecha_nacimiento": _as_str(fm.get("fecha_nacimiento")),
|
||||
"dni": _as_str(fm.get("dni")),
|
||||
"telefono": telefonos[0] if telefonos else None,
|
||||
"email": emails[0] if emails else None,
|
||||
"direccion": direcciones[0] if direcciones else None,
|
||||
"pais": _as_str(fm.get("pais")),
|
||||
"contexto": _as_str(fm.get("contexto")),
|
||||
"fuente": _as_str(fm.get("fuente")),
|
||||
"dav_uid": _dav_uid_from_fuente(fm.get("fuente")),
|
||||
"tags": _json(_as_list(fm.get("tags"))),
|
||||
"updated_at": mtime,
|
||||
"telefonos": _json(telefonos),
|
||||
"emails": _json(emails),
|
||||
"direcciones": _json(direcciones),
|
||||
"extra_fm": _extra_fm(fm),
|
||||
}
|
||||
|
||||
|
||||
def ingest_vault(cfg: Config) -> dict:
|
||||
"""Escanea el vault completo y reconstruye notes + tablas de entidades.
|
||||
|
||||
Devuelve {status:'ok', notes:N, persons:N, organizations:N, domains:N,
|
||||
cases:N, places:N, skipped_unreadable:N, derived_rebuilt:[...]}.
|
||||
notes y las entidades de espejo puro (organizations/domains/cases/places)
|
||||
se reemplazan por completo. persons se ingesta de forma SELECTIVA: las
|
||||
fichas existentes solo refrescan note_path + extra_fm (conservando los
|
||||
campos OWNED de la DB), las nuevas se insertan completas (bootstrap).
|
||||
|
||||
Devuelve {status:'ok', notes:N, persons:N, persons_inserted:N,
|
||||
persons_updated:N, organizations:N, domains:N, cases:N, places:N,
|
||||
skipped_unreadable:N, derived_rebuilt:[...]}.
|
||||
"""
|
||||
if not os.path.isdir(cfg.vault_dir):
|
||||
return {"status": "error", "error": f"vault no encontrado: {cfg.vault_dir}"}
|
||||
|
||||
note_rows: list = []
|
||||
entity_rows: dict = {table: [] for _, _, table in cfg.entity_folders}
|
||||
# Filas de espejo puro (todas las entidades menos persons).
|
||||
mirror_rows: dict = {
|
||||
table: [] for _, _, table in cfg.entity_folders if table != "persons"
|
||||
}
|
||||
# Fichas de persona, deduplicadas por slug, como dicts {slug, fm, ...}.
|
||||
person_fichas: dict = {}
|
||||
folder_to_table = {folder: table for folder, _, table in cfg.entity_folders}
|
||||
skipped = 0
|
||||
|
||||
@@ -105,28 +244,17 @@ def ingest_vault(cfg: Config) -> dict:
|
||||
if top_folder in folder_to_table and is_level1 and not base.startswith("_"):
|
||||
table = folder_to_table[top_folder]
|
||||
if table == "persons":
|
||||
entity_rows[table].append(
|
||||
[
|
||||
slug,
|
||||
rel_path,
|
||||
_as_str(fm.get("nombre")) or base,
|
||||
_json(_as_list(fm.get("aliases"))),
|
||||
_as_str(fm.get("sexo")),
|
||||
_as_str(fm.get("fecha_nacimiento")),
|
||||
_as_str(fm.get("dni")),
|
||||
_as_str(fm.get("telefono")),
|
||||
_as_str(fm.get("email")),
|
||||
_as_str(fm.get("direccion")),
|
||||
_as_str(fm.get("pais")),
|
||||
_as_str(fm.get("contexto")),
|
||||
_as_str(fm.get("fuente")),
|
||||
_dav_uid_from_fuente(fm.get("fuente")),
|
||||
_json(_as_list(fm.get("tags"))),
|
||||
mtime,
|
||||
]
|
||||
)
|
||||
# Gana la primera ficha vista con cada slug (respeta la PK).
|
||||
if slug not in person_fichas:
|
||||
person_fichas[slug] = {
|
||||
"slug": slug,
|
||||
"rel_path": rel_path,
|
||||
"fm": fm,
|
||||
"mtime": mtime,
|
||||
"base": base,
|
||||
}
|
||||
else:
|
||||
entity_rows[table].append(
|
||||
mirror_rows[table].append(
|
||||
[
|
||||
slug,
|
||||
rel_path,
|
||||
@@ -137,7 +265,6 @@ def ingest_vault(cfg: Config) -> dict:
|
||||
]
|
||||
)
|
||||
|
||||
derived_rebuilt: list = []
|
||||
with write_conn(cfg.db_path) as conn:
|
||||
conn.execute("BEGIN")
|
||||
try:
|
||||
@@ -146,20 +273,27 @@ def ingest_vault(cfg: Config) -> dict:
|
||||
conn.executemany(
|
||||
"INSERT INTO notes VALUES (?, ?, ?, ?, ?, ?)", note_rows
|
||||
)
|
||||
conn.execute("DELETE FROM persons")
|
||||
if entity_rows["persons"]:
|
||||
conn.executemany(
|
||||
"INSERT INTO persons VALUES "
|
||||
"(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
_dedup_by_slug(entity_rows["persons"]),
|
||||
)
|
||||
for table in ("organizations", "domains", "cases", "places"):
|
||||
conn.execute(f"DELETE FROM {table}")
|
||||
if entity_rows[table]:
|
||||
if mirror_rows[table]:
|
||||
conn.executemany(
|
||||
f"INSERT INTO {table} VALUES (?, ?, ?, ?, ?, ?)",
|
||||
_dedup_by_slug(entity_rows[table]),
|
||||
_dedup_by_slug(mirror_rows[table]),
|
||||
)
|
||||
conn.execute("COMMIT")
|
||||
except Exception:
|
||||
conn.execute("ROLLBACK")
|
||||
raise
|
||||
|
||||
# persons: ingest selectivo via duckdb_upsert (ownership de campos OWNED).
|
||||
# Se hace fuera de la transacción de espejo puro pero bajo el mismo lock de
|
||||
# proceso (write_conn ya lo liberó): single-writer respetado.
|
||||
p_inserted, p_updated = _ingest_persons_selective(cfg.db_path, person_fichas)
|
||||
|
||||
# Re-enlace de contactos + derivadas, de nuevo bajo el lock de escritura.
|
||||
with write_conn(cfg.db_path) as conn:
|
||||
conn.execute("BEGIN")
|
||||
try:
|
||||
_link_contacts(conn)
|
||||
conn.execute("COMMIT")
|
||||
except Exception:
|
||||
@@ -170,16 +304,89 @@ def ingest_vault(cfg: Config) -> dict:
|
||||
return {
|
||||
"status": "ok",
|
||||
"notes": len(note_rows),
|
||||
"persons": len(_dedup_by_slug(entity_rows["persons"])),
|
||||
"organizations": len(_dedup_by_slug(entity_rows["organizations"])),
|
||||
"domains": len(_dedup_by_slug(entity_rows["domains"])),
|
||||
"cases": len(_dedup_by_slug(entity_rows["cases"])),
|
||||
"places": len(_dedup_by_slug(entity_rows["places"])),
|
||||
"persons": len(person_fichas),
|
||||
"persons_inserted": p_inserted,
|
||||
"persons_updated": p_updated,
|
||||
"organizations": len(_dedup_by_slug(mirror_rows["organizations"])),
|
||||
"domains": len(_dedup_by_slug(mirror_rows["domains"])),
|
||||
"cases": len(_dedup_by_slug(mirror_rows["cases"])),
|
||||
"places": len(_dedup_by_slug(mirror_rows["places"])),
|
||||
"skipped_unreadable": skipped,
|
||||
"derived_rebuilt": derived_rebuilt,
|
||||
}
|
||||
|
||||
|
||||
def _ingest_persons_selective(db_path: str, person_fichas: dict) -> tuple:
|
||||
"""Upsert selectivo de persons: existentes solo note_path+extra_fm, nuevas full.
|
||||
|
||||
Lee qué slugs existen ya en la DB y reparte las fichas en dos lotes:
|
||||
- existentes -> duckdb_upsert con update_cols=['note_path','extra_fm'],
|
||||
de modo que los campos OWNED de la DB no se pisan.
|
||||
- nuevas -> duckdb_upsert con update_cols=None (full insert de bootstrap).
|
||||
|
||||
Devuelve (inserted, updated). Bajo el lock single-writer del proceso:
|
||||
duckdb_upsert abre su propia conexión, pero DuckDB comparte la instancia
|
||||
de la base dentro del proceso, así que no hay conflicto de lock.
|
||||
|
||||
Raises:
|
||||
RuntimeError: si algún upsert de persons devuelve status 'error'.
|
||||
"""
|
||||
if not person_fichas:
|
||||
return 0, 0
|
||||
|
||||
# Slugs ya presentes en la DB (lectura read-only, sin tocar el writer).
|
||||
import duckdb
|
||||
|
||||
conn = duckdb.connect(db_path, read_only=True)
|
||||
try:
|
||||
existing = {row[0] for row in conn.execute("SELECT slug FROM persons").fetchall()}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
existing_rows: list = []
|
||||
new_rows: list = []
|
||||
for slug, ficha in person_fichas.items():
|
||||
full = _person_row_from_fm(
|
||||
ficha["slug"], ficha["rel_path"], ficha["fm"], ficha["mtime"], ficha["base"]
|
||||
)
|
||||
if slug in existing:
|
||||
# Solo los campos que el vault sigue gobernando para fichas vivas:
|
||||
# dónde está la nota y el frontmatter no-owned.
|
||||
existing_rows.append(
|
||||
{
|
||||
"slug": full["slug"],
|
||||
"note_path": full["note_path"],
|
||||
"extra_fm": full["extra_fm"],
|
||||
}
|
||||
)
|
||||
else:
|
||||
new_rows.append(full)
|
||||
|
||||
inserted = updated = 0
|
||||
if existing_rows:
|
||||
res = duckdb_upsert(
|
||||
db_path,
|
||||
"persons",
|
||||
existing_rows,
|
||||
key_cols=["slug"],
|
||||
update_cols=["note_path", "extra_fm"],
|
||||
)
|
||||
if res.get("status") != "ok":
|
||||
raise RuntimeError(f"persons upsert (existentes): {res.get('error')}")
|
||||
updated += res.get("updated", 0)
|
||||
# Filas "existentes" que en realidad ya no estaban (carrera) se insertan.
|
||||
inserted += res.get("inserted", 0)
|
||||
if new_rows:
|
||||
res = duckdb_upsert(
|
||||
db_path, "persons", new_rows, key_cols=["slug"], update_cols=None
|
||||
)
|
||||
if res.get("status") != "ok":
|
||||
raise RuntimeError(f"persons upsert (nuevas): {res.get('error')}")
|
||||
inserted += res.get("inserted", 0)
|
||||
updated += res.get("updated", 0)
|
||||
return inserted, updated
|
||||
|
||||
|
||||
def _dedup_by_slug(rows: list) -> list:
|
||||
"""Quita filas con slug repetido (gana la primera) para respetar la PK."""
|
||||
seen, out = set(), []
|
||||
@@ -194,7 +401,10 @@ def _dedup_by_slug(rows: list) -> list:
|
||||
def ingest_dav(cfg: Config) -> dict:
|
||||
"""Baja las colecciones de Xandikos y reconstruye contacts + events.
|
||||
|
||||
Devuelve {status:'ok', contacts:N, events:N, calendars:[...],
|
||||
Itera TODAS las libretas CardDAV registradas en la tabla addressbooks (no
|
||||
solo la colección fija): cada contacto guarda su collection real. Si la
|
||||
tabla está vacía cae a la colección por defecto de la config. Devuelve
|
||||
{status:'ok', contacts:N, events:N, addressbooks:[...], calendars:[...],
|
||||
contacts_linked:N, derived_rebuilt:[...]} o {status:'error', error}.
|
||||
"""
|
||||
secret = pass_get_secret(cfg.pass_secret)
|
||||
@@ -206,37 +416,42 @@ def ingest_dav(cfg: Config) -> dict:
|
||||
}
|
||||
pwd = secret["value"] # sensible: nunca logear
|
||||
|
||||
coll = dav_get_collection(
|
||||
cfg.dav_base, cfg.dav_user, pwd, cfg.dav_contacts_collection, "vcard"
|
||||
)
|
||||
if coll.get("status") != "ok":
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"CardDAV: {coll.get('error')} (http {coll.get('http_status')})",
|
||||
}
|
||||
collections = _addressbook_collections(cfg)
|
||||
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
contact_rows: list = []
|
||||
seen_uids: set = set()
|
||||
for res in coll.get("resources", []):
|
||||
parsed = davparse.parse_vcard(res.get("data", ""))
|
||||
uid = parsed["uid"] or os.path.splitext(os.path.basename(res["href"]))[0]
|
||||
if uid in seen_uids:
|
||||
continue
|
||||
seen_uids.add(uid)
|
||||
contact_rows.append(
|
||||
[
|
||||
uid,
|
||||
cfg.dav_contacts_collection,
|
||||
res.get("etag"),
|
||||
parsed["fn"] or None,
|
||||
_json(parsed["tels"]),
|
||||
_json(parsed["emails"]),
|
||||
res.get("data", ""),
|
||||
None, # note_path se rellena en el enlace posterior
|
||||
now,
|
||||
]
|
||||
used_addressbooks: list = []
|
||||
for collection in collections:
|
||||
coll = dav_get_collection(
|
||||
cfg.dav_base, cfg.dav_user, pwd, collection, "vcard"
|
||||
)
|
||||
if coll.get("status") != "ok":
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"CardDAV {collection}: {coll.get('error')} "
|
||||
f"(http {coll.get('http_status')})",
|
||||
}
|
||||
used_addressbooks.append(collection)
|
||||
for res in coll.get("resources", []):
|
||||
parsed = davparse.parse_vcard(res.get("data", ""))
|
||||
uid = parsed["uid"] or os.path.splitext(os.path.basename(res["href"]))[0]
|
||||
if uid in seen_uids:
|
||||
continue
|
||||
seen_uids.add(uid)
|
||||
contact_rows.append(
|
||||
[
|
||||
uid,
|
||||
collection,
|
||||
res.get("etag"),
|
||||
parsed["fn"] or None,
|
||||
_json(parsed["tels"]),
|
||||
_json(parsed["emails"]),
|
||||
res.get("data", ""),
|
||||
None, # note_path se rellena en el enlace posterior
|
||||
now,
|
||||
]
|
||||
)
|
||||
|
||||
cals = dav_list_calendars(cfg.dav_base, cfg.dav_user, pwd, cfg.dav_calendar_home)
|
||||
if cals.get("status") != "ok":
|
||||
@@ -308,33 +523,63 @@ def ingest_dav(cfg: Config) -> dict:
|
||||
"status": "ok",
|
||||
"contacts": len(contact_rows),
|
||||
"events": len(event_rows),
|
||||
"addressbooks": used_addressbooks,
|
||||
"calendars": calendar_names,
|
||||
"contacts_linked": linked,
|
||||
"derived_rebuilt": derived_rebuilt,
|
||||
}
|
||||
|
||||
|
||||
def _addressbook_collections(cfg: Config) -> list:
|
||||
"""Colecciones CardDAV a recorrer en el ingest DAV.
|
||||
|
||||
Fuente de verdad: la tabla addressbooks de la DB. Si está vacía (o ilegible)
|
||||
cae a la colección por defecto de la config para no romper el ingest.
|
||||
"""
|
||||
try:
|
||||
import duckdb
|
||||
|
||||
conn = duckdb.connect(cfg.db_path, read_only=True)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT collection_path FROM addressbooks "
|
||||
"WHERE collection_path IS NOT NULL ORDER BY slug"
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
collections = [r[0] for r in rows if r[0]]
|
||||
except Exception: # noqa: BLE001 — tabla ausente/ilegible -> default
|
||||
collections = []
|
||||
if not collections:
|
||||
collections = [cfg.dav_contacts_collection]
|
||||
return collections
|
||||
|
||||
|
||||
def _link_contacts(conn) -> int:
|
||||
"""Enlaza contacts.note_path contra las fichas de persons.
|
||||
|
||||
Orden de matching por fiabilidad: UID estilo osint-<slug> (creado por el
|
||||
push del vault), dav_uid registrado en la ficha, teléfono normalizado y
|
||||
por último email. Devuelve el número de contactos enlazados.
|
||||
push del vault), dav_uid registrado en la ficha, teléfono normalizado
|
||||
(singular o cualquiera de la lista telefonos[]) y por último email (singular
|
||||
o cualquiera de emails[]). Devuelve el número de contactos enlazados.
|
||||
"""
|
||||
persons = conn.execute(
|
||||
"SELECT slug, note_path, telefono, email, dav_uid FROM persons"
|
||||
"SELECT slug, note_path, telefono, email, dav_uid, telefonos, emails "
|
||||
"FROM persons"
|
||||
).fetchall()
|
||||
by_slug, by_dav_uid, by_phone, by_email = {}, {}, {}, {}
|
||||
for slug, note_path, telefono, email, dav_uid in persons:
|
||||
for slug, note_path, telefono, email, dav_uid, telefonos, emails in persons:
|
||||
by_slug[slug] = note_path
|
||||
if dav_uid:
|
||||
by_dav_uid.setdefault(dav_uid, note_path)
|
||||
if telefono:
|
||||
key = davparse.norm_phone(telefono)
|
||||
# Teléfonos: singular + todos los de la lista multi-valor.
|
||||
for tel in _coalesce_values(telefono, telefonos):
|
||||
key = davparse.norm_phone(tel)
|
||||
if key:
|
||||
by_phone.setdefault(key, note_path)
|
||||
if email:
|
||||
by_email.setdefault(str(email).strip().lower(), note_path)
|
||||
# Emails: singular + todos los de la lista multi-valor.
|
||||
for em in _coalesce_values(email, emails):
|
||||
by_email.setdefault(str(em).strip().lower(), note_path)
|
||||
|
||||
contacts = conn.execute("SELECT uid, tels, emails FROM contacts").fetchall()
|
||||
linked = 0
|
||||
@@ -362,3 +607,24 @@ def _link_contacts(conn) -> int:
|
||||
)
|
||||
linked += 1
|
||||
return linked
|
||||
|
||||
|
||||
def _coalesce_values(singular, list_json) -> list:
|
||||
"""Une el valor singular con la lista JSON multi-valor (sin vacíos ni dups)."""
|
||||
out, seen = [], set()
|
||||
candidates = []
|
||||
if singular:
|
||||
candidates.append(singular)
|
||||
try:
|
||||
parsed = json.loads(list_json) if list_json else []
|
||||
except (TypeError, ValueError):
|
||||
parsed = []
|
||||
if isinstance(parsed, list):
|
||||
candidates.extend(parsed)
|
||||
for v in candidates:
|
||||
s = str(v).strip()
|
||||
key = s.lower()
|
||||
if s and key not in seen:
|
||||
seen.add(key)
|
||||
out.append(s)
|
||||
return out
|
||||
|
||||
+134
@@ -55,6 +55,7 @@ from server.registry_bridge import ( # noqa: E402
|
||||
update_obsidian_note,
|
||||
upsert_sentinel_block,
|
||||
)
|
||||
from server import writes # noqa: E402
|
||||
|
||||
# Tope de filas que un render vuelca en una nota (las notas no son un export).
|
||||
RENDER_MAX_ROWS = 200
|
||||
@@ -85,6 +86,71 @@ class RenderNoteBody(BaseModel):
|
||||
title: str | None = None
|
||||
|
||||
|
||||
class PersonBody(BaseModel):
|
||||
"""Body de POST/PUT /api/person: ficha de persona multi-valor.
|
||||
|
||||
Las listas telefonos/emails/direcciones son la fuente de verdad; los
|
||||
singulares de la nota se derivan del primer elemento al materializar.
|
||||
"""
|
||||
|
||||
slug: str | None = None
|
||||
nombre: str | None = None
|
||||
aliases: list = Field(default_factory=list)
|
||||
sexo: str | None = None
|
||||
fecha_nacimiento: str | None = None
|
||||
dni: str | None = None
|
||||
pais: str | None = None
|
||||
contexto: str | None = None
|
||||
telefonos: list = Field(default_factory=list)
|
||||
emails: list = Field(default_factory=list)
|
||||
direcciones: list = Field(default_factory=list)
|
||||
tags: list = Field(default_factory=list)
|
||||
|
||||
|
||||
class ContactBody(BaseModel):
|
||||
"""Body de POST/PUT /api/contact: contacto CardDAV multi-valor."""
|
||||
|
||||
uid: str | None = None
|
||||
nombre: str | None = None
|
||||
fn: str | None = None
|
||||
collection: str | None = None
|
||||
tels: list = Field(default_factory=list)
|
||||
telefonos: list = Field(default_factory=list)
|
||||
emails: list = Field(default_factory=list)
|
||||
correos: list = Field(default_factory=list)
|
||||
direcciones: list = Field(default_factory=list)
|
||||
|
||||
|
||||
class EventBody(BaseModel):
|
||||
"""Body de POST/PUT /api/event: evento CalDAV."""
|
||||
|
||||
uid: str | None = None
|
||||
calendar: str | None = None
|
||||
summary: str | None = None
|
||||
dtstart: str | None = None
|
||||
dtend: str | None = None
|
||||
all_day: bool = False
|
||||
location: str | None = None
|
||||
rrule: str | None = None
|
||||
|
||||
|
||||
class AddressbookBody(BaseModel):
|
||||
"""Body de POST /api/addressbook: crea libreta CardDAV."""
|
||||
|
||||
slug: str
|
||||
display_name: str | None = None
|
||||
description: str | None = None
|
||||
color: str | None = None
|
||||
|
||||
|
||||
class CalendarBody(BaseModel):
|
||||
"""Body de POST /api/calendar: crea calendario CalDAV."""
|
||||
|
||||
slug: str
|
||||
display_name: str | None = None
|
||||
color: str | None = None
|
||||
|
||||
|
||||
def create_app(cfg: Config) -> FastAPI:
|
||||
"""Construye la app FastAPI con la configuración dada (inyectable en tests)."""
|
||||
app = FastAPI(title="osint_db", docs_url=None, redoc_url=None)
|
||||
@@ -215,6 +281,74 @@ def create_app(cfg: Config) -> FastAPI:
|
||||
"rows_rendered": result["row_count"],
|
||||
}
|
||||
|
||||
# --- Escritura estructurada (DB fuente de verdad) ---------------------
|
||||
# Todos responden 200 + {status}. La escritura DB va bajo el lock del
|
||||
# service; el push DAV y el render ocurren tras cerrar la transacción.
|
||||
|
||||
def _guard(fn) -> dict:
|
||||
try:
|
||||
return fn()
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
@app.post("/api/person")
|
||||
def create_person(body: PersonBody) -> dict:
|
||||
if not body.slug:
|
||||
return {"status": "error", "error": "falta 'slug'"}
|
||||
return _guard(lambda: writes.upsert_person(cfg, body.slug, body.model_dump()))
|
||||
|
||||
@app.put("/api/person/{slug}")
|
||||
def update_person(slug: str, body: PersonBody) -> dict:
|
||||
return _guard(lambda: writes.upsert_person(cfg, slug, body.model_dump()))
|
||||
|
||||
@app.delete("/api/person/{slug}")
|
||||
def remove_person(slug: str) -> dict:
|
||||
return _guard(lambda: writes.delete_person(cfg, slug))
|
||||
|
||||
@app.post("/api/person/{slug}/render")
|
||||
def materialize_person(slug: str) -> dict:
|
||||
return _guard(lambda: writes.render_person(cfg, slug))
|
||||
|
||||
@app.post("/api/contact")
|
||||
def create_contact(body: ContactBody) -> dict:
|
||||
if not body.uid:
|
||||
return {"status": "error", "error": "falta 'uid'"}
|
||||
return _guard(lambda: writes.upsert_contact(cfg, body.uid, body.model_dump()))
|
||||
|
||||
@app.put("/api/contact/{uid}")
|
||||
def update_contact(uid: str, body: ContactBody) -> dict:
|
||||
return _guard(lambda: writes.upsert_contact(cfg, uid, body.model_dump()))
|
||||
|
||||
@app.delete("/api/contact/{uid}")
|
||||
def remove_contact(uid: str) -> dict:
|
||||
return _guard(lambda: writes.delete_contact(cfg, uid))
|
||||
|
||||
@app.post("/api/event")
|
||||
def create_event(body: EventBody) -> dict:
|
||||
if not body.uid:
|
||||
return {"status": "error", "error": "falta 'uid'"}
|
||||
return _guard(lambda: writes.upsert_event(cfg, body.uid, body.model_dump()))
|
||||
|
||||
@app.put("/api/event/{uid}")
|
||||
def update_event(uid: str, body: EventBody) -> dict:
|
||||
return _guard(lambda: writes.upsert_event(cfg, uid, body.model_dump()))
|
||||
|
||||
@app.delete("/api/event/{uid}")
|
||||
def remove_event(uid: str) -> dict:
|
||||
return _guard(lambda: writes.delete_event(cfg, uid))
|
||||
|
||||
@app.post("/api/addressbook")
|
||||
def create_addressbook(body: AddressbookBody) -> dict:
|
||||
return _guard(lambda: writes.make_addressbook(cfg, body.model_dump()))
|
||||
|
||||
@app.post("/api/calendar")
|
||||
def create_calendar(body: CalendarBody) -> dict:
|
||||
return _guard(lambda: writes.make_calendar(cfg, body.model_dump()))
|
||||
|
||||
@app.post("/api/push/dav")
|
||||
def push_dav() -> dict:
|
||||
return _guard(lambda: writes.push_all_dav(cfg))
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -89,8 +89,22 @@ def _load_registry_fn(package: str, module_name: str, attr: str):
|
||||
# Grupo dav: lectura bulk de colecciones Xandikos (CardDAV/CalDAV).
|
||||
dav_get_collection = _load_registry_fn("infra", "dav_get_collection", "dav_get_collection")
|
||||
dav_list_calendars = _load_registry_fn("infra", "dav_list_calendars", "dav_list_calendars")
|
||||
dav_list_addressbooks = _load_registry_fn(
|
||||
"infra", "dav_list_addressbooks", "dav_list_addressbooks"
|
||||
)
|
||||
dav_collection_ctag = _load_registry_fn("infra", "dav_collection_ctag", "dav_collection_ctag")
|
||||
|
||||
# Grupo dav: escritura (push DB -> Xandikos) y creación de colecciones.
|
||||
carddav_put_vcard = _load_registry_fn("infra", "carddav_put_vcard", "carddav_put_vcard")
|
||||
caldav_put_event = _load_registry_fn("infra", "caldav_put_event", "caldav_put_event")
|
||||
dav_delete_resource = _load_registry_fn(
|
||||
"infra", "dav_delete_resource", "dav_delete_resource"
|
||||
)
|
||||
dav_make_addressbook = _load_registry_fn(
|
||||
"infra", "dav_make_addressbook", "dav_make_addressbook"
|
||||
)
|
||||
dav_make_calendar = _load_registry_fn("infra", "dav_make_calendar", "dav_make_calendar")
|
||||
|
||||
# Secretos via pass (credencial Xandikos, nunca hardcodeada).
|
||||
pass_get_secret = _load_registry_fn("infra", "pass_get_secret", "pass_get_secret")
|
||||
|
||||
@@ -98,6 +112,12 @@ pass_get_secret = _load_registry_fn("infra", "pass_get_secret", "pass_get_secret
|
||||
duckdb_query_readonly = _load_registry_fn(
|
||||
"infra", "duckdb_query_readonly", "duckdb_query_readonly"
|
||||
)
|
||||
# Escritura DuckDB del grupo: DDL/DML directo + UPSERT con ownership selectivo.
|
||||
duckdb_execute = _load_registry_fn("infra", "duckdb_execute", "duckdb_execute")
|
||||
duckdb_upsert = _load_registry_fn("infra", "duckdb_upsert", "duckdb_upsert")
|
||||
|
||||
# Composición del vCard multi-valor (DB -> Xandikos), puro.
|
||||
build_vcard = _load_registry_fn("core", "build_vcard", "build_vcard")
|
||||
|
||||
# Render de tablas Markdown + bloques sentinel idempotentes para las notas.
|
||||
render_markdown_table = _load_registry_fn(
|
||||
@@ -116,8 +136,17 @@ __all__ = [
|
||||
"dav_collection_ctag",
|
||||
"dav_get_collection",
|
||||
"dav_list_calendars",
|
||||
"dav_list_addressbooks",
|
||||
"carddav_put_vcard",
|
||||
"caldav_put_event",
|
||||
"dav_delete_resource",
|
||||
"dav_make_addressbook",
|
||||
"dav_make_calendar",
|
||||
"pass_get_secret",
|
||||
"duckdb_query_readonly",
|
||||
"duckdb_execute",
|
||||
"duckdb_upsert",
|
||||
"build_vcard",
|
||||
"render_markdown_table",
|
||||
"upsert_sentinel_block",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,678 @@
|
||||
"""Escritura estructurada del service osint_db (DB como fuente de verdad).
|
||||
|
||||
Estos helpers implementan los endpoints de escritura de /api/person,
|
||||
/api/contact, /api/event, /api/addressbook, /api/calendar y /api/push/dav. El
|
||||
patrón común:
|
||||
|
||||
1. Se escribe en la DB DuckDB bajo el lock single-writer del service.
|
||||
2. El push a Xandikos (CardDAV/CalDAV) y el render DB->nota se hacen DESPUÉS
|
||||
de cerrar la transacción, para no bloquear la DB con la latencia de red.
|
||||
|
||||
persons es dueña de sus campos estructurados (multi-valor): los singulares
|
||||
telefono/email/direccion se rellenan con el primer elemento de cada lista al
|
||||
materializar la ficha, y la nota Markdown se reescribe SIN tocar su prosa
|
||||
(update_obsidian_note con set_frontmatter hace merge del frontmatter y conserva
|
||||
el body).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .config import Config
|
||||
from .registry_bridge import (
|
||||
build_vcard,
|
||||
caldav_put_event,
|
||||
carddav_put_vcard,
|
||||
create_obsidian_note,
|
||||
dav_delete_resource,
|
||||
dav_make_addressbook,
|
||||
dav_make_calendar,
|
||||
duckdb_execute,
|
||||
duckdb_query_readonly,
|
||||
duckdb_upsert,
|
||||
pass_get_secret,
|
||||
update_obsidian_note,
|
||||
)
|
||||
|
||||
# Columnas de persons gobernadas por la API estructurada (sin slug, que es la
|
||||
# clave, ni note_path/extra_fm que gestiona el ingest del vault).
|
||||
_PERSON_API_COLS = (
|
||||
"nombre",
|
||||
"aliases",
|
||||
"sexo",
|
||||
"fecha_nacimiento",
|
||||
"dni",
|
||||
"pais",
|
||||
"contexto",
|
||||
"telefonos",
|
||||
"emails",
|
||||
"direcciones",
|
||||
"tags",
|
||||
)
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.now(tz=timezone.utc)
|
||||
|
||||
|
||||
def _as_list(value) -> list:
|
||||
"""Normaliza a lista de strings no vacíos (string suelto -> [string])."""
|
||||
if value is None:
|
||||
return []
|
||||
seq = value if isinstance(value, list) else [value]
|
||||
out = []
|
||||
for v in seq:
|
||||
s = str(v).strip()
|
||||
if s:
|
||||
out.append(s)
|
||||
return out
|
||||
|
||||
|
||||
def _json(value) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
def _read_person(db_path: str, slug: str) -> dict | None:
|
||||
"""Lee una ficha de persons como dict (o None si no existe)."""
|
||||
res = duckdb_query_readonly(
|
||||
db_path,
|
||||
"SELECT slug, note_path, nombre, aliases, sexo, fecha_nacimiento, dni, "
|
||||
"telefono, email, direccion, pais, contexto, fuente, dav_uid, tags, "
|
||||
"telefonos, emails, direcciones, extra_fm FROM persons WHERE slug = ?",
|
||||
[slug],
|
||||
1,
|
||||
)
|
||||
if res.get("status") != "ok" or not res.get("rows"):
|
||||
return None
|
||||
return res["rows"][0]
|
||||
|
||||
|
||||
def _decode_json_field(value) -> list:
|
||||
"""Decodifica un campo JSON de la DB a lista (tolera None/str/list)."""
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except (TypeError, ValueError):
|
||||
return []
|
||||
return parsed if isinstance(parsed, list) else [parsed]
|
||||
|
||||
|
||||
def _decode_extra_fm(value) -> dict:
|
||||
"""Decodifica extra_fm (objeto JSON de la DB) a dict (o {} si no aplica)."""
|
||||
if value is None:
|
||||
return {}
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except (TypeError, ValueError):
|
||||
return {}
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# persons
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def upsert_person(cfg: Config, slug: str, fields: dict, *, render: bool = True) -> dict:
|
||||
"""Crea/actualiza una persona multi-valor y materializa su nota.
|
||||
|
||||
Escribe los campos estructurados en la DB (la DB es dueña), rellena los
|
||||
singulares con el primer elemento de cada lista, y tras cerrar la escritura
|
||||
materializa la ficha DB->nota (frontmatter OWNED + merge de extra_fm) sin
|
||||
tocar la prosa de la nota.
|
||||
"""
|
||||
slug = (slug or "").strip()
|
||||
if not slug:
|
||||
return {"status": "error", "error": "slug vacío"}
|
||||
|
||||
telefonos = _as_list(fields.get("telefonos"))
|
||||
emails = _as_list(fields.get("emails"))
|
||||
direcciones = _as_list(fields.get("direcciones"))
|
||||
aliases = _as_list(fields.get("aliases"))
|
||||
tags = _as_list(fields.get("tags"))
|
||||
|
||||
existing = _read_person(cfg.db_path, slug)
|
||||
note_path = (existing.get("note_path") if existing else None) or os.path.join(
|
||||
"personas", f"{slug}.md"
|
||||
)
|
||||
|
||||
row = {
|
||||
"slug": slug,
|
||||
"note_path": note_path,
|
||||
"nombre": (fields.get("nombre") or slug),
|
||||
"aliases": _json(aliases),
|
||||
"sexo": fields.get("sexo"),
|
||||
"fecha_nacimiento": fields.get("fecha_nacimiento"),
|
||||
"dni": fields.get("dni"),
|
||||
"telefono": telefonos[0] if telefonos else None,
|
||||
"email": emails[0] if emails else None,
|
||||
"direccion": direcciones[0] if direcciones else None,
|
||||
"pais": fields.get("pais"),
|
||||
"contexto": fields.get("contexto"),
|
||||
"tags": _json(tags),
|
||||
"telefonos": _json(telefonos),
|
||||
"emails": _json(emails),
|
||||
"direcciones": _json(direcciones),
|
||||
"updated_at": _now(),
|
||||
}
|
||||
# update_cols = todo lo que la API gobierna (no pisa fuente/dav_uid/extra_fm
|
||||
# que pertenecen al ingest del vault).
|
||||
update_cols = [c for c in row if c not in ("slug",)]
|
||||
res = duckdb_upsert(
|
||||
cfg.db_path, "persons", [row], key_cols=["slug"], update_cols=update_cols
|
||||
)
|
||||
if res.get("status") != "ok":
|
||||
return {"status": "error", "error": res.get("error")}
|
||||
|
||||
materialized = False
|
||||
if render:
|
||||
r = render_person(cfg, slug)
|
||||
materialized = r.get("status") == "ok"
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"slug": slug,
|
||||
"inserted": res.get("inserted", 0),
|
||||
"updated": res.get("updated", 0),
|
||||
"note_path": note_path,
|
||||
"materialized": materialized,
|
||||
}
|
||||
|
||||
|
||||
def delete_person(cfg: Config, slug: str) -> dict:
|
||||
"""Borra una ficha de persons de la DB (no borra la nota del vault)."""
|
||||
slug = (slug or "").strip()
|
||||
if not slug:
|
||||
return {"status": "error", "error": "slug vacío"}
|
||||
res = duckdb_execute(cfg.db_path, "DELETE FROM persons WHERE slug = ?", [slug])
|
||||
if res.get("status") != "ok":
|
||||
return {"status": "error", "error": res.get("error")}
|
||||
return {"status": "ok", "slug": slug, "deleted": res.get("rowcount", 0)}
|
||||
|
||||
|
||||
def render_person(cfg: Config, slug: str) -> dict:
|
||||
"""Materializa una ficha DB->nota: frontmatter OWNED + extra_fm, sin prosa.
|
||||
|
||||
Lee la fila de persons, compone el frontmatter (campos OWNED como listas +
|
||||
merge de extra_fm) y lo escribe en la nota con update_obsidian_note (que
|
||||
conserva el body). Si la nota no existe la crea con un body mínimo.
|
||||
"""
|
||||
slug = (slug or "").strip()
|
||||
person = _read_person(cfg.db_path, slug)
|
||||
if person is None:
|
||||
return {"status": "error", "error": f"persona desconocida: {slug!r}"}
|
||||
|
||||
rel = person.get("note_path") or os.path.join("personas", f"{slug}.md")
|
||||
if not rel.endswith(".md"):
|
||||
rel = rel + ".md"
|
||||
abs_path = os.path.abspath(os.path.join(cfg.vault_dir, rel))
|
||||
vault_real = os.path.realpath(cfg.vault_dir)
|
||||
if not os.path.realpath(abs_path).startswith(vault_real + os.sep):
|
||||
return {"status": "error", "error": f"note_path fuera del vault: {rel!r}"}
|
||||
|
||||
telefonos = _decode_json_field(person.get("telefonos"))
|
||||
emails = _decode_json_field(person.get("emails"))
|
||||
direcciones = _decode_json_field(person.get("direcciones"))
|
||||
aliases = _decode_json_field(person.get("aliases"))
|
||||
tags = _decode_json_field(person.get("tags"))
|
||||
|
||||
frontmatter = {
|
||||
"tipo": "persona",
|
||||
"slug": slug,
|
||||
"nombre": person.get("nombre") or slug,
|
||||
"aliases": aliases,
|
||||
"sexo": person.get("sexo"),
|
||||
"fecha_nacimiento": person.get("fecha_nacimiento"),
|
||||
"dni": person.get("dni"),
|
||||
"telefonos": telefonos,
|
||||
"emails": emails,
|
||||
"direcciones": direcciones,
|
||||
# singulares por compatibilidad con consumidores que aún los leen.
|
||||
"telefono": telefonos[0] if telefonos else None,
|
||||
"email": emails[0] if emails else None,
|
||||
"direccion": direcciones[0] if direcciones else None,
|
||||
"pais": person.get("pais"),
|
||||
"contexto": person.get("contexto"),
|
||||
"fuente": person.get("fuente"),
|
||||
"tags": tags,
|
||||
}
|
||||
# Merge del frontmatter no-owned capturado del vault (no pisa las claves
|
||||
# OWNED de arriba). extra_fm es un objeto JSON (dict) en la DB.
|
||||
extra = _decode_extra_fm(person.get("extra_fm"))
|
||||
if extra:
|
||||
merged = dict(extra)
|
||||
merged.update(frontmatter)
|
||||
frontmatter = merged
|
||||
|
||||
try:
|
||||
if os.path.exists(abs_path):
|
||||
# set_frontmatter hace merge y NO toca el body (prosa preservada).
|
||||
update_obsidian_note(abs_path, set_frontmatter=frontmatter)
|
||||
else:
|
||||
create_obsidian_note(
|
||||
cfg.vault_dir,
|
||||
rel,
|
||||
body="## Notas\n",
|
||||
frontmatter=frontmatter,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
return {"status": "ok", "slug": slug, "note_path": rel}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# contacts (DB -> Xandikos)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _resolve_password(cfg: Config) -> tuple:
|
||||
"""Resuelve la password de Xandikos desde pass. Devuelve (pwd|None, error|None)."""
|
||||
secret = pass_get_secret(cfg.pass_secret)
|
||||
if secret.get("status") != "ok":
|
||||
return None, (
|
||||
f"pass no devolvió el secreto {cfg.pass_secret!r}: {secret.get('error')}"
|
||||
)
|
||||
return secret["value"], None
|
||||
|
||||
|
||||
def _default_collection(cfg: Config) -> str:
|
||||
return cfg.dav_contacts_collection
|
||||
|
||||
|
||||
def upsert_contact(cfg: Config, uid: str, fields: dict) -> dict:
|
||||
"""Crea/actualiza un contacto en la DB y lo empuja a Xandikos (PUT vCard).
|
||||
|
||||
La escritura DB se hace bajo el lock; el push DAV ocurre después.
|
||||
"""
|
||||
uid = (uid or "").strip()
|
||||
if not uid:
|
||||
return {"status": "error", "error": "uid vacío"}
|
||||
|
||||
tels = _as_list(fields.get("tels") or fields.get("telefonos"))
|
||||
emails = _as_list(fields.get("emails") or fields.get("correos"))
|
||||
fn = fields.get("fn") or fields.get("nombre")
|
||||
collection = fields.get("collection") or _default_collection(cfg)
|
||||
|
||||
vcard = build_vcard(
|
||||
{
|
||||
"uid": uid,
|
||||
"fn": fn,
|
||||
"tels": tels,
|
||||
"emails": emails,
|
||||
"adrs": _as_list(fields.get("direcciones") or fields.get("adrs")),
|
||||
}
|
||||
)
|
||||
|
||||
row = {
|
||||
"uid": uid,
|
||||
"collection": collection,
|
||||
"etag": None,
|
||||
"fn": fn,
|
||||
"tels": _json(tels),
|
||||
"emails": _json(emails),
|
||||
"raw": vcard,
|
||||
"note_path": None,
|
||||
"updated_at": _now(),
|
||||
}
|
||||
res = duckdb_upsert(
|
||||
cfg.db_path,
|
||||
"contacts",
|
||||
[row],
|
||||
key_cols=["uid"],
|
||||
update_cols=["collection", "fn", "tels", "emails", "raw", "updated_at"],
|
||||
)
|
||||
if res.get("status") != "ok":
|
||||
return {"status": "error", "error": res.get("error")}
|
||||
|
||||
# Push DB -> Xandikos fuera de cualquier transacción de la DB.
|
||||
pwd, err = _resolve_password(cfg)
|
||||
pushed = None
|
||||
if err is None:
|
||||
push = carddav_put_vcard(
|
||||
cfg.dav_base, cfg.dav_user, pwd, collection, uid, vcard
|
||||
)
|
||||
pushed = push.get("status") == "ok"
|
||||
return {
|
||||
"status": "ok",
|
||||
"uid": uid,
|
||||
"inserted": res.get("inserted", 0),
|
||||
"updated": res.get("updated", 0),
|
||||
"pushed": pushed,
|
||||
"push_error": err,
|
||||
}
|
||||
|
||||
|
||||
def delete_contact(cfg: Config, uid: str) -> dict:
|
||||
"""Borra un contacto de la DB y del servidor Xandikos (DELETE del recurso)."""
|
||||
uid = (uid or "").strip()
|
||||
if not uid:
|
||||
return {"status": "error", "error": "uid vacío"}
|
||||
|
||||
person = duckdb_query_readonly(
|
||||
cfg.db_path, "SELECT collection FROM contacts WHERE uid = ?", [uid], 1
|
||||
)
|
||||
collection = _default_collection(cfg)
|
||||
if person.get("status") == "ok" and person.get("rows"):
|
||||
collection = person["rows"][0].get("collection") or collection
|
||||
|
||||
res = duckdb_execute(cfg.db_path, "DELETE FROM contacts WHERE uid = ?", [uid])
|
||||
if res.get("status") != "ok":
|
||||
return {"status": "error", "error": res.get("error")}
|
||||
|
||||
# Borrado remoto del recurso .vcf (DESTRUCTIVO, explícito por el endpoint).
|
||||
pwd, err = _resolve_password(cfg)
|
||||
deleted_remote = None
|
||||
if err is None:
|
||||
resource = collection.rstrip("/") + "/" + _safe_resource(uid) + ".vcf"
|
||||
rm = dav_delete_resource(cfg.dav_base, cfg.dav_user, pwd, resource)
|
||||
deleted_remote = rm.get("status") == "ok"
|
||||
return {
|
||||
"status": "ok",
|
||||
"uid": uid,
|
||||
"deleted": res.get("rowcount", 0),
|
||||
"deleted_remote": deleted_remote,
|
||||
"push_error": err,
|
||||
}
|
||||
|
||||
|
||||
def _safe_resource(uid: str) -> str:
|
||||
"""Sanea un UID al mismo nombre de recurso que carddav_put_vcard/caldav_put_event."""
|
||||
import re
|
||||
|
||||
return re.sub(r"[^A-Za-z0-9_.-]", "_", uid)[:120]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# events (DB -> Xandikos)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_vcalendar(uid: str, fields: dict) -> str:
|
||||
"""Compone un VCALENDAR mínimo con un VEVENT desde los campos del evento."""
|
||||
dtstart = (fields.get("dtstart") or "").replace("-", "").replace(":", "")
|
||||
dtend = (fields.get("dtend") or "").replace("-", "").replace(":", "")
|
||||
lines = [
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"PRODID:-//osint_db//events//EN",
|
||||
"BEGIN:VEVENT",
|
||||
f"UID:{uid}",
|
||||
f"SUMMARY:{fields.get('summary') or ''}",
|
||||
]
|
||||
if dtstart:
|
||||
lines.append(f"DTSTART:{dtstart}")
|
||||
if dtend:
|
||||
lines.append(f"DTEND:{dtend}")
|
||||
if fields.get("location"):
|
||||
lines.append(f"LOCATION:{fields['location']}")
|
||||
if fields.get("rrule"):
|
||||
lines.append(f"RRULE:{fields['rrule']}")
|
||||
lines += ["END:VEVENT", "END:VCALENDAR"]
|
||||
return "\r\n".join(lines) + "\r\n"
|
||||
|
||||
|
||||
def upsert_event(cfg: Config, uid: str, fields: dict) -> dict:
|
||||
"""Crea/actualiza un evento en la DB y lo empuja a Xandikos (PUT VCALENDAR)."""
|
||||
uid = (uid or "").strip()
|
||||
if not uid:
|
||||
return {"status": "error", "error": "uid vacío"}
|
||||
|
||||
calendar = fields.get("calendar") or "default"
|
||||
raw = _build_vcalendar(uid, fields)
|
||||
row = {
|
||||
"uid": uid,
|
||||
"calendar": calendar,
|
||||
"etag": None,
|
||||
"dtstart": fields.get("dtstart"),
|
||||
"dtend": fields.get("dtend"),
|
||||
"all_day": bool(fields.get("all_day")),
|
||||
"summary": fields.get("summary"),
|
||||
"location": fields.get("location"),
|
||||
"rrule": fields.get("rrule"),
|
||||
"raw": raw,
|
||||
"updated_at": _now(),
|
||||
}
|
||||
res = duckdb_upsert(
|
||||
cfg.db_path,
|
||||
"events",
|
||||
[row],
|
||||
key_cols=["uid"],
|
||||
update_cols=[
|
||||
"calendar",
|
||||
"dtstart",
|
||||
"dtend",
|
||||
"all_day",
|
||||
"summary",
|
||||
"location",
|
||||
"rrule",
|
||||
"raw",
|
||||
"updated_at",
|
||||
],
|
||||
)
|
||||
if res.get("status") != "ok":
|
||||
return {"status": "error", "error": res.get("error")}
|
||||
|
||||
# El calendario CalDAV destino se resuelve por su path; usamos el calendar
|
||||
# home + slug del calendario. Push fuera de transacción.
|
||||
pwd, err = _resolve_password(cfg)
|
||||
pushed = None
|
||||
if err is None:
|
||||
collection = cfg.dav_calendar_home.rstrip("/") + "/" + calendar + "/"
|
||||
push = caldav_put_event(
|
||||
cfg.dav_base, cfg.dav_user, pwd, collection, uid, raw
|
||||
)
|
||||
pushed = push.get("status") == "ok"
|
||||
return {
|
||||
"status": "ok",
|
||||
"uid": uid,
|
||||
"inserted": res.get("inserted", 0),
|
||||
"updated": res.get("updated", 0),
|
||||
"pushed": pushed,
|
||||
"push_error": err,
|
||||
}
|
||||
|
||||
|
||||
def delete_event(cfg: Config, uid: str) -> dict:
|
||||
"""Borra un evento de la DB y del servidor Xandikos."""
|
||||
uid = (uid or "").strip()
|
||||
if not uid:
|
||||
return {"status": "error", "error": "uid vacío"}
|
||||
|
||||
row = duckdb_query_readonly(
|
||||
cfg.db_path, "SELECT calendar FROM events WHERE uid = ?", [uid], 1
|
||||
)
|
||||
calendar = "default"
|
||||
if row.get("status") == "ok" and row.get("rows"):
|
||||
calendar = row["rows"][0].get("calendar") or calendar
|
||||
|
||||
res = duckdb_execute(cfg.db_path, "DELETE FROM events WHERE uid = ?", [uid])
|
||||
if res.get("status") != "ok":
|
||||
return {"status": "error", "error": res.get("error")}
|
||||
|
||||
pwd, err = _resolve_password(cfg)
|
||||
deleted_remote = None
|
||||
if err is None:
|
||||
collection = cfg.dav_calendar_home.rstrip("/") + "/" + calendar + "/"
|
||||
resource = collection + _safe_resource(uid) + ".ics"
|
||||
rm = dav_delete_resource(cfg.dav_base, cfg.dav_user, pwd, resource)
|
||||
deleted_remote = rm.get("status") == "ok"
|
||||
return {
|
||||
"status": "ok",
|
||||
"uid": uid,
|
||||
"deleted": res.get("rowcount", 0),
|
||||
"deleted_remote": deleted_remote,
|
||||
"push_error": err,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# addressbooks / calendars
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_addressbook(cfg: Config, fields: dict) -> dict:
|
||||
"""Crea una libreta CardDAV en Xandikos y la registra en la tabla addressbooks."""
|
||||
slug = (fields.get("slug") or "").strip()
|
||||
if not slug:
|
||||
return {"status": "error", "error": "slug vacío"}
|
||||
display_name = fields.get("display_name") or ""
|
||||
description = fields.get("description") or ""
|
||||
color = fields.get("color") or ""
|
||||
|
||||
pwd, err = _resolve_password(cfg)
|
||||
if err is not None:
|
||||
return {"status": "error", "error": err}
|
||||
|
||||
# contacts_home = el directorio padre de la colección por defecto
|
||||
# (/enmanuel/contacts/addressbook/ -> /enmanuel/contacts/).
|
||||
contacts_home = "/" + "/".join(
|
||||
cfg.dav_contacts_collection.strip("/").split("/")[:-1]
|
||||
)
|
||||
if not contacts_home.endswith("/"):
|
||||
contacts_home += "/"
|
||||
|
||||
mk = dav_make_addressbook(
|
||||
cfg.dav_base,
|
||||
cfg.dav_user,
|
||||
pwd,
|
||||
contacts_home,
|
||||
slug,
|
||||
display_name,
|
||||
description,
|
||||
)
|
||||
if mk.get("status") != "ok":
|
||||
return {"status": "error", "error": mk.get("error"), "http_status": mk.get("http_status")}
|
||||
|
||||
collection_path = mk.get("href") or (contacts_home + slug + "/")
|
||||
row = {
|
||||
"slug": slug,
|
||||
"display_name": display_name or slug,
|
||||
"collection_path": collection_path,
|
||||
"description": description or None,
|
||||
"color": color or None,
|
||||
"created_at": _now(),
|
||||
}
|
||||
res = duckdb_upsert(
|
||||
cfg.db_path,
|
||||
"addressbooks",
|
||||
[row],
|
||||
key_cols=["slug"],
|
||||
update_cols=["display_name", "collection_path", "description", "color"],
|
||||
)
|
||||
if res.get("status") != "ok":
|
||||
return {"status": "error", "error": res.get("error")}
|
||||
return {
|
||||
"status": "ok",
|
||||
"slug": slug,
|
||||
"collection_path": collection_path,
|
||||
"existed": mk.get("existed", False),
|
||||
}
|
||||
|
||||
|
||||
def make_calendar(cfg: Config, fields: dict) -> dict:
|
||||
"""Crea un calendario CalDAV en Xandikos (paridad con make_addressbook)."""
|
||||
slug = (fields.get("slug") or "").strip()
|
||||
if not slug:
|
||||
return {"status": "error", "error": "slug vacío"}
|
||||
display_name = fields.get("display_name") or ""
|
||||
color = fields.get("color") or ""
|
||||
|
||||
pwd, err = _resolve_password(cfg)
|
||||
if err is not None:
|
||||
return {"status": "error", "error": err}
|
||||
|
||||
mk = dav_make_calendar(
|
||||
cfg.dav_base,
|
||||
cfg.dav_user,
|
||||
pwd,
|
||||
cfg.dav_calendar_home,
|
||||
slug,
|
||||
display_name,
|
||||
color,
|
||||
)
|
||||
if mk.get("status") != "ok":
|
||||
return {"status": "error", "error": mk.get("error"), "http_status": mk.get("http_status")}
|
||||
return {
|
||||
"status": "ok",
|
||||
"slug": slug,
|
||||
"href": mk.get("href"),
|
||||
"existed": mk.get("existed", False),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# push masivo DB -> Xandikos
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def push_all_dav(cfg: Config) -> dict:
|
||||
"""Reconcilia en bloque: empuja todos los contacts y events de la DB a Xandikos.
|
||||
|
||||
Útil tras una migración para volcar lo que vive solo en la DB. Devuelve los
|
||||
conteos de éxito/fallo por tipo. NO borra nada en remoto (solo PUT).
|
||||
"""
|
||||
pwd, err = _resolve_password(cfg)
|
||||
if err is not None:
|
||||
return {"status": "error", "error": err}
|
||||
|
||||
contacts = duckdb_query_readonly(
|
||||
cfg.db_path,
|
||||
"SELECT uid, collection, fn, tels, emails FROM contacts",
|
||||
[],
|
||||
1000000,
|
||||
)
|
||||
c_ok = c_fail = 0
|
||||
if contacts.get("status") == "ok":
|
||||
for row in contacts["rows"]:
|
||||
uid = row["uid"]
|
||||
collection = row.get("collection") or _default_collection(cfg)
|
||||
vcard = build_vcard(
|
||||
{
|
||||
"uid": uid,
|
||||
"fn": row.get("fn"),
|
||||
"tels": _decode_json_field(row.get("tels")),
|
||||
"emails": _decode_json_field(row.get("emails")),
|
||||
}
|
||||
)
|
||||
push = carddav_put_vcard(
|
||||
cfg.dav_base, cfg.dav_user, pwd, collection, uid, vcard
|
||||
)
|
||||
if push.get("status") == "ok":
|
||||
c_ok += 1
|
||||
else:
|
||||
c_fail += 1
|
||||
|
||||
events = duckdb_query_readonly(
|
||||
cfg.db_path, "SELECT uid, calendar, raw FROM events", [], 1000000
|
||||
)
|
||||
e_ok = e_fail = 0
|
||||
if events.get("status") == "ok":
|
||||
for row in events["rows"]:
|
||||
uid = row["uid"]
|
||||
calendar = row.get("calendar") or "default"
|
||||
collection = cfg.dav_calendar_home.rstrip("/") + "/" + calendar + "/"
|
||||
raw = row.get("raw") or _build_vcalendar(uid, {})
|
||||
push = caldav_put_event(
|
||||
cfg.dav_base, cfg.dav_user, pwd, collection, uid, raw
|
||||
)
|
||||
if push.get("status") == "ok":
|
||||
e_ok += 1
|
||||
else:
|
||||
e_fail += 1
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"contacts_pushed": c_ok,
|
||||
"contacts_failed": c_fail,
|
||||
"events_pushed": e_ok,
|
||||
"events_failed": e_fail,
|
||||
}
|
||||
@@ -8,6 +8,7 @@ ingest del vault, que re-enlaza.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
@@ -354,3 +355,189 @@ def test_render_note_valida_inputs(client):
|
||||
).json()
|
||||
assert r["status"] == "error"
|
||||
assert "fuera del vault" in r["error"]
|
||||
|
||||
|
||||
# --- F1: migraciones multi-valor + addressbooks ----------------------------
|
||||
|
||||
|
||||
def test_migracion_multivalue_y_addressbooks(client, cfg):
|
||||
"""002 añade columnas multi-valor a persons; 003 crea+seed addressbooks."""
|
||||
r = client.get("/api/tables").json()
|
||||
by_name = {(t["schema"], t["name"]): t for t in r["tables"]}
|
||||
persons_cols = {c["name"] for c in by_name[("main", "persons")]["columns"]}
|
||||
assert {"telefonos", "emails", "direcciones", "extra_fm"} <= persons_cols
|
||||
# Las singulares siguen existiendo (compat).
|
||||
assert {"telefono", "email", "direccion"} <= persons_cols
|
||||
# La libreta por defecto quedó sembrada.
|
||||
assert ("main", "addressbooks") in by_name
|
||||
ab = client.post(
|
||||
"/api/query", json={"sql": "SELECT slug, collection_path FROM addressbooks"}
|
||||
).json()
|
||||
rows = {row["slug"]: row["collection_path"] for row in ab["rows"]}
|
||||
assert rows["addressbook"] == "/enmanuel/contacts/addressbook/"
|
||||
|
||||
|
||||
# --- F2: ingest selectivo (la DB es dueña de los campos OWNED) --------------
|
||||
|
||||
|
||||
def test_ingest_vault_no_pisa_campo_owned(client, cfg):
|
||||
"""Un valor escrito por la API persiste tras re-ingestar el vault.
|
||||
|
||||
Simula la escritura de un teléfono por la futura API con un UPDATE directo
|
||||
a la DB; el re-ingest del vault NO debe pisarlo con el frontmatter viejo de
|
||||
la nota (que tenía '+34 600 111 222').
|
||||
"""
|
||||
client.post("/api/ingest/vault")
|
||||
# La API escribe un teléfono nuevo (multi-valor) en la DB.
|
||||
with write_conn(cfg.db_path) as conn:
|
||||
conn.execute(
|
||||
"UPDATE persons SET telefonos = ?, telefono = ? WHERE slug = ?",
|
||||
['["+34 999 888 777"]', "+34 999 888 777", "ana-garcia-perez"],
|
||||
)
|
||||
# Re-ingest del vault: la ficha de Ana YA existe -> solo refresca
|
||||
# note_path + extra_fm, NO los campos OWNED.
|
||||
r = client.post("/api/ingest/vault").json()
|
||||
assert r["status"] == "ok"
|
||||
assert r["persons"] == 2
|
||||
assert r["persons_updated"] == 2 # ambas fichas ya existían
|
||||
assert r["persons_inserted"] == 0
|
||||
|
||||
q = client.post(
|
||||
"/api/query",
|
||||
json={
|
||||
"sql": "SELECT telefono, telefonos FROM persons WHERE slug = 'ana-garcia-perez'"
|
||||
},
|
||||
).json()
|
||||
assert q["rows"][0]["telefono"] == "+34 999 888 777" # el valor de la API, NO el del FM
|
||||
assert "+34 999 888 777" in q["rows"][0]["telefonos"]
|
||||
|
||||
|
||||
def test_ingest_vault_bootstrapea_ficha_nueva(client, cfg):
|
||||
"""Una ficha cuyo slug no está en la DB se inserta completa desde el FM."""
|
||||
# Primer ingest: solo las dos fichas del fixture.
|
||||
client.post("/api/ingest/vault")
|
||||
# Añadimos una ficha nueva al vault con teléfono singular en el FM.
|
||||
nueva = (
|
||||
"---\n"
|
||||
"tipo: persona\n"
|
||||
'nombre: "Marta Ruiz"\n'
|
||||
"slug: marta-ruiz\n"
|
||||
'telefono: "+34 611 000 111"\n'
|
||||
"contexto: trabajo\n"
|
||||
"campo_libre: valor_raro\n"
|
||||
"tags: [persona]\n"
|
||||
"---\n\n## Notas\n"
|
||||
)
|
||||
with open(
|
||||
os.path.join(cfg.vault_dir, "personas", "marta-ruiz.md"), "w", encoding="utf-8"
|
||||
) as fh:
|
||||
fh.write(nueva)
|
||||
|
||||
r = client.post("/api/ingest/vault").json()
|
||||
assert r["status"] == "ok"
|
||||
assert r["persons"] == 3
|
||||
assert r["persons_inserted"] == 1 # marta-ruiz nueva
|
||||
|
||||
q = client.post(
|
||||
"/api/query",
|
||||
json={
|
||||
"sql": "SELECT telefono, telefonos, contexto, extra_fm "
|
||||
"FROM persons WHERE slug = 'marta-ruiz'"
|
||||
},
|
||||
).json()
|
||||
row = q["rows"][0]
|
||||
assert row["telefono"] == "+34 611 000 111" # singular derivado de la lista
|
||||
assert "+34 611 000 111" in row["telefonos"] # lista poblada desde el singular
|
||||
assert row["contexto"] == "trabajo"
|
||||
# extra_fm captura el frontmatter no-owned (campo_libre), no los OWNED.
|
||||
extra = json.loads(row["extra_fm"])
|
||||
assert extra.get("campo_libre") == "valor_raro"
|
||||
assert "telefono" not in extra and "contexto" not in extra
|
||||
|
||||
|
||||
# --- F3: endpoints de escritura estructurada (persons, sin red) ------------
|
||||
|
||||
|
||||
def test_api_person_crud_y_materializa(client, cfg):
|
||||
"""POST /api/person con 2 teléfonos -> fila en DB + nota .md materializada."""
|
||||
client.post("/api/ingest/vault")
|
||||
body = {
|
||||
"slug": "nuevo-contacto",
|
||||
"nombre": "Nuevo Contacto",
|
||||
"telefonos": ["+34 600 000 001", "+34 600 000 002"],
|
||||
"emails": ["nc@example.com"],
|
||||
"contexto": "trabajo",
|
||||
"tags": ["persona"],
|
||||
}
|
||||
r = client.post("/api/person", json=body).json()
|
||||
assert r["status"] == "ok"
|
||||
assert r["inserted"] == 1
|
||||
assert r["materialized"] is True
|
||||
|
||||
# Fila en DB: telefonos como lista, singular = primer elemento.
|
||||
q = client.post(
|
||||
"/api/query",
|
||||
json={
|
||||
"sql": "SELECT telefono, telefonos, emails, email FROM persons "
|
||||
"WHERE slug = 'nuevo-contacto'"
|
||||
},
|
||||
).json()
|
||||
row = q["rows"][0]
|
||||
assert json.loads(row["telefonos"]) == ["+34 600 000 001", "+34 600 000 002"]
|
||||
assert row["telefono"] == "+34 600 000 001"
|
||||
assert row["email"] == "nc@example.com"
|
||||
|
||||
# Nota .md materializada con la lista telefonos.
|
||||
note_file = os.path.join(cfg.vault_dir, "personas", "nuevo-contacto.md")
|
||||
assert os.path.exists(note_file)
|
||||
content = open(note_file, encoding="utf-8").read()
|
||||
assert "telefonos:" in content
|
||||
assert "+34 600 000 001" in content
|
||||
assert "+34 600 000 002" in content
|
||||
|
||||
# PUT actualiza (un solo teléfono ahora).
|
||||
r = client.put(
|
||||
"/api/person/nuevo-contacto",
|
||||
json={"slug": "nuevo-contacto", "nombre": "NC", "telefonos": ["+34 600 000 009"]},
|
||||
).json()
|
||||
assert r["status"] == "ok"
|
||||
assert r["updated"] == 1
|
||||
q = client.post(
|
||||
"/api/query",
|
||||
json={"sql": "SELECT telefono FROM persons WHERE slug = 'nuevo-contacto'"},
|
||||
).json()
|
||||
assert q["rows"][0]["telefono"] == "+34 600 000 009"
|
||||
|
||||
# DELETE quita la fila de la DB.
|
||||
r = client.request("DELETE", "/api/person/nuevo-contacto").json()
|
||||
assert r["status"] == "ok"
|
||||
q = client.post(
|
||||
"/api/query",
|
||||
json={"sql": "SELECT COUNT(*) AS n FROM persons WHERE slug = 'nuevo-contacto'"},
|
||||
).json()
|
||||
assert q["rows"][0]["n"] == 0
|
||||
|
||||
|
||||
def test_api_person_render_preserva_prosa(client, cfg):
|
||||
"""POST /api/person/{slug}/render reescribe el frontmatter SIN tocar la prosa."""
|
||||
client.post("/api/ingest/vault")
|
||||
# Ana ya tiene cuerpo "## Notas\nFicha de prueba." en el fixture.
|
||||
note_file = os.path.join(cfg.vault_dir, "personas", "ana-garcia-perez.md")
|
||||
before = open(note_file, encoding="utf-8").read()
|
||||
assert "Ficha de prueba." in before
|
||||
|
||||
# Cambiamos el teléfono por la API y re-materializamos.
|
||||
client.put(
|
||||
"/api/person/ana-garcia-perez",
|
||||
json={
|
||||
"slug": "ana-garcia-perez",
|
||||
"nombre": "Ana García Pérez",
|
||||
"telefonos": ["+34 622 333 444"],
|
||||
},
|
||||
)
|
||||
r = client.post("/api/person/ana-garcia-perez/render").json()
|
||||
assert r["status"] == "ok"
|
||||
|
||||
after = open(note_file, encoding="utf-8").read()
|
||||
assert "Ficha de prueba." in after # prosa preservada
|
||||
assert "+34 622 333 444" in after # frontmatter actualizado
|
||||
|
||||
Reference in New Issue
Block a user