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:`. * * A property is not always ONE line. RFC 5545 folds anything over 75 octets onto continuation lines that * start with a space, so a long description is one property spread over several. Replacing only the first * line leaves the rest behind, and they then fold onto whatever was written in its place — the new value * silently grows the tail of the old one. So the whole run goes, and the whole run is rewritten. */ 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};`)); // Everything folded onto the property belongs to it. let after = at + 1; while (at !== -1 && after < lines.length && /^[ \t]/.test(lines[after]!)) after++; if (value === null) return at === -1 ? raw : [...lines.slice(0, at), ...lines.slice(after)].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), fold(`${prop}${params}:${escaped}`), ...lines.slice(after)].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), fold(`${prop}:${escaped}`), ...lines.slice(insertAt)].join('\r\n'); } /** * Fold a content line at 75 OCTETS, per RFC 5545 §3.1. * * Octets rather than characters, and broken between code points, so an accent in a pasted title is not * cut in half. Deliberately a second copy of the sidecar's `fold` rather than an import: this package is * a browser bundle and the sidecar is a server process, and the existing rule in this plugin is that the * two MIRROR each other's shapes instead of sharing code across that boundary. */ function fold(line: string): string { const encoder = new TextEncoder(); const out: string[] = []; let current = ''; let bytes = 0; for (const ch of line) { const size = encoder.encode(ch).length; if (bytes + size > 75) { out.push(current); current = ' '; bytes = 1; } current += ch; bytes += size; } out.push(current); return out.join('\r\n'); } export type NewEvent = { summary: string; location?: string; /** An ISO instant for a timed event; `YYYY-MM-DD` when `allDay`. */ start: string; end?: string; allDay: boolean; }; /** `busy` holds the href being written; a create has no href yet, so it gets a sentinel of its own. */ export const CREATING = 'creating'; 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); } }; /** * A new event. The BODY is built by the sidecar, not here — a browser has no business generating a * UID, a DTSTAMP and a VTIMEZONE-free UTC timestamp, and there is exactly one correct answer for each, * which belongs next to the parser that has to read them back. */ const create = async (collection: string, event: NewEvent) => { setBusy(CREATING); setError(null); try { await client.post('/caldav/_officer/event', { collection, ...event }); await refresh(); return true; } catch (err) { setError(err instanceof Error ? err.message : 'Could not create.'); return false; } finally { setBusy(null); } }; return { save, remove, create, readRaw, busy, error }; }