one sidecar, one permission, one install — and two screens, because Calendar and Contacts are two places you go rather than two sections of one page. the first plugin to declare `screens`, which the platform gained for this. what moved: the radicale sidecar, the four routers, the panel apps, the app-password table and the settings form. the form became a modal on the calendar screen — a plugin ships panels and cannot contribute a settings section, which is the same wall the email plugin hit and the same answer. the routes are /calendar and /contacts, not /caldav/calendar. they were core routes and keeping them is not cosmetic: a bookmark, a dock tile someone arranged, and a persisted layout all address them. only a first-party plugin may choose its own segment; a third-party one is confined to /p/<publisher>/. four paths outside /api come with it, above the account gate, because phones are not browsers: DAVx5, iOS and thunderbird send HTTP basic on every request and have nowhere to put a JWT. that is not "open" — the sync door demands a scoped app password and resolves it to one user before radicale sees anything. two things deliberately unchanged. the table is still dav_app_passwords, not caldav_app_passwords: installs hold real rows and a naming convention is not worth a data migration on a live machine. and the panel names still resolve through the platform's catalogs, so the six locales they had as core screens survive rather than dropping to english to make the refactor tidier. per-user was already true and is worth writing down because the config said otherwise: the sync door resolves an app password to an account, tells radicale that id is the principal, and owner_only does the rest. the comment claiming "the owner is the only principal that exists" had been stale since multi-user landed on 2026-08-07. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
203 lines
6.8 KiB
TypeScript
203 lines
6.8 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;
|
|
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;
|
|
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;
|
|
}
|