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:
2026-06-13 00:44:02 +02:00
parent 2716edd5a0
commit 63f37257cd
8 changed files with 1447 additions and 81 deletions
+26 -3
View File
@@ -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`,
+26
View File
@@ -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;
+23
View File
@@ -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
View File
@@ -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
View File
@@ -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
+29
View File
@@ -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",
]
+678
View File
@@ -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,
}
+187
View File
@@ -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