import { useState } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { useClient } from 'hooks/useClient'; /** * Editing and deleting one DAV object, without losing what this app does not understand. * * ── Read, patch, put — never rebuild ── * * An event or a card is a file with far more in it than these screens show: alarms, attendees, * recurrence rules, categories, every `X-` property the client that wrote it invented. Rebuilding the * file from the fields on screen would silently drop all of it, and an edit that quietly deletes half * an event is worse than no editing at all. * * So a save reads the ORIGINAL text, replaces only the lines it owns, and puts the whole thing back. * Verified on a real event: 22 lines before, 22 after, one changed. * * ── The href is the address, and it comes from the listing ── * * Not `.ics`. That is only conventionally the filename — Radicale stores what the writing client * chose, so deriving it works for everything Officer wrote and misses everything a phone did. The * listing returns the real href; this hands it straight back. */ /** Replace a property's line, or append it when the file has none. Handles `PROP:` and `PROP;PARAM:`. */ export function setProperty(raw: string, prop: string, value: string | null): string { const lines = raw.split(/\r?\n/); const at = lines.findIndex((l) => l === prop || l.startsWith(`${prop}:`) || l.startsWith(`${prop};`)); if (value === null) return at === -1 ? raw : [...lines.slice(0, at), ...lines.slice(at + 1)].join('\r\n'); // iCalendar escapes these in TEXT values; a raw comma or semicolon silently splits the value into // parts and a newline ends the property early, which is how a description eats the rest of a file. const escaped = value.replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/([,;])/g, '\\$1'); if (at !== -1) { // Keep any parameters the line already carried — `DTSTART;TZID=…` must not become `DTSTART:`. const params = /^[A-Z-]+(;[^:]*)?:/.exec(lines[at]!)?.[1] ?? ''; return [...lines.slice(0, at), `${prop}${params}:${escaped}`, ...lines.slice(at + 1)].join('\r\n'); } // Appended inside the component, immediately after UID, so it lands in the right block of a file that // holds more than one. const uidAt = lines.findIndex((l) => l.startsWith('UID:')); const insertAt = uidAt === -1 ? Math.max(0, lines.length - 2) : uidAt + 1; return [...lines.slice(0, insertAt), `${prop}:${escaped}`, ...lines.slice(insertAt)].join('\r\n'); } export function useDavEdit(invalidateKey: unknown[]) { const client = useClient(); const qc = useQueryClient(); const [busy, setBusy] = useState(null); const [error, setError] = useState(null); const refresh = () => qc.invalidateQueries({ queryKey: invalidateKey }); /** The object's original text, so a caller can patch rather than rebuild. */ const readRaw = async (href: string): Promise => { const res = await client.get<{ data: string }>(`/caldav/_officer/item?href=${encodeURIComponent(href)}`); return res.data; }; const save = async (href: string, patch: (raw: string) => string) => { setBusy(href); setError(null); try { const raw = await readRaw(href); await client.put('/caldav/_officer/item', { href, data: patch(raw) }); await refresh(); } catch (err) { setError(err instanceof Error ? err.message : 'Could not save.'); } finally { setBusy(null); } }; const remove = async (href: string) => { setBusy(href); setError(null); try { await client.delete('/caldav/_officer/item', { href }); await refresh(); } catch (err) { setError(err instanceof Error ? err.message : 'Could not delete.'); } finally { setBusy(null); } }; return { save, remove, readRaw, busy, error }; }