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:
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user