feat(contacts): CRUD de contactos (vault .md fuente de verdad + reflejo vCard)

Añade alta, edición y borrado de contactos (personas y organizaciones) a la
app osint_web. La fuente de verdad es la ficha .md del vault Obsidian
(CONVENTIONS.md §3b/§6); Xandikos es el retransmisor al móvil.

Backend (server/main.py):
- POST /api/contact: genera slug, escribe la ficha .md con el frontmatter
  canónico + PUT del vCard a Xandikos. 409 si el slug ya existe.
- PUT /api/contact/{slug}: merge del frontmatter (preserva campos heredados)
  + re-PUT del vCard. 404 si no existe.
- DELETE /api/contact/{slug}: borra la ficha .md + DELETE del vCard. 404 si
  no existe.
Cada escritura invalida la caché DAV para que el cambio se vea ya en la app.
Registry-first: orquesta create/update/delete_obsidian_note del grupo obsidian
y carddav_put_vcard/dav_delete_resource del grupo dav (sin reimplementar
parseo ni HTTP). Mapea los campos OSINT a propiedades X-OSINT-* del vCard.

Frontend (ContactsView.tsx + api.ts + format.ts):
- Botón "Nuevo contacto" → modal con formulario Mantine (TextInput,
  TagsInput aliases, Select contexto, Textarea notas).
- Detalle: botones "Editar" (formulario precargado) y "Borrar" (con
  confirmación). Tras guardar refresca la lista.
- Helper slugify (replica slugify_obsidian_name) para resolver la ficha.

