the screens could show a calendar and an address book and change neither. this adds a pencil and a bin to every event and every card, with a confirm on the bin. three things worth defending. THE HREF IS THE ADDRESS, and the listing now carries it. it used to return only the object's data, which is enough to show something and not enough to change it. deriving `<uid>.ics` was the obvious shortcut and is wrong: a .ics filename only conventionally matches the uid — radicale stores what the writing client chose — so it works for everything officer wrote and misses everything a phone did. proved immediately: this machine's own calendar holds 62970EC7-47B1-4214-858E-85C32C2476A5.ics, written by an iphone. READ, PATCH, PUT — NEVER REBUILD. an event is a file with far more in it than these screens show: alarms, attendees, RRULEs, every X- property. rebuilding it from the fields on screen would silently drop all of that, and an edit that quietly deletes half an event is worse than no editing. so a save reads the original text, replaces only the lines it owns, and puts the whole file back. verified on a real event: 22 lines before, 22 after, one changed. THE EDITOR IS DELIBERATELY SMALL. title and place for an event, name/email/phone for a card. times and recurrence are where an editor does damage, and a half-editor that rewrites an RRULE it does not understand is the thing this avoids. `setProperty` also escapes commas, semicolons and newlines, because a raw comma silently splits an iCalendar TEXT value into parts. the sidecar grew GET/PUT/DELETE on /_officer/item, above the `collection is required` guard — they take an href rather than a collection, and sitting below it they answered 400 forever. both check the href is inside this user's own tree even though X-Remote-User already scopes radicale: an href from a request body is attacker-shaped input either way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
207 lines
7.0 KiB
TypeScript
207 lines
7.0 KiB
TypeScript
// Just enough iCalendar/vCard to drive a list view.
|
|
//
|
|
// SCOPE, DELIBERATELY SMALL. This unfolds lines, splits properties and pulls out the handful of fields a
|
|
// UI shows. It does NOT expand RRULE, and it does not resolve VTIMEZONE — see
|
|
// docs/nextcloud-replacement.md for why those are the expensive half and why Radicale, not this file,
|
|
// owns correctness. `rrule` is passed through raw so the UI can say "repeats" without either of us
|
|
// pretending to know when.
|
|
//
|
|
// Everything here is display-only. The authoritative copy of an event is the .ics on disk that DAV
|
|
// clients read and write; nothing in this file ever writes one back.
|
|
|
|
/** Unfold RFC 5545 continuation lines (a leading space or tab continues the previous line). */
|
|
function unfold(text: string): string[] {
|
|
const out: string[] = [];
|
|
for (const raw of text.split(/\r?\n/)) {
|
|
if ((raw.startsWith(' ') || raw.startsWith('\t')) && out.length > 0) {
|
|
out[out.length - 1] += raw.slice(1);
|
|
} else if (raw.length > 0) {
|
|
out.push(raw);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export type Prop = { name: string; params: Record<string, string>; value: string };
|
|
|
|
/** `DTSTART;TZID=Europe/Lisbon:20260805T090000` → name, params, value. */
|
|
function parseLine(line: string): Prop | null {
|
|
const colon = line.indexOf(':');
|
|
if (colon < 0) return null;
|
|
const head = line.slice(0, colon);
|
|
const value = line.slice(colon + 1);
|
|
const [name, ...paramParts] = head.split(';');
|
|
const params: Record<string, string> = {};
|
|
for (const part of paramParts) {
|
|
const eq = part.indexOf('=');
|
|
if (eq > 0) params[part.slice(0, eq).toUpperCase()] = part.slice(eq + 1).replace(/^"|"$/g, '');
|
|
}
|
|
return { name: (name ?? '').toUpperCase(), params, value };
|
|
}
|
|
|
|
// Escaped per RFC 5545 §3.3.11. Order matters: backslash last, or it would un-escape the escapes.
|
|
const unescapeText = (value: string) =>
|
|
value.replace(/\\n/gi, '\n').replace(/\\,/g, ',').replace(/\\;/g, ';').replace(/\\\\/g, '\\');
|
|
|
|
/**
|
|
* iCalendar dates come in three shapes and conflating them is how an all-day event ends up on the wrong
|
|
* day: `20260805` (date only), `20260805T090000Z` (UTC) and `20260805T090000` with a TZID parameter
|
|
* (floating/local). Only the first two can be resolved here without a timezone database, so the third is
|
|
* returned as-is with `allDay:false` and the UI treats it as local — which is what it means to whoever
|
|
* typed it in.
|
|
*/
|
|
export function parseDate(prop: Prop): { iso: string; allDay: boolean } | null {
|
|
const v = prop.value.trim();
|
|
const dateOnly = /^(\d{4})(\d{2})(\d{2})$/.exec(v);
|
|
if (dateOnly) return { iso: `${dateOnly[1]}-${dateOnly[2]}-${dateOnly[3]}`, allDay: true };
|
|
|
|
const dateTime = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z)?$/.exec(v);
|
|
if (!dateTime) return null;
|
|
const [, y, mo, d, h, mi, s, z] = dateTime;
|
|
return { iso: `${y}-${mo}-${d}T${h}:${mi}:${s}${z ? 'Z' : ''}`, allDay: false };
|
|
}
|
|
|
|
export type CalendarEvent = {
|
|
uid: string;
|
|
/** Where this event LIVES — the DAV href. Absent when parsed outside a listing. Needed to edit or delete it. */
|
|
href?: string;
|
|
summary: string;
|
|
description?: string;
|
|
location?: string;
|
|
start?: string;
|
|
end?: string;
|
|
allDay: boolean;
|
|
/** Raw RRULE, unexpanded. Present means "this repeats"; the UI must not infer occurrences from it. */
|
|
rrule?: string;
|
|
status?: string;
|
|
};
|
|
|
|
/** Every VEVENT in one .ics. A file usually holds one, plus overrides for a recurring series. */
|
|
export function parseEvents(ics: string): CalendarEvent[] {
|
|
const events: CalendarEvent[] = [];
|
|
let current: CalendarEvent | null = null;
|
|
// Only VEVENT is collected; a VALARM or VTIMEZONE nested inside must not have its properties
|
|
// attributed to the event that contains it.
|
|
let depth: string[] = [];
|
|
|
|
for (const line of unfold(ics)) {
|
|
const prop = parseLine(line);
|
|
if (!prop) continue;
|
|
|
|
if (prop.name === 'BEGIN') {
|
|
depth.push(prop.value.toUpperCase());
|
|
if (prop.value.toUpperCase() === 'VEVENT') current = { uid: '', summary: '', allDay: false };
|
|
continue;
|
|
}
|
|
if (prop.name === 'END') {
|
|
if (prop.value.toUpperCase() === 'VEVENT' && current) {
|
|
events.push(current);
|
|
current = null;
|
|
}
|
|
depth.pop();
|
|
continue;
|
|
}
|
|
if (!current || depth[depth.length - 1] !== 'VEVENT') continue;
|
|
|
|
switch (prop.name) {
|
|
case 'UID':
|
|
current.uid = prop.value;
|
|
break;
|
|
case 'SUMMARY':
|
|
current.summary = unescapeText(prop.value);
|
|
break;
|
|
case 'DESCRIPTION':
|
|
current.description = unescapeText(prop.value);
|
|
break;
|
|
case 'LOCATION':
|
|
current.location = unescapeText(prop.value);
|
|
break;
|
|
case 'STATUS':
|
|
current.status = prop.value;
|
|
break;
|
|
case 'RRULE':
|
|
current.rrule = prop.value;
|
|
break;
|
|
case 'DTSTART': {
|
|
const parsed = parseDate(prop);
|
|
if (parsed) {
|
|
current.start = parsed.iso;
|
|
current.allDay = parsed.allDay;
|
|
}
|
|
break;
|
|
}
|
|
case 'DTEND': {
|
|
const parsed = parseDate(prop);
|
|
if (parsed) current.end = parsed.iso;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return events;
|
|
}
|
|
|
|
export type Contact = {
|
|
uid: string;
|
|
/** Where this contact LIVES — the DAV href. Absent when parsed outside a listing. Needed to edit or delete it. */
|
|
href?: string;
|
|
fullName: string;
|
|
emails: string[];
|
|
phones: string[];
|
|
org?: string;
|
|
title?: string;
|
|
/** Present when the card carries an embedded photo; the bytes are not inlined into list responses. */
|
|
hasPhoto: boolean;
|
|
};
|
|
|
|
/** Every VCARD in one .vcf. */
|
|
export function parseContacts(vcf: string): Contact[] {
|
|
const contacts: Contact[] = [];
|
|
let current: Contact | null = null;
|
|
|
|
for (const line of unfold(vcf)) {
|
|
const prop = parseLine(line);
|
|
if (!prop) continue;
|
|
|
|
if (prop.name === 'BEGIN' && prop.value.toUpperCase() === 'VCARD') {
|
|
current = { uid: '', fullName: '', emails: [], phones: [], hasPhoto: false };
|
|
continue;
|
|
}
|
|
if (prop.name === 'END' && prop.value.toUpperCase() === 'VCARD') {
|
|
if (current) contacts.push(current);
|
|
current = null;
|
|
continue;
|
|
}
|
|
if (!current) continue;
|
|
|
|
switch (prop.name) {
|
|
case 'UID':
|
|
current.uid = prop.value;
|
|
break;
|
|
case 'FN':
|
|
current.fullName = unescapeText(prop.value);
|
|
break;
|
|
case 'EMAIL':
|
|
current.emails.push(prop.value.trim());
|
|
break;
|
|
case 'TEL':
|
|
current.phones.push(prop.value.trim());
|
|
break;
|
|
case 'ORG':
|
|
// ORG is structured: `Company;Department`. The first component is the one a list shows.
|
|
current.org = unescapeText(prop.value.split(';')[0] ?? '');
|
|
break;
|
|
case 'TITLE':
|
|
current.title = unescapeText(prop.value);
|
|
break;
|
|
case 'PHOTO':
|
|
current.hasPhoto = true;
|
|
break;
|
|
}
|
|
}
|
|
// A card with no FN is legal but unshowable; fall back to the first email so it is at least findable.
|
|
for (const contact of contacts) {
|
|
if (!contact.fullName) contact.fullName = contact.emails[0] ?? contact.uid ?? 'Unnamed';
|
|
}
|
|
return contacts;
|
|
}
|