b620cc38c2
Privacidad (decisión del usuario: al móvil solo datos de agenda): - _compose_agenda_vcard compone el vCard desde el contacto (fn/tels/emails) + las direcciones (ADR) y aliases (NICKNAME) de la persona enlazada por note_path, SIN pasar nunca el dict osint a build_vcard → el vCard jamás lleva X-OSINT-* (DNI/sexo/fecha-nac quedan solo en DuckDB+Obsidian). Usado en upsert_contact y en el push masivo push_all_dav (que antes leía solo contacts y perdía las direcciones). Sync inverso DAVx5→DuckDB (last-write-wins por etag): - Tras cada push se captura el etag nuevo del recurso (dav_list_resources) y se persiste en contacts.etag, para no confundir el push propio con una edición del móvil. - POST /api/sync/dav-pull: pull incremental — compara etags, descarga SOLO los recursos cambiados/nuevos (dav_get_resource + parse_vcard + upsert), borra los que el móvil quitó, re-enlaza. Distinto del ingest_dav (DELETE+INSERT ciego): respeta la verdad de la DB salvo donde el etag prueba un cambio externo. 20 tests verdes (18 + 2 nuevos: vCard sin OSINT con direcciones; pull incremental por etag).
159 lines
6.1 KiB
Python
159 lines
6.1 KiB
Python
"""Puente con el fn_registry: localiza python/functions y expone las funciones.
|
|
|
|
Registry-first: esta app NO reimplementa el parseo de notas Obsidian, el
|
|
protocolo DAV, el acceso a pass ni la ejecución read-only de DuckDB. Importa
|
|
las funciones del registry y las reexporta para el resto de módulos del
|
|
service. Toda función importada aquí está declarada en uses_functions del
|
|
app.md.
|
|
|
|
La localización de python/functions evita paths hardcodeados de usuario:
|
|
prueba las variables de entorno FN_REGISTRY_FUNCTIONS y FN_REGISTRY_ROOT,
|
|
después sube por los directorios padre de este archivo hasta encontrar una
|
|
raíz que contenga python/functions/obsidian (la app vive en
|
|
<root>/projects/osint/apps/osint_db/server/), y por último cae al layout
|
|
estándar del PC.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
|
|
|
|
def _registry_functions_dir() -> str:
|
|
"""Devuelve el directorio python/functions del fn_registry."""
|
|
env_functions = os.environ.get("FN_REGISTRY_FUNCTIONS")
|
|
if env_functions and os.path.isdir(os.path.join(env_functions, "obsidian")):
|
|
return env_functions
|
|
|
|
candidates: list[str] = []
|
|
env_root = os.environ.get("FN_REGISTRY_ROOT")
|
|
if env_root:
|
|
candidates.append(env_root)
|
|
current = os.path.dirname(os.path.abspath(__file__))
|
|
while True:
|
|
candidates.append(current)
|
|
parent = os.path.dirname(current)
|
|
if parent == current:
|
|
break
|
|
current = parent
|
|
candidates.append(os.path.expanduser("~/fn_registry"))
|
|
for root in candidates:
|
|
functions_dir = os.path.join(root, "python", "functions")
|
|
if os.path.isdir(os.path.join(functions_dir, "obsidian")):
|
|
return functions_dir
|
|
raise RuntimeError(
|
|
"no se encontró python/functions/obsidian subiendo desde "
|
|
f"{os.path.abspath(__file__)}; define FN_REGISTRY_ROOT con la raíz "
|
|
"del fn_registry"
|
|
)
|
|
|
|
|
|
_FUNCTIONS_DIR = _registry_functions_dir()
|
|
if _FUNCTIONS_DIR not in sys.path:
|
|
sys.path.insert(0, _FUNCTIONS_DIR)
|
|
|
|
# Grupo obsidian: CRUD de notas del vault + slugs. El __init__ del paquete
|
|
# obsidian es ligero (sin dependencias pesadas), así que se importa directo.
|
|
from obsidian import ( # noqa: E402
|
|
create_obsidian_note,
|
|
list_obsidian_notes,
|
|
read_obsidian_note,
|
|
slugify_obsidian_name,
|
|
update_obsidian_note,
|
|
)
|
|
|
|
|
|
def _load_registry_fn(package: str, module_name: str, attr: str):
|
|
"""Carga una función del registry por path, sin ejecutar el __init__ del paquete.
|
|
|
|
Los __init__ de los paquetes infra y core importan TODAS sus funciones, y
|
|
algunas arrastran dependencias pesadas (Pillow, etc.) que este service no
|
|
necesita. Cargamos el archivo concreto con importlib (mismo patrón que
|
|
osint_web). Sigue siendo registry-first: se usa la función del registry sin
|
|
reimplementarla, solo se importa de forma quirúrgica.
|
|
"""
|
|
import importlib.util
|
|
|
|
file_path = os.path.join(_FUNCTIONS_DIR, package, module_name + ".py")
|
|
spec = importlib.util.spec_from_file_location(
|
|
f"{package}_{module_name}", file_path
|
|
)
|
|
if spec is None or spec.loader is None: # pragma: no cover - defensivo
|
|
raise ImportError(f"no se pudo cargar {file_path}")
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return getattr(module, attr)
|
|
|
|
|
|
# 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: listado incremental (PROPFIND Depth:1 -> [{href, etag}]) y GET de
|
|
# un recurso suelto. Base del sync inverso por etag (/api/sync/dav-pull).
|
|
dav_list_resources = _load_registry_fn("infra", "dav_list_resources", "dav_list_resources")
|
|
dav_get_resource = _load_registry_fn("infra", "dav_get_resource", "dav_get_resource")
|
|
|
|
# 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")
|
|
|
|
# Lectura read-only de DuckDB (la conexión de /api/query, separada del writer).
|
|
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(
|
|
"core", "render_markdown_table", "render_markdown_table"
|
|
)
|
|
upsert_sentinel_block = _load_registry_fn(
|
|
"core", "upsert_sentinel_block", "upsert_sentinel_block"
|
|
)
|
|
|
|
__all__ = [
|
|
"create_obsidian_note",
|
|
"list_obsidian_notes",
|
|
"read_obsidian_note",
|
|
"slugify_obsidian_name",
|
|
"update_obsidian_note",
|
|
"dav_collection_ctag",
|
|
"dav_get_collection",
|
|
"dav_list_calendars",
|
|
"dav_list_addressbooks",
|
|
"dav_list_resources",
|
|
"dav_get_resource",
|
|
"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",
|
|
]
|