Tests: 6 nuevos casos (ciclo crear→editar→borrar con .md real + reflejo vCard
mockeado, organización, 404s, tipo inválido, preserva campos heredados). Suite
27 passed. Ciclo e2e real verificado contra Xandikos + vault (vCard creado,
editado y borrado; slug zz-test-crud limpiado). pnpm build verde (React 19 +
Mantine v9).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-12 00:18:55 +02:00
parent 44a696c12e
commit 43889bfc07
6 changed files with 1079 additions and 114 deletions
+21 -5
View File
@@ -9,12 +9,17 @@ uses_functions:
- build_obsidian_graph_py_obsidian
- list_obsidian_notes_py_obsidian
- read_obsidian_note_py_obsidian
- create_obsidian_note_py_obsidian
- update_obsidian_note_py_obsidian
- delete_obsidian_note_py_obsidian
- extract_obsidian_embeds_py_obsidian
- resolve_obsidian_embed_py_obsidian
- slugify_obsidian_name_py_obsidian
- search_obsidian_notes_py_obsidian
- dav_list_resources_py_infra
- dav_get_resource_py_infra
- dav_get_collection_py_infra
- dav_collection_ctag_py_infra
- carddav_put_vcard_py_infra
- dav_delete_resource_py_infra
- split_vcards_py_infra
- pass_get_secret_py_infra
uses_types: []
@@ -43,11 +48,22 @@ web local:
2. **El servidor Xandikos** (CardDAV/CalDAV): agenda de contactos y calendario de
eventos.
Edición de contactos (CRUD): la app permite crear, editar y borrar contactos
(personas y organizaciones). La **fuente de verdad es la ficha `.md` del vault**
(esquema canónico de `CONVENTIONS.md` §3b/§6); Xandikos es el retransmisor al
móvil. Cada alta/edición/borrado escribe primero la ficha del vault (acción
primaria con `create_obsidian_note` / `update_obsidian_note` /
`delete_obsidian_note`) y a continuación refleja el cambio en Xandikos de
inmediato (`carddav_put_vcard` / `dav_delete_resource`) para que se vea ya en la
app y en el móvil sin esperar al sync periódico del dag_engine.
Registry-first: el backend NO parsea el vault ni habla DAV a mano — orquesta las
funciones del grupo `obsidian` (`build_obsidian_graph`, `read_obsidian_note`,
`resolve_obsidian_embed`, ...) y del grupo `dav` (`dav_list_resources`,
`dav_get_resource`, `split_vcards`) más `pass_get_secret` para la credencial,
todas declaradas en `uses_functions`.
`create_obsidian_note`, `update_obsidian_note`, `delete_obsidian_note`,
`resolve_obsidian_embed`, ...) y del grupo `dav` (`dav_get_collection`,
`dav_collection_ctag`, `carddav_put_vcard`, `dav_delete_resource`,
`split_vcards`) más `pass_get_secret` para la credencial, todas declaradas en
`uses_functions`.
## Stack
+63
View File
@@ -153,6 +153,69 @@ export const fetchSearch = (q: string) =>
export const fetchContacts = () => getJSON<ContactsPayload>("/contacts");
// --- CRUD de contactos: ficha .md del vault (verdad) + reflejo del vCard ----
export interface ContactInput {
tipo: "persona" | "organizacion";
nombre: string;
aliases: string[];
telefono: string | null;
email: string | null;
dni: string | null;
direccion: string | null;
pais: string | null;
contexto: string | null;
relaciones: string[];
notas: string | null;
}
export interface ContactWriteResult {
status: string;
slug: string;
uid: string;
deleted?: boolean;
dav?: { status?: string; http_status?: number; error?: string };
}
async function sendJSON<T>(
path: string,
method: "POST" | "PUT" | "DELETE",
body?: unknown,
): Promise<T> {
const res = await fetch(BASE + path, {
method,
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!res.ok) {
let detail = "";
try {
const j = await res.json();
detail = j?.detail || j?.error || "";
} catch {
/* respuesta no JSON */
}
throw new Error(`HTTP ${res.status}${detail ? `${detail}` : ""}`);
}
return res.json() as Promise<T>;
}
export const createContact = (data: ContactInput) =>
sendJSON<ContactWriteResult>("/contact", "POST", data);
export const updateContact = (slug: string, data: ContactInput) =>
sendJSON<ContactWriteResult>(
`/contact/${encodeURIComponent(slug)}`,
"PUT",
data,
);
export const deleteContact = (slug: string) =>
sendJSON<ContactWriteResult>(
`/contact/${encodeURIComponent(slug)}`,
"DELETE",
);
export const fetchCalendar = (from = "", to = "") => {
const qs = new URLSearchParams();
if (from) qs.set("from", from);
+18
View File
@@ -19,6 +19,24 @@ const MESES = [
"dic",
];
/**
* Slug kebab-case estable a partir de un nombre, replicando el
* `slugify_obsidian_name` del registry (transliteración Unicode + minúsculas +
* colapsar no-[a-z0-9] a un guion). Se usa para resolver la ficha .md de un
* contacto al editar/borrar (el archivo se llama `<slug>.md`). El backend valida
* la existencia, esto solo predice el nombre del recurso.
*/
export function slugify(name: string): string {
return name
.normalize("NFKD")
.replace(/[̀-ͯ]/g, "") // quita marcas diacríticas combinantes
.replace(/ñ/g, "n")
.replace(/Ñ/g, "n")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
/** "2026-06-07" → "07/06/2026". Devuelve el original si no matchea. */
export function formatISODate(value: string): string {
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
+428 -108
View File
@@ -3,31 +3,113 @@ import {
Alert,
Badge,
Box,
Button,
Center,
Divider,
Group,
Loader,
Modal,
Paper,
ScrollArea,
Select,
Stack,
TagsInput,
Table,
Text,
Textarea,
TextInput,
Title,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { notifications } from "@mantine/notifications";
import {
IconAt,
IconEdit,
IconNote,
IconPhone,
IconPlus,
IconSearch,
IconTrash,
IconUser,
} from "@tabler/icons-react";
import { fetchContacts, type Contact } from "../api";
import {
createContact,
deleteContact,
fetchContacts,
updateContact,
type Contact,
type ContactInput,
} from "../api";
import { slugify } from "../format";
// Agenda: lista de contactos del addressbook Xandikos a la izquierda (con
// buscador por nombre / alias / teléfono / email) y la ficha del contacto
// seleccionado a la derecha (todos los campos, incluido el bloque osint y nota).
// buscador) y la ficha del contacto seleccionado a la derecha. Mantiene la
// vista de lectura y añade los controles de edición: alta ("Nuevo contacto"),
// edición y borrado de la ficha del vault (con reflejo inmediato en Xandikos).
type FormTipo = "persona" | "organizacion";
interface FormState {
tipo: FormTipo;
nombre: string;
aliases: string[];
telefono: string;
email: string;
dni: string;
direccion: string;
pais: string;
contexto: string;
notas: string;
}
const EMPTY_FORM: FormState = {
tipo: "persona",
nombre: "",
aliases: [],
telefono: "",
email: "",
dni: "",
direccion: "",
pais: "",
contexto: "",
notas: "",
};
// Construye el estado del formulario a partir de un contacto existente (para
// editar). Toma los campos del bloque osint (dni/direccion/pais/contexto) que el
// backend expone tras parsear el vCard.
function formFromContact(c: Contact): FormState {
const osint = c.osint ?? {};
return {
tipo: "persona",
nombre: c.nombre ?? "",
aliases: c.alias ? c.alias.split(",").map((s) => s.trim()).filter(Boolean) : [],
telefono: c.telefonos?.[0] ?? "",
email: c.correos?.[0] ?? "",
dni: osint.dni ?? "",
direccion: osint.direccion ?? "",
pais: osint.pais ?? "",
contexto: osint.contexto ?? "",
notas: c.nota ?? "",
};
}
function formToInput(f: FormState): ContactInput {
const t = (v: string) => (v.trim() ? v.trim() : null);
return {
tipo: f.tipo,
nombre: f.nombre.trim(),
aliases: f.aliases.map((s) => s.trim()).filter(Boolean),
telefono: t(f.telefono),
email: t(f.email),
dni: t(f.dni),
direccion: t(f.direccion),
pais: t(f.pais),
contexto: t(f.contexto),
relaciones: [],
notas: t(f.notas),
};
}
export function ContactsView() {
const [contacts, setContacts] = useState<Contact[]>([]);
@@ -37,23 +119,31 @@ export function ContactsView() {
const [query, setQuery] = useState("");
const [debQuery] = useDebouncedValue(query, 200);
useEffect(() => {
let alive = true;
// Estado del formulario (alta/edición). `editSlug` distingue alta (null) de
// edición (slug del contacto). `saving` deshabilita el botón mientras escribe.
const [formOpen, setFormOpen] = useState(false);
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const [editSlug, setEditSlug] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
function reload() {
setLoading(true);
fetchContacts()
.then((d) => {
if (!alive) return;
if (d.status !== "ok") {
setError(d.error || "Xandikos no respondió");
return;
}
setError(null);
setContacts(d.contacts ?? []);
})
.catch((e) => alive && setError(String(e)))
.finally(() => alive && setLoading(false));
return () => {
alive = false;
};
.catch((e) => setError(String(e)))
.finally(() => setLoading(false));
}
useEffect(() => {
reload();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const filtered = useMemo(() => {
@@ -74,115 +164,345 @@ export function ContactsView() {
});
}, [contacts, debQuery]);
if (error) {
return (
<Center h="100%" p="xl">
<Alert color="orange" title="Agenda no disponible" maw={500}>
{error}
<Text size="sm" mt="xs" c="dimmed">
El calendario y los contactos vienen del servidor Xandikos. El resto
de la app (grafo, tablas) funciona sin él.
</Text>
</Alert>
</Center>
);
function openNew() {
setForm(EMPTY_FORM);
setEditSlug(null);
setFormOpen(true);
}
function openEdit(c: Contact) {
setForm(formFromContact(c));
setEditSlug(c.uid || slugify(c.nombre ?? ""));
setFormOpen(true);
}
async function onSave() {
if (!form.nombre.trim()) {
notifications.show({ color: "red", message: "El nombre es obligatorio" });
return;
}
setSaving(true);
try {
const input = formToInput(form);
if (editSlug) {
await updateContact(editSlug, input);
notifications.show({ color: "teal", message: "Contacto actualizado" });
} else {
const res = await createContact(input);
notifications.show({
color: "teal",
message: `Contacto creado (${res.slug})`,
});
}
setFormOpen(false);
setSelected(null);
reload();
} catch (e) {
notifications.show({ color: "red", title: "No se pudo guardar", message: String(e) });
} finally {
setSaving(false);
}
}
async function onDelete(c: Contact) {
const slug = c.uid || slugify(c.nombre ?? "");
if (!confirm(`¿Borrar el contacto "${c.nombre || slug}"? Se elimina la ficha y el contacto del móvil.`))
return;
try {
await deleteContact(slug);
notifications.show({ color: "teal", message: "Contacto borrado" });
setSelected(null);
reload();
} catch (e) {
notifications.show({ color: "red", title: "No se pudo borrar", message: String(e) });
}
}
return (
<Group h="100%" gap={0} wrap="nowrap" align="stretch">
<Paper
w={360}
radius={0}
withBorder
style={{ borderTop: 0, borderBottom: 0, borderLeft: 0, display: "flex" }}
>
<Stack gap={0} w="100%">
<Box p="md" pb="xs">
<TextInput
placeholder="Buscar contacto…"
leftSection={<IconSearch size={16} />}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
/>
<Text size="xs" c="dimmed" mt={6}>
{filtered.length} de {contacts.length} contactos
</Text>
</Box>
<Divider />
{loading ? (
<Center style={{ flex: 1 }}>
<Loader />
</Center>
) : (
<ScrollArea style={{ flex: 1 }}>
<Stack gap={0}>
{filtered.map((c, i) => {
const key = c.uid || c.href || String(i);
const isSel = selected === c;
return (
<Box
key={key}
px="md"
py="xs"
onClick={() => setSelected(c)}
style={{
cursor: "pointer",
background: isSel
? "var(--mantine-color-brand-light)"
: undefined,
borderBottom: "1px solid var(--mantine-color-dark-5)",
}}
>
<Text size="sm" fw={isSel ? 600 : 400} truncate>
{c.nombre || c.alias || c.uid || "(sin nombre)"}
</Text>
{(c.telefonos?.[0] || c.correos?.[0]) && (
<Text size="xs" c="dimmed" truncate>
{c.telefonos?.[0] || c.correos?.[0]}
</Text>
)}
</Box>
);
})}
</Stack>
</ScrollArea>
)}
</Stack>
</Paper>
<>
<ContactForm
opened={formOpen}
editing={editSlug !== null}
form={form}
saving={saving}
onChange={setForm}
onClose={() => setFormOpen(false)}
onSave={onSave}
/>
<Box style={{ flex: 1, minWidth: 0 }}>
<ScrollArea h="100%">
{selected ? (
<ContactDetail contact={selected} />
) : (
<Center h="60vh">
<Stack align="center" gap="xs">
<IconUser size={48} opacity={0.3} />
<Text c="dimmed">Selecciona un contacto</Text>
</Stack>
</Center>
)}
</ScrollArea>
</Box>
</Group>
{error ? (
<Center h="100%" p="xl">
<Alert color="orange" title="Agenda no disponible" maw={500}>
{error}
<Text size="sm" mt="xs" c="dimmed">
El calendario y los contactos vienen del servidor Xandikos. El
resto de la app (grafo, tablas) funciona sin él.
</Text>
</Alert>
</Center>
) : (
<Group h="100%" gap={0} wrap="nowrap" align="stretch">
<Paper
w={360}
radius={0}
withBorder
style={{ borderTop: 0, borderBottom: 0, borderLeft: 0, display: "flex" }}
>
<Stack gap={0} w="100%">
<Box p="md" pb="xs">
<Button
fullWidth
leftSection={<IconPlus size={16} />}
onClick={openNew}
mb="sm"
>
Nuevo contacto
</Button>
<TextInput
placeholder="Buscar contacto…"
leftSection={<IconSearch size={16} />}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
/>
<Text size="xs" c="dimmed" mt={6}>
{filtered.length} de {contacts.length} contactos
</Text>
</Box>
<Divider />
{loading ? (
<Center style={{ flex: 1 }}>
<Loader />
</Center>
) : (
<ScrollArea style={{ flex: 1 }}>
<Stack gap={0}>
{filtered.map((c, i) => {
const key = c.uid || c.href || String(i);
const isSel = selected === c;
return (
<Box
key={key}
px="md"
py="xs"
onClick={() => setSelected(c)}
style={{
cursor: "pointer",
background: isSel
? "var(--mantine-color-brand-light)"
: undefined,
borderBottom: "1px solid var(--mantine-color-dark-5)",
}}
>
<Text size="sm" fw={isSel ? 600 : 400} truncate>
{c.nombre || c.alias || c.uid || "(sin nombre)"}
</Text>
{(c.telefonos?.[0] || c.correos?.[0]) && (
<Text size="xs" c="dimmed" truncate>
{c.telefonos?.[0] || c.correos?.[0]}
</Text>
)}
</Box>
);
})}
</Stack>
</ScrollArea>
)}
</Stack>
</Paper>
<Box style={{ flex: 1, minWidth: 0 }}>
<ScrollArea h="100%">
{selected ? (
<ContactDetail
contact={selected}
onEdit={() => openEdit(selected)}
onDelete={() => onDelete(selected)}
/>
) : (
<Center h="60vh">
<Stack align="center" gap="xs">
<IconUser size={48} opacity={0.3} />
<Text c="dimmed">Selecciona un contacto</Text>
</Stack>
</Center>
)}
</ScrollArea>
</Box>
</Group>
)}
</>
);
}
function ContactDetail({ contact }: { contact: Contact }) {
function ContactForm({
opened,
editing,
form,
saving,
onChange,
onClose,
onSave,
}: {
opened: boolean;
editing: boolean;
form: FormState;
saving: boolean;
onChange: (f: FormState) => void;
onClose: () => void;
onSave: () => void;
}) {
const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
onChange({ ...form, [key]: value });
return (
<Modal
opened={opened}
onClose={onClose}
title={editing ? "Editar contacto" : "Nuevo contacto"}
size="lg"
>
<Stack gap="sm">
<Select
label="Tipo"
data={[
{ value: "persona", label: "Persona" },
{ value: "organizacion", label: "Organización" },
]}
value={form.tipo}
onChange={(v) => set("tipo", (v as FormTipo) || "persona")}
disabled={editing}
allowDeselect={false}
/>
<TextInput
label="Nombre"
required
value={form.nombre}
onChange={(e) => set("nombre", e.currentTarget.value)}
data-autofocus
/>
<TagsInput
label="Aliases"
placeholder="otros nombres"
value={form.aliases}
onChange={(v) => set("aliases", v)}
/>
<Group grow>
<TextInput
label="Teléfono"
value={form.telefono}
onChange={(e) => set("telefono", e.currentTarget.value)}
/>
<TextInput
label="Email"
value={form.email}
onChange={(e) => set("email", e.currentTarget.value)}
/>
</Group>
{form.tipo === "persona" && (
<Group grow>
<TextInput
label="DNI"
value={form.dni}
onChange={(e) => set("dni", e.currentTarget.value)}
/>
<TextInput
label="País"
value={form.pais}
onChange={(e) => set("pais", e.currentTarget.value)}
/>
</Group>
)}
{form.tipo === "organizacion" && (
<TextInput
label="País"
value={form.pais}
onChange={(e) => set("pais", e.currentTarget.value)}
/>
)}
<TextInput
label="Dirección"
value={form.direccion}
onChange={(e) => set("direccion", e.currentTarget.value)}
/>
<Select
label="Contexto"
placeholder="origen / círculo"
data={[
"familia",
"aurgiobsidian",
"google-contacts",
"trabajo",
"prueba",
]}
value={form.contexto || null}
onChange={(v) => set("contexto", v || "")}
searchable
clearable
/>
<Textarea
label="Notas"
autosize
minRows={2}
value={form.notas}
onChange={(e) => set("notas", e.currentTarget.value)}
/>
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose}>
Cancelar
</Button>
<Button onClick={onSave} loading={saving}>
{editing ? "Guardar cambios" : "Crear contacto"}
</Button>
</Group>
</Stack>
</Modal>
);
}
function ContactDetail({
contact,
onEdit,
onDelete,
}: {
contact: Contact;
onEdit: () => void;
onDelete: () => void;
}) {
const osintEntries = Object.entries(contact.osint ?? {}).filter(
([, v]) => v != null && v !== "",
);
return (
<Stack p="xl" gap="lg" maw={720}>
<Group gap="sm">
<Title order={3}>
{contact.nombre || contact.alias || contact.uid || "(sin nombre)"}
</Title>
{contact.alias && contact.alias !== contact.nombre && (
<Badge variant="light" color="gray">
{contact.alias}
</Badge>
)}
<Group justify="space-between" align="flex-start">
<Group gap="sm">
<Title order={3}>
{contact.nombre || contact.alias || contact.uid || "(sin nombre)"}
</Title>
{contact.alias && contact.alias !== contact.nombre && (
<Badge variant="light" color="gray">
{contact.alias}
</Badge>
)}
</Group>
<Group gap="xs">
<Button
size="xs"
variant="light"
leftSection={<IconEdit size={14} />}
onClick={onEdit}
>
Editar
</Button>
<Button
size="xs"
variant="light"
color="red"
leftSection={<IconTrash size={14} />}
onClick={onDelete}
>
Borrar
</Button>
</Group>
</Group>
{contact.org && (
+387 -1
View File
@@ -91,20 +91,24 @@ def _registry_functions_dir() -> str:
_FUNCTIONS_DIR = _registry_functions_dir()
sys.path.insert(0, _FUNCTIONS_DIR)
from fastapi import FastAPI, HTTPException, Query # noqa: E402
from fastapi import Body, FastAPI, HTTPException, Query # noqa: E402
from fastapi.responses import FileResponse, JSONResponse # noqa: E402
from pydantic import BaseModel, Field # noqa: E402
# --- Grupo de capacidad obsidian (parseo del vault) ---
# El paquete obsidian tiene un __init__ ligero (sin dependencias pesadas), así
# que se importa directamente.
from obsidian import ( # noqa: E402 (sys.path debe resolverse antes)
build_obsidian_graph,
create_obsidian_note,
delete_obsidian_note,
extract_obsidian_embeds,
list_obsidian_notes,
read_obsidian_note,
resolve_obsidian_embed,
search_obsidian_notes,
slugify_obsidian_name,
update_obsidian_note,
)
@@ -138,6 +142,11 @@ dav_get_collection = _load_infra_fn("dav_get_collection", "dav_get_collection")
dav_collection_ctag = _load_infra_fn("dav_collection_ctag", "dav_collection_ctag")
split_vcards = _load_infra_fn("split_vcards", "split_vcards")
pass_get_secret = _load_infra_fn("pass_get_secret", "pass_get_secret")
# Escritura CardDAV: PUT (crear/editar) y DELETE (borrar) un vCard. El cambio en
# el vault .md es la fuente de verdad; estas reflejan el cambio en Xandikos de
# inmediato para que la app y el móvil lo vean ya, sin esperar al sync periódico.
carddav_put_vcard = _load_infra_fn("carddav_put_vcard", "carddav_put_vcard")
dav_delete_resource = _load_infra_fn("dav_delete_resource", "dav_delete_resource")
# ---------------------------------------------------------------------------
@@ -656,6 +665,183 @@ class VaultState:
self._calendar_cache = None
self._force_reload = True
# --- Escritura de contactos: ficha .md (verdad) + reflejo en Xandikos ----
def _contact_note_path(self, tipo: str, slug: str) -> str:
"""Path absoluto de la ficha ``.md`` de un contacto en el vault.
``personas/<slug>.md`` o ``organizaciones/<slug>.md`` según el tipo.
"""
folder = _TIPO_FOLDER.get(tipo, "personas")
return os.path.join(self._vault_real, folder, slug + ".md")
def _put_vcard(self, slug: str, vcard_text: str) -> dict:
"""Sube (PUT) un vCard a Xandikos por su UID=slug. No lanza por sí sola.
Reflejo inmediato del cambio en la ficha del vault, para que el contacto
se vea ya en la app y en el móvil sin esperar al sync periódico. Devuelve
el dict ``{status, http_status|error}`` de ``carddav_put_vcard``.
"""
password = self.xandikos_password()
return carddav_put_vcard(
XANDIKOS_BASE_URL,
XANDIKOS_USERNAME,
password,
XANDIKOS_CONTACTS_COLLECTION,
slug,
vcard_text,
)
def _delete_vcard(self, slug: str) -> dict:
"""Borra (DELETE) el vCard ``<slug>.vcf`` de Xandikos. No lanza.
Compone ``dav_delete_resource`` con el href del recurso (mismo nombre que
usó el PUT: ``<slug>.vcf``). Trata 404 como idempotente (ya no existía →
objetivo cumplido), igual que un borrado repetido.
"""
password = self.xandikos_password()
resource_path = XANDIKOS_CONTACTS_COLLECTION + slug + ".vcf"
res = dav_delete_resource(
XANDIKOS_BASE_URL, XANDIKOS_USERNAME, password, resource_path
)
if res.get("status") != "ok" and res.get("http_status") == 404:
return {"status": "ok", "http_status": 404, "idempotent": True}
return res
def create_contact(self, data: "ContactIn") -> dict:
"""Crea un contacto: ficha ``.md`` (verdad) + reflejo del vCard.
1. Genera el slug del nombre. 409 si ya existe la ficha.
2. Escribe la ficha ``.md`` con el frontmatter canónico (acción primaria).
3. Hace PUT del vCard a Xandikos (reflejo inmediato; un fallo NO revierte
la ficha — el sync periódico reconciliará).
4. Invalida las cachés DAV para que el contacto aparezca ya en la app.
Returns:
dict ``{slug, uid, path, dav}``.
Raises:
HTTPException(409): si ya existe una ficha con ese slug.
HTTPException(400): si el tipo no es 'persona'|'organizacion' o el
nombre está vacío.
"""
tipo = (data.tipo or "persona").strip()
if tipo not in _TIPO_FOLDER:
raise HTTPException(
status_code=400,
detail="tipo inválido '%s' (persona|organizacion)" % tipo,
)
if not data.nombre.strip():
raise HTTPException(status_code=400, detail="el nombre es obligatorio")
slug = slugify_obsidian_name(data.nombre)
if not slug:
raise HTTPException(
status_code=400, detail="el nombre no produce un slug válido"
)
note_path = self._contact_note_path(tipo, slug)
if os.path.exists(note_path):
raise HTTPException(
status_code=409, detail="ya existe un contacto con slug '%s'" % slug
)
frontmatter = _contact_frontmatter(data, slug)
body = _contact_body(data.notas)
folder = _TIPO_FOLDER[tipo]
# Acción primaria: la ficha del vault es la fuente de verdad.
create_obsidian_note(
self.vault_dir,
os.path.join(folder, slug),
body=body,
frontmatter=frontmatter,
)
# Reflejo inmediato en Xandikos (no rompe el alta si Xandikos cae).
vcard_fm = dict(frontmatter)
vcard_fm["_notas"] = _norm_str(data.notas)
dav = self._put_vcard(slug, _build_vcard(vcard_fm, slug))
self.refresh()
self.invalidate_dav()
return {"slug": slug, "uid": slug, "path": note_path, "dav": dav}
def update_contact(self, slug: str, data: "ContactIn") -> dict:
"""Edita un contacto existente: merge del frontmatter + re-PUT del vCard.
Localiza la ficha por slug (en personas/ u organizaciones/ según el tipo
de la ficha actual). 404 si no existe. Hace merge de los campos editables
sobre el frontmatter actual (preserva campos heredados no tocados como
``sexo``, ``fecha_nacimiento``, ``horoscopo``), reescribe el body
``## Notas`` y re-sube el vCard. Invalida las cachés DAV.
Returns:
dict ``{slug, uid, path, dav}``.
Raises:
HTTPException(404): si no existe la ficha del contacto.
"""
path = self._find_contact_note(slug)
if path is None:
raise HTTPException(
status_code=404, detail="contacto '%s' no encontrado" % slug
)
note = read_obsidian_note(path)
current = dict(note.get("frontmatter") or {})
# Merge de los campos editables (preserva los heredados no tocados).
merged = {
"nombre": data.nombre.strip() or current.get("nombre") or slug,
"aliases": _norm_list(data.aliases),
"telefono": _norm_str(data.telefono),
"email": _norm_str(data.email),
"direccion": _norm_str(data.direccion),
"pais": _norm_str(data.pais),
"relaciones": _norm_list(data.relaciones),
"contexto": _norm_str(data.contexto),
}
if current.get("tipo") != "organizacion":
merged["dni"] = _norm_str(data.dni)
current.update(merged)
update_obsidian_note(
path, body=_contact_body(data.notas), set_frontmatter=current
)
vcard_fm = dict(current)
vcard_fm["_notas"] = _norm_str(data.notas)
dav = self._put_vcard(slug, _build_vcard(vcard_fm, slug))
self.refresh()
self.invalidate_dav()
return {"slug": slug, "uid": slug, "path": path, "dav": dav}
def delete_contact(self, slug: str) -> dict:
"""Borra un contacto: elimina la ficha ``.md`` + el vCard en Xandikos.
404 si la ficha no existe. Borra el archivo ``.md`` (acción primaria) y
el recurso ``<slug>.vcf`` de Xandikos (reflejo). Invalida las cachés DAV.
Returns:
dict ``{slug, deleted: True, dav}``.
Raises:
HTTPException(404): si no existe la ficha del contacto.
"""
path = self._find_contact_note(slug)
if path is None:
raise HTTPException(
status_code=404, detail="contacto '%s' no encontrado" % slug
)
delete_obsidian_note(path)
dav = self._delete_vcard(slug)
self.refresh()
self.invalidate_dav()
return {"slug": slug, "deleted": True, "dav": dav}
def _find_contact_note(self, slug: str):
"""Localiza la ficha ``.md`` de un contacto por slug, o None.
Busca ``personas/<slug>.md`` y ``organizaciones/<slug>.md`` (los dos
tipos de contacto). Devuelve el primer path existente o None.
"""
for folder in ("personas", "organizaciones"):
candidate = os.path.join(self._vault_real, folder, slug + ".md")
if os.path.isfile(candidate):
return candidate
return None
# ---------------------------------------------------------------------------
# Helpers DAV: parseo ligero de vCard / iCalendar a JSON
@@ -846,6 +1032,174 @@ def _event_in_range(event: dict, dt_from: str, dt_to: str) -> bool:
return True
# ---------------------------------------------------------------------------
# Escritura de contactos: ficha .md del vault (fuente de verdad) + vCard
# ---------------------------------------------------------------------------
# Subcarpeta del vault por tipo de contacto. La fuente de verdad de un contacto
# es su ficha en el vault (CONVENTIONS.md §3b para persona, §6 para organización);
# Xandikos es solo el retransmisor al móvil.
_TIPO_FOLDER = {"persona": "personas", "organizacion": "organizaciones"}
# Tags por defecto de cada tipo (CONVENTIONS.md). Se preservan si la ficha ya
# trae otros tags al editar.
_TIPO_TAGS = {
"persona": ["persona", "osint"],
"organizacion": ["organizacion", "osint"],
}
class ContactIn(BaseModel):
"""Cuerpo de POST/PUT de un contacto (persona u organización).
Refleja el esquema canónico del frontmatter (CONVENTIONS.md §3b/§6). Los
campos vacíos se normalizan a ``null``/``[]`` al escribir la ficha, nunca se
omiten, para que el score de completitud sea consistente.
"""
tipo: str = Field(default="persona")
nombre: str
aliases: list[str] = Field(default_factory=list)
telefono: Optional[str] = None
email: Optional[str] = None
dni: Optional[str] = None
direccion: Optional[str] = None
pais: Optional[str] = None
contexto: Optional[str] = None
relaciones: list[str] = Field(default_factory=list)
notas: Optional[str] = None
def _norm_str(value: Optional[str]) -> Optional[str]:
"""Normaliza un string opcional: trim; cadena vacía → None."""
if value is None:
return None
value = value.strip()
return value or None
def _norm_list(values: Optional[list]) -> list:
"""Normaliza una lista de strings: trim cada item y descarta los vacíos."""
if not values:
return []
out = []
for v in values:
s = (v or "").strip()
if s:
out.append(s)
return out
def _contact_frontmatter(data: "ContactIn", slug: str) -> dict:
"""Construye el frontmatter canónico de la ficha de un contacto.
Para ``tipo: persona`` sigue el esquema completo de CONVENTIONS.md §3b
(todos los campos presentes, ``null``/``[]`` si vacíos). Para
``tipo: organizacion`` usa el subconjunto de §6. El orden de claves se
preserva (``yaml.safe_dump(sort_keys=False)`` en ``format_obsidian_note``).
"""
nombre = data.nombre.strip()
aliases = _norm_list(data.aliases)
relaciones = _norm_list(data.relaciones)
if data.tipo == "organizacion":
return {
"tipo": "organizacion",
"nombre": nombre,
"slug": slug,
"aliases": aliases,
"telefono": _norm_str(data.telefono),
"email": _norm_str(data.email),
"direccion": _norm_str(data.direccion),
"pais": _norm_str(data.pais),
"relaciones": relaciones,
"contexto": _norm_str(data.contexto),
"fuente": "osint_web (alta manual)",
"tags": list(_TIPO_TAGS["organizacion"]),
}
# Persona: esquema canónico §3b.
return {
"tipo": "persona",
"nombre": nombre,
"slug": slug,
"aliases": aliases,
"sexo": None,
"fecha_nacimiento": None,
"dni": _norm_str(data.dni),
"telefono": _norm_str(data.telefono),
"email": _norm_str(data.email),
"direccion": _norm_str(data.direccion),
"pais": _norm_str(data.pais),
"relaciones": relaciones,
"contexto": _norm_str(data.contexto),
"fuente": "osint_web (alta manual)",
"tags": list(_TIPO_TAGS["persona"]),
}
def _contact_body(notas: Optional[str]) -> str:
"""Cuerpo Markdown de la ficha: sección ``## Notas`` con el texto libre."""
notas = _norm_str(notas)
if notas:
return "## Notas\n\n%s\n" % notas
return "## Notas\n"
def _vcard_escape(value: str) -> str:
"""Escapa un valor de texto para una línea vCard (RFC 6350)."""
return (
value.replace("\\", "\\\\")
.replace("\n", "\\n")
.replace(",", "\\,")
.replace(";", "\\;")
)
def _build_vcard(frontmatter: dict, slug: str) -> str:
"""Serializa un frontmatter de contacto a un VCARD 3.0 con el UID = slug.
Mapea: nombre→FN, aliases→NICKNAME, telefono→TEL, email→EMAIL, notas→NOTE,
organización→ORG; y los campos OSINT (dni, direccion, pais, contexto, sexo,
fecha_nacimiento) a propiedades ``X-OSINT-*`` que el parser ``_vcard_to_json``
ya entiende. El UID es el slug → idempotente: re-subir el mismo slug
sobrescribe el recurso ``<slug>.vcf``.
"""
nombre = (frontmatter.get("nombre") or slug).strip()
lines = [
"BEGIN:VCARD",
"VERSION:3.0",
"UID:%s" % slug,
"FN:%s" % _vcard_escape(nombre),
]
aliases = frontmatter.get("aliases") or []
if aliases:
lines.append("NICKNAME:%s" % _vcard_escape(",".join(str(a) for a in aliases)))
if frontmatter.get("tipo") == "organizacion":
lines.append("ORG:%s" % _vcard_escape(nombre))
tel = frontmatter.get("telefono")
if tel:
lines.append("TEL;TYPE=CELL:%s" % _vcard_escape(str(tel)))
email = frontmatter.get("email")
if email:
lines.append("EMAIL;TYPE=INTERNET:%s" % _vcard_escape(str(email)))
# Campos OSINT → X-OSINT-* (los recoge _vcard_to_json en el bloque osint).
for fm_key, x_name in (
("dni", "X-OSINT-DNI"),
("direccion", "X-OSINT-DIRECCION"),
("pais", "X-OSINT-PAIS"),
("contexto", "X-OSINT-CONTEXTO"),
("sexo", "X-OSINT-SEXO"),
("fecha_nacimiento", "X-OSINT-FECHA-NACIMIENTO"),
):
val = frontmatter.get(fm_key)
if val:
lines.append("%s:%s" % (x_name, _vcard_escape(str(val))))
notas = frontmatter.get("_notas")
if notas:
lines.append("NOTE:%s" % _vcard_escape(str(notas)))
lines.append("END:VCARD")
return "\r\n".join(lines) + "\r\n"
# ---------------------------------------------------------------------------
# Construcción de la app FastAPI
# ---------------------------------------------------------------------------
@@ -981,6 +1335,38 @@ def create_app(vault_dir: str) -> FastAPI:
)
return JSONResponse(content={"status": "ok", "contact": match})
# -- Contactos: CRUD (ficha .md del vault = verdad, vCard = reflejo) --
@app.post("/api/contact")
def api_create_contact(data: ContactIn = Body(...)) -> JSONResponse:
"""Crea un contacto: escribe la ficha ``.md`` del vault + el vCard.
La ficha del vault es la fuente de verdad (CONVENTIONS.md §3b/§6);
Xandikos se actualiza de inmediato para que el contacto se vea ya en la
app y en el móvil. 409 si el slug ya existe. Devuelve ``{slug, uid}``.
"""
result = state.create_contact(data)
return JSONResponse(status_code=201, content={"status": "ok", **result})
@app.put("/api/contact/{slug}")
def api_update_contact(slug: str, data: ContactIn = Body(...)) -> JSONResponse:
"""Edita la ficha de un contacto (merge frontmatter) + re-sube el vCard.
404 si no existe la ficha. Preserva campos heredados no editables
(``sexo``, ``fecha_nacimiento``, ...). Devuelve ``{slug, uid}``.
"""
result = state.update_contact(slug, data)
return JSONResponse(content={"status": "ok", **result})
@app.delete("/api/contact/{slug}")
def api_delete_contact(slug: str) -> JSONResponse:
"""Borra un contacto: elimina la ficha ``.md`` + el vCard en Xandikos.
404 si no existe la ficha. Devuelve confirmación ``{slug, deleted}``.
"""
result = state.delete_contact(slug)
return JSONResponse(content={"status": "ok", **result})
# -- Xandikos: calendario (CalDAV) --
@app.get("/api/calendar")
+162
View File
@@ -442,6 +442,168 @@ def test_disk_cache_recarga_si_cambia_ctag(vault, fake_dav):
assert fake_dav["reports"] > reports_after_first # re-descargó
# ---------------------------------------------------------------------------
# CRUD de contactos: ficha .md del vault (verdad) + reflejo del vCard
# ---------------------------------------------------------------------------
@pytest.fixture()
def crud_client(vault, monkeypatch):
"""Cliente con el PUT/DELETE de Xandikos mockeado (CRUD sin red).
Verifica el comportamiento sobre el vault real (la ficha .md) sin tocar
Xandikos. ``calls`` registra los PUT/DELETE que el server intentó, para
asertar que el reflejo en Xandikos se dispara.
"""
calls = {"put": [], "delete": []}
def _put(base, user, pw, coll, uid, vcard_text, **kw):
calls["put"].append({"uid": uid, "vcard": vcard_text})
return {"status": "ok", "http_status": 201, "url": coll + uid + ".vcf"}
def _delete(base, user, pw, resource_path, **kw):
calls["delete"].append(resource_path)
return {"status": "ok", "http_status": 204, "url": resource_path}
monkeypatch.setattr(srv, "carddav_put_vcard", _put)
monkeypatch.setattr(srv, "dav_delete_resource", _delete)
monkeypatch.setattr(srv, "pass_get_secret", lambda *a, **k: {"status": "ok", "value": "x"})
app = srv.create_app(vault)
client = TestClient(app)
client._crud_calls = calls # type: ignore[attr-defined]
return client
def _persona_md_path(vault_dir: str, slug: str) -> str:
return os.path.join(vault_dir, "personas", slug + ".md")
def test_crud_contact_full_cycle(crud_client, vault):
"""Golden: crear → editar → borrar un contacto (ficha .md + reflejo vCard)."""
calls = crud_client._crud_calls
# -- CREATE --
body = {
"tipo": "persona",
"nombre": "Zoé Test Crud",
"aliases": ["Zozo"],
"telefono": "+34600999888",
"email": "zoe@example.com",
"dni": "99999999Z",
"direccion": "Calle Falsa 123",
"pais": "españa",
"contexto": "prueba",
"notas": "Contacto de prueba CRUD.",
}
r = crud_client.post("/api/contact", json=body)
assert r.status_code == 201, r.text
slug = r.json()["slug"]
assert slug == "zoe-test-crud"
assert r.json()["uid"] == slug
# La ficha .md se escribió de verdad en el vault.
md = _persona_md_path(vault, slug)
assert os.path.isfile(md)
content = open(md, encoding="utf-8").read()
assert "tipo: persona" in content
assert "Zoé Test Crud" in content
assert "99999999Z" in content
assert "Contacto de prueba CRUD." in content
# Reflejo: se hizo PUT del vCard con UID=slug y los X-OSINT-*.
assert calls["put"], "debió hacer PUT del vCard"
vc = calls["put"][-1]["vcard"]
assert "UID:zoe-test-crud" in vc
assert "FN:Zoé Test Crud" in vc
assert "X-OSINT-DNI:99999999Z" in vc
assert "TEL;TYPE=CELL:+34600999888" in vc
# 409 al recrear el mismo slug.
assert crud_client.post("/api/contact", json=body).status_code == 409
# -- READ (vía /api/node, la ficha aparece en el grafo) --
nr = crud_client.get("/api/node/%s" % slug)
assert nr.status_code == 200
assert nr.json()["frontmatter"]["dni"] == "99999999Z"
# -- UPDATE --
body2 = dict(body)
body2["telefono"] = "+34611000111"
body2["notas"] = "Editado."
ur = crud_client.put("/api/contact/%s" % slug, json=body2)
assert ur.status_code == 200, ur.text
content2 = open(md, encoding="utf-8").read()
assert "+34611000111" in content2
assert "Editado." in content2
# Re-PUT del vCard con el teléfono nuevo.
assert "TEL;TYPE=CELL:+34611000111" in calls["put"][-1]["vcard"]
# -- DELETE --
dr = crud_client.delete("/api/contact/%s" % slug)
assert dr.status_code == 200, dr.text
assert dr.json()["deleted"] is True
assert not os.path.isfile(md), "la ficha .md debe desaparecer"
# Reflejo: DELETE del recurso <slug>.vcf en Xandikos.
assert any(slug + ".vcf" in p for p in calls["delete"])
def test_crud_organizacion(crud_client, vault):
"""Edge: una organización usa organizaciones/ y emite ORG en el vCard."""
calls = crud_client._crud_calls
r = crud_client.post(
"/api/contact",
json={"tipo": "organizacion", "nombre": "Acme Test Org", "pais": "españa"},
)
assert r.status_code == 201, r.text
slug = r.json()["slug"]
org_md = os.path.join(vault, "organizaciones", slug + ".md")
assert os.path.isfile(org_md)
assert "tipo: organizacion" in open(org_md, encoding="utf-8").read()
assert "ORG:Acme Test Org" in calls["put"][-1]["vcard"]
# limpieza
crud_client.delete("/api/contact/%s" % slug)
assert not os.path.isfile(org_md)
def test_crud_update_missing_404(crud_client):
"""Error: editar un contacto inexistente devuelve 404, no crash."""
r = crud_client.put(
"/api/contact/no-existe", json={"tipo": "persona", "nombre": "X"}
)
assert r.status_code == 404
def test_crud_delete_missing_404(crud_client):
"""Error: borrar un contacto inexistente devuelve 404."""
assert crud_client.delete("/api/contact/no-existe").status_code == 404
def test_crud_create_invalid_tipo_400(crud_client):
"""Error: un tipo no soportado devuelve 400."""
r = crud_client.post("/api/contact", json={"tipo": "robot", "nombre": "R2D2"})
assert r.status_code == 400
def test_crud_update_preserves_inherited_fields(crud_client, vault):
"""Edge: editar preserva campos heredados no editables (sexo, etc.)."""
# Crea y luego inyecta un campo heredado a mano (simula ficha previa).
crud_client.post(
"/api/contact", json={"tipo": "persona", "nombre": "Inés Hered"}
)
slug = "ines-hered"
md = _persona_md_path(vault, slug)
# Añade sexo + horoscopo al frontmatter como si fueran heredados.
srv.update_obsidian_note(md, set_frontmatter={"sexo": "mujer", "horoscopo": "aries"})
# Edita vía API: no toca sexo/horoscopo.
crud_client.put(
"/api/contact/%s" % slug,
json={"tipo": "persona", "nombre": "Inés Hered", "email": "i@x.com"},
)
content = open(md, encoding="utf-8").read()
assert "sexo: mujer" in content
assert "horoscopo: aries" in content
assert "i@x.com" in content
crud_client.delete("/api/contact/%s" % slug)
# ---------------------------------------------------------------------------
# Smoke real opcional contra Xandikos (gateado, no corre en CI)
# ---------------------------------------------------------------------------