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
|
- build_obsidian_graph_py_obsidian
|
||||||
- list_obsidian_notes_py_obsidian
|
- list_obsidian_notes_py_obsidian
|
||||||
- read_obsidian_note_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
|
- extract_obsidian_embeds_py_obsidian
|
||||||
- resolve_obsidian_embed_py_obsidian
|
- resolve_obsidian_embed_py_obsidian
|
||||||
- slugify_obsidian_name_py_obsidian
|
- slugify_obsidian_name_py_obsidian
|
||||||
- search_obsidian_notes_py_obsidian
|
- search_obsidian_notes_py_obsidian
|
||||||
- dav_list_resources_py_infra
|
- dav_get_collection_py_infra
|
||||||
- dav_get_resource_py_infra
|
- dav_collection_ctag_py_infra
|
||||||
|
- carddav_put_vcard_py_infra
|
||||||
|
- dav_delete_resource_py_infra
|
||||||
- split_vcards_py_infra
|
- split_vcards_py_infra
|
||||||
- pass_get_secret_py_infra
|
- pass_get_secret_py_infra
|
||||||
uses_types: []
|
uses_types: []
|
||||||
@@ -43,11 +48,22 @@ web local:
|
|||||||
2. **El servidor Xandikos** (CardDAV/CalDAV): agenda de contactos y calendario de
|
2. **El servidor Xandikos** (CardDAV/CalDAV): agenda de contactos y calendario de
|
||||||
eventos.
|
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
|
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`,
|
funciones del grupo `obsidian` (`build_obsidian_graph`, `read_obsidian_note`,
|
||||||
`resolve_obsidian_embed`, ...) y del grupo `dav` (`dav_list_resources`,
|
`create_obsidian_note`, `update_obsidian_note`, `delete_obsidian_note`,
|
||||||
`dav_get_resource`, `split_vcards`) más `pass_get_secret` para la credencial,
|
`resolve_obsidian_embed`, ...) y del grupo `dav` (`dav_get_collection`,
|
||||||
todas declaradas en `uses_functions`.
|
`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
|
## Stack
|
||||||
|
|
||||||
|
|||||||
@@ -153,6 +153,69 @@ export const fetchSearch = (q: string) =>
|
|||||||
|
|
||||||
export const fetchContacts = () => getJSON<ContactsPayload>("/contacts");
|
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 = "") => {
|
export const fetchCalendar = (from = "", to = "") => {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (from) qs.set("from", from);
|
if (from) qs.set("from", from);
|
||||||
|
|||||||
@@ -19,6 +19,24 @@ const MESES = [
|
|||||||
"dic",
|
"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. */
|
/** "2026-06-07" → "07/06/2026". Devuelve el original si no matchea. */
|
||||||
export function formatISODate(value: string): string {
|
export function formatISODate(value: string): string {
|
||||||
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
|
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
|
||||||
|
|||||||
+428
-108
@@ -3,31 +3,113 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
|
Button,
|
||||||
Center,
|
Center,
|
||||||
Divider,
|
Divider,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
|
Modal,
|
||||||
Paper,
|
Paper,
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
|
TagsInput,
|
||||||
Table,
|
Table,
|
||||||
Text,
|
Text,
|
||||||
|
Textarea,
|
||||||
TextInput,
|
TextInput,
|
||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
import {
|
import {
|
||||||
IconAt,
|
IconAt,
|
||||||
|
IconEdit,
|
||||||
IconNote,
|
IconNote,
|
||||||
IconPhone,
|
IconPhone,
|
||||||
|
IconPlus,
|
||||||
IconSearch,
|
IconSearch,
|
||||||
|
IconTrash,
|
||||||
IconUser,
|
IconUser,
|
||||||
} from "@tabler/icons-react";
|
} 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
|
// Agenda: lista de contactos del addressbook Xandikos a la izquierda (con
|
||||||
// buscador por nombre / alias / teléfono / email) y la ficha del contacto
|
// buscador) y la ficha del contacto seleccionado a la derecha. Mantiene la
|
||||||
// seleccionado a la derecha (todos los campos, incluido el bloque osint y nota).
|
// 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() {
|
export function ContactsView() {
|
||||||
const [contacts, setContacts] = useState<Contact[]>([]);
|
const [contacts, setContacts] = useState<Contact[]>([]);
|
||||||
@@ -37,23 +119,31 @@ export function ContactsView() {
|
|||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [debQuery] = useDebouncedValue(query, 200);
|
const [debQuery] = useDebouncedValue(query, 200);
|
||||||
|
|
||||||
useEffect(() => {
|
// Estado del formulario (alta/edición). `editSlug` distingue alta (null) de
|
||||||
let alive = true;
|
// 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);
|
setLoading(true);
|
||||||
fetchContacts()
|
fetchContacts()
|
||||||
.then((d) => {
|
.then((d) => {
|
||||||
if (!alive) return;
|
|
||||||
if (d.status !== "ok") {
|
if (d.status !== "ok") {
|
||||||
setError(d.error || "Xandikos no respondió");
|
setError(d.error || "Xandikos no respondió");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setError(null);
|
||||||
setContacts(d.contacts ?? []);
|
setContacts(d.contacts ?? []);
|
||||||
})
|
})
|
||||||
.catch((e) => alive && setError(String(e)))
|
.catch((e) => setError(String(e)))
|
||||||
.finally(() => alive && setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
return () => {
|
}
|
||||||
alive = false;
|
|
||||||
};
|
useEffect(() => {
|
||||||
|
reload();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
@@ -74,115 +164,345 @@ export function ContactsView() {
|
|||||||
});
|
});
|
||||||
}, [contacts, debQuery]);
|
}, [contacts, debQuery]);
|
||||||
|
|
||||||
if (error) {
|
function openNew() {
|
||||||
return (
|
setForm(EMPTY_FORM);
|
||||||
<Center h="100%" p="xl">
|
setEditSlug(null);
|
||||||
<Alert color="orange" title="Agenda no disponible" maw={500}>
|
setFormOpen(true);
|
||||||
{error}
|
}
|
||||||
<Text size="sm" mt="xs" c="dimmed">
|
|
||||||
El calendario y los contactos vienen del servidor Xandikos. El resto
|
function openEdit(c: Contact) {
|
||||||
de la app (grafo, tablas) funciona sin él.
|
setForm(formFromContact(c));
|
||||||
</Text>
|
setEditSlug(c.uid || slugify(c.nombre ?? ""));
|
||||||
</Alert>
|
setFormOpen(true);
|
||||||
</Center>
|
}
|
||||||
);
|
|
||||||
|
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 (
|
return (
|
||||||
<Group h="100%" gap={0} wrap="nowrap" align="stretch">
|
<>
|
||||||
<Paper
|
<ContactForm
|
||||||
w={360}
|
opened={formOpen}
|
||||||
radius={0}
|
editing={editSlug !== null}
|
||||||
withBorder
|
form={form}
|
||||||
style={{ borderTop: 0, borderBottom: 0, borderLeft: 0, display: "flex" }}
|
saving={saving}
|
||||||
>
|
onChange={setForm}
|
||||||
<Stack gap={0} w="100%">
|
onClose={() => setFormOpen(false)}
|
||||||
<Box p="md" pb="xs">
|
onSave={onSave}
|
||||||
<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 }}>
|
{error ? (
|
||||||
<ScrollArea h="100%">
|
<Center h="100%" p="xl">
|
||||||
{selected ? (
|
<Alert color="orange" title="Agenda no disponible" maw={500}>
|
||||||
<ContactDetail contact={selected} />
|
{error}
|
||||||
) : (
|
<Text size="sm" mt="xs" c="dimmed">
|
||||||
<Center h="60vh">
|
El calendario y los contactos vienen del servidor Xandikos. El
|
||||||
<Stack align="center" gap="xs">
|
resto de la app (grafo, tablas) funciona sin él.
|
||||||
<IconUser size={48} opacity={0.3} />
|
</Text>
|
||||||
<Text c="dimmed">Selecciona un contacto</Text>
|
</Alert>
|
||||||
</Stack>
|
</Center>
|
||||||
</Center>
|
) : (
|
||||||
)}
|
<Group h="100%" gap={0} wrap="nowrap" align="stretch">
|
||||||
</ScrollArea>
|
<Paper
|
||||||
</Box>
|
w={360}
|
||||||
</Group>
|
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(
|
const osintEntries = Object.entries(contact.osint ?? {}).filter(
|
||||||
([, v]) => v != null && v !== "",
|
([, v]) => v != null && v !== "",
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<Stack p="xl" gap="lg" maw={720}>
|
<Stack p="xl" gap="lg" maw={720}>
|
||||||
<Group gap="sm">
|
<Group justify="space-between" align="flex-start">
|
||||||
<Title order={3}>
|
<Group gap="sm">
|
||||||
{contact.nombre || contact.alias || contact.uid || "(sin nombre)"}
|
<Title order={3}>
|
||||||
</Title>
|
{contact.nombre || contact.alias || contact.uid || "(sin nombre)"}
|
||||||
{contact.alias && contact.alias !== contact.nombre && (
|
</Title>
|
||||||
<Badge variant="light" color="gray">
|
{contact.alias && contact.alias !== contact.nombre && (
|
||||||
{contact.alias}
|
<Badge variant="light" color="gray">
|
||||||
</Badge>
|
{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>
|
</Group>
|
||||||
|
|
||||||
{contact.org && (
|
{contact.org && (
|
||||||
|
|||||||
+387
-1
@@ -91,20 +91,24 @@ def _registry_functions_dir() -> str:
|
|||||||
_FUNCTIONS_DIR = _registry_functions_dir()
|
_FUNCTIONS_DIR = _registry_functions_dir()
|
||||||
sys.path.insert(0, _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 fastapi.responses import FileResponse, JSONResponse # noqa: E402
|
||||||
|
from pydantic import BaseModel, Field # noqa: E402
|
||||||
|
|
||||||
# --- Grupo de capacidad obsidian (parseo del vault) ---
|
# --- Grupo de capacidad obsidian (parseo del vault) ---
|
||||||
# El paquete obsidian tiene un __init__ ligero (sin dependencias pesadas), así
|
# El paquete obsidian tiene un __init__ ligero (sin dependencias pesadas), así
|
||||||
# que se importa directamente.
|
# que se importa directamente.
|
||||||
from obsidian import ( # noqa: E402 (sys.path debe resolverse antes)
|
from obsidian import ( # noqa: E402 (sys.path debe resolverse antes)
|
||||||
build_obsidian_graph,
|
build_obsidian_graph,
|
||||||
|
create_obsidian_note,
|
||||||
|
delete_obsidian_note,
|
||||||
extract_obsidian_embeds,
|
extract_obsidian_embeds,
|
||||||
list_obsidian_notes,
|
list_obsidian_notes,
|
||||||
read_obsidian_note,
|
read_obsidian_note,
|
||||||
resolve_obsidian_embed,
|
resolve_obsidian_embed,
|
||||||
search_obsidian_notes,
|
search_obsidian_notes,
|
||||||
slugify_obsidian_name,
|
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")
|
dav_collection_ctag = _load_infra_fn("dav_collection_ctag", "dav_collection_ctag")
|
||||||
split_vcards = _load_infra_fn("split_vcards", "split_vcards")
|
split_vcards = _load_infra_fn("split_vcards", "split_vcards")
|
||||||
pass_get_secret = _load_infra_fn("pass_get_secret", "pass_get_secret")
|
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._calendar_cache = None
|
||||||
self._force_reload = True
|
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
|
# 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
|
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
|
# Construcción de la app FastAPI
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -981,6 +1335,38 @@ def create_app(vault_dir: str) -> FastAPI:
|
|||||||
)
|
)
|
||||||
return JSONResponse(content={"status": "ok", "contact": match})
|
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) --
|
# -- Xandikos: calendario (CalDAV) --
|
||||||
|
|
||||||
@app.get("/api/calendar")
|
@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ó
|
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)
|
# Smoke real opcional contra Xandikos (gateado, no corre en CI)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user