Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b578c2aa3b | ||
|
|
6954c878d3 | ||
|
|
76b4454cae | ||
|
|
43cb531dc2 | ||
|
|
25c8502c4b | ||
|
|
785e5f9eed | ||
|
|
0faf9183ad | ||
|
|
bfb12c2dde |
+2
-32
@@ -1,4 +1,3 @@
|
|||||||
import type { Context } from 'hono';
|
|
||||||
import { createRouter } from '@@/create-router';
|
import { createRouter } from '@@/create-router';
|
||||||
import {
|
import {
|
||||||
listDavAppPasswords,
|
listDavAppPasswords,
|
||||||
@@ -19,35 +18,6 @@ import { stashIosProfile } from './ios-profile';
|
|||||||
|
|
||||||
export const appPasswordsRouter = createRouter();
|
export const appPasswordsRouter = createRouter();
|
||||||
|
|
||||||
/**
|
|
||||||
* Read this request's JSON body.
|
|
||||||
*
|
|
||||||
* `ctx.get('body')` is the platform's parsed body, and it is EMPTY here — deliberately, and for a reason
|
|
||||||
* that only became true when caldav became a plugin.
|
|
||||||
*
|
|
||||||
* `createSidecarProxy` registers `/api/caldav` with `registerProxiedPrefix`, which tells `bodyParser` to
|
|
||||||
* leave those request streams untouched. That is right for forwarded traffic and load-bearing for
|
|
||||||
* multipart uploads: a body parsed here is re-serialised with a new boundary while the old content-type
|
|
||||||
* header is still forwarded, and every upload fails. But the prefix covers this plugin's OWN routes too,
|
|
||||||
* so they get `{}` and every field reads as missing.
|
|
||||||
*
|
|
||||||
* Before the extraction these routes lived under `/api/dav`, a second top-level prefix that was not
|
|
||||||
* proxied, so their bodies parsed. One prefix per plugin is the rule that ended that arrangement.
|
|
||||||
*
|
|
||||||
* Read here rather than fixed in the platform because the platform's rule is correct — the narrower fix
|
|
||||||
* is for the proxy to register only the sub-path it actually serves (`_officer/*`), which is a change to
|
|
||||||
* shared machinery every sidecar depends on and not one to make while a phone is waiting.
|
|
||||||
*/
|
|
||||||
async function jsonBody<T>(ctx: Context): Promise<T> {
|
|
||||||
try {
|
|
||||||
return (await ctx.req.json()) as T;
|
|
||||||
} catch {
|
|
||||||
// No body, or not JSON. Every caller below treats a missing field as a bad request anyway.
|
|
||||||
return {} as T;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
appPasswordsRouter.get('/passwords', async (ctx) => {
|
appPasswordsRouter.get('/passwords', async (ctx) => {
|
||||||
const user = ctx.get('user');
|
const user = ctx.get('user');
|
||||||
return ctx.json({ passwords: await listDavAppPasswords(user.id) });
|
return ctx.json({ passwords: await listDavAppPasswords(user.id) });
|
||||||
@@ -55,7 +25,7 @@ appPasswordsRouter.get('/passwords', async (ctx) => {
|
|||||||
|
|
||||||
appPasswordsRouter.post('/passwords', async (ctx) => {
|
appPasswordsRouter.post('/passwords', async (ctx) => {
|
||||||
const user = ctx.get('user');
|
const user = ctx.get('user');
|
||||||
const body = await jsonBody<{ label?: string }>(ctx);
|
const body = (ctx.get('body') ?? {}) as { label?: string };
|
||||||
const label = body?.label?.trim();
|
const label = body?.label?.trim();
|
||||||
if (!label) throw errors.BAD_REQUEST('label is required');
|
if (!label) throw errors.BAD_REQUEST('label is required');
|
||||||
|
|
||||||
@@ -72,7 +42,7 @@ appPasswordsRouter.post('/passwords', async (ctx) => {
|
|||||||
// exists because the alternative is the owner typing a 25-character secret into a phone keyboard twice.
|
// exists because the alternative is the owner typing a 25-character secret into a phone keyboard twice.
|
||||||
appPasswordsRouter.post('/provision/ios', async (ctx) => {
|
appPasswordsRouter.post('/provision/ios', async (ctx) => {
|
||||||
const user = ctx.get('user');
|
const user = ctx.get('user');
|
||||||
const body = await jsonBody<{ deviceLabel?: string; only?: 'caldav' | 'carddav' }>(ctx);
|
const body = (ctx.get('body') ?? {}) as { deviceLabel?: string; only?: 'caldav' | 'carddav' };
|
||||||
const deviceLabel = body?.deviceLabel?.trim();
|
const deviceLabel = body?.deviceLabel?.trim();
|
||||||
if (!deviceLabel) throw errors.BAD_REQUEST('deviceLabel is required');
|
if (!deviceLabel) throw errors.BAD_REQUEST('deviceLabel is required');
|
||||||
|
|
||||||
|
|||||||
+81
-6
@@ -136,11 +136,21 @@ ${kinds.map((k) => account(k, k === 'CalDAV' ? 'Officer Calendar' : 'Officer Con
|
|||||||
// The remaining caveat is Apple's: a REPLACEMENT profile must be signed by the same identity as the one
|
// The remaining caveat is Apple's: a REPLACEMENT profile must be signed by the same identity as the one
|
||||||
// it replaces, so after a certificate rotation a device may refuse to replace an older profile until the
|
// it replaces, so after a certificate rotation a device may refuse to replace an older profile until the
|
||||||
// old one is removed. Nothing detects that for you.
|
// old one is removed. Nothing detects that for you.
|
||||||
|
/**
|
||||||
|
* `OFFICERDEV_X ?? X`, which is the convention every other consumer follows.
|
||||||
|
*
|
||||||
|
* This read the bare names only. `.env` on a converted install prefixes everything — see
|
||||||
|
* `servers/env-prefix.ts` — so on those machines the signing certificate could be configured correctly
|
||||||
|
* and silently ignored, and the only symptom is a profile that says "Not Signed" for no visible reason.
|
||||||
|
* The unprefixed name stays as the fallback, never the source.
|
||||||
|
*/
|
||||||
|
const env = (name: string) => process.env[`OFFICERDEV_${name}`] ?? process.env[name];
|
||||||
|
|
||||||
const signingConfig = () => {
|
const signingConfig = () => {
|
||||||
const cert = process.env.DAV_PROFILE_SIGN_CERT;
|
const cert = env('DAV_PROFILE_SIGN_CERT');
|
||||||
const key = process.env.DAV_PROFILE_SIGN_KEY;
|
const key = env('DAV_PROFILE_SIGN_KEY');
|
||||||
if (!cert || !key) return null;
|
if (!cert || !key) return null;
|
||||||
return { cert, key, chain: process.env.DAV_PROFILE_SIGN_CHAIN };
|
return { cert, key, chain: env('DAV_PROFILE_SIGN_CHAIN') };
|
||||||
};
|
};
|
||||||
|
|
||||||
let warnedUnsigned = false;
|
let warnedUnsigned = false;
|
||||||
@@ -161,7 +171,30 @@ async function sign(plist: string): Promise<Uint8Array | null> {
|
|||||||
// there is no reason for it to touch the filesystem. `-outform der` and `-nodetach` are both
|
// there is no reason for it to touch the filesystem. `-outform der` and `-nodetach` are both
|
||||||
// load-bearing — the default output is base64 S/MIME, which iOS will not parse, and without -nodetach
|
// load-bearing — the default output is base64 S/MIME, which iOS will not parse, and without -nodetach
|
||||||
// the file is a signature with no profile embedded in it.
|
// the file is a signature with no profile embedded in it.
|
||||||
const args = ['smime', '-sign', '-signer', config.cert, '-inkey', config.key, '-outform', 'der', '-nodetach'];
|
/*
|
||||||
|
`-binary` IS NOT OPTIONAL, and its absence is why every signed profile was refused.
|
||||||
|
|
||||||
|
Without it `openssl smime -sign` treats the input as TEXT: it canonicalises line endings and adds MIME
|
||||||
|
headers before signing. The signature then verifies perfectly — `openssl smime -verify` reports
|
||||||
|
"Verification successful" — while the bytes iOS unwraps are no longer the plist that was written. The
|
||||||
|
device shows the profile, then refuses it with "Invalid Profile".
|
||||||
|
|
||||||
|
Apple's own sample signs with `OpenSSL::PKCS7::BINARY` for the same reason. This was found and written
|
||||||
|
down in `officer-oas/enroll.ts` on 2026-08-20, four wrong endings into a hunt that was never about the
|
||||||
|
ending — and then repeated here on 2026-08-27 because this file was written without reading that one.
|
||||||
|
*/
|
||||||
|
const args = [
|
||||||
|
'smime',
|
||||||
|
'-sign',
|
||||||
|
'-binary',
|
||||||
|
'-signer',
|
||||||
|
config.cert,
|
||||||
|
'-inkey',
|
||||||
|
config.key,
|
||||||
|
'-outform',
|
||||||
|
'der',
|
||||||
|
'-nodetach',
|
||||||
|
];
|
||||||
if (config.chain) args.push('-certfile', config.chain);
|
if (config.chain) args.push('-certfile', config.chain);
|
||||||
args.push('-md', 'sha256');
|
args.push('-md', 'sha256');
|
||||||
|
|
||||||
@@ -185,7 +218,42 @@ async function sign(plist: string): Promise<Uint8Array | null> {
|
|||||||
return new Uint8Array(out);
|
return new Uint8Array(out);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StoredProfile = { url: string; expiresAt: string; signed: boolean };
|
export type StoredProfile = {
|
||||||
|
url: string;
|
||||||
|
/** The same profile at a URL a person can TYPE. See `shortCode`. */
|
||||||
|
shortUrl: string;
|
||||||
|
code: string;
|
||||||
|
expiresAt: string;
|
||||||
|
signed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
A code short enough to type into Safari's address bar.
|
||||||
|
|
||||||
|
── Why a long URL is not good enough ──
|
||||||
|
|
||||||
|
The 43-character token cannot be typed, so it has to be tapped or pasted — and on a phone neither
|
||||||
|
works. Officer runs from the Home Screen in a WEBVIEW, and a webview cannot hand a `.mobileconfig` to
|
||||||
|
Settings: the download has nowhere to go and the screen does nothing. Every link the chat renders also
|
||||||
|
gets `target="_blank"`, and a popup tab breaks the profile hand-off in Safari too.
|
||||||
|
|
||||||
|
So the only path that reliably reaches iOS is a person typing a URL into real Safari, and that means it
|
||||||
|
has to be short.
|
||||||
|
|
||||||
|
── Why this is not a weaker credential ──
|
||||||
|
|
||||||
|
Crockford's alphabet minus the ambiguous letters, 8 characters: ~10^12 combinations. It is SINGLE USE
|
||||||
|
and lives ten minutes, so an attacker gets one guess per attempt against a target that disappears when
|
||||||
|
it is claimed or when the clock runs out. The long token still exists and still works; this is a second
|
||||||
|
door to the same one-shot entry, not a replacement for it.
|
||||||
|
*/
|
||||||
|
const CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTVWXYZ23456789';
|
||||||
|
const CODE_TTL_MS = 10 * 60_000;
|
||||||
|
|
||||||
|
const shortCode = (): string => {
|
||||||
|
const bytes = randomBytes(8);
|
||||||
|
return Array.from(bytes, (b) => CODE_ALPHABET[b % CODE_ALPHABET.length]).join('');
|
||||||
|
};
|
||||||
|
|
||||||
/** Build the profile and park it behind a one-shot token. Returns the URL Safari should be sent to. */
|
/** Build the profile and park it behind a one-shot token. Returns the URL Safari should be sent to. */
|
||||||
export async function stashIosProfile(params: ProfileParams, publicUrl: string): Promise<StoredProfile> {
|
export async function stashIosProfile(params: ProfileParams, publicUrl: string): Promise<StoredProfile> {
|
||||||
@@ -200,8 +268,15 @@ export async function stashIosProfile(params: ProfileParams, publicUrl: string):
|
|||||||
const expiresAt = Date.now() + TTL_MS;
|
const expiresAt = Date.now() + TTL_MS;
|
||||||
pending.set(token, { body, expiresAt });
|
pending.set(token, { body, expiresAt });
|
||||||
|
|
||||||
|
// The same profile behind a typeable code. Shorter-lived on purpose — see `shortCode`.
|
||||||
|
const code = shortCode();
|
||||||
|
pending.set(code, { body, expiresAt: Date.now() + CODE_TTL_MS });
|
||||||
|
|
||||||
|
const base = publicUrl.replace(/\/$/, '');
|
||||||
return {
|
return {
|
||||||
url: `${publicUrl.replace(/\/$/, '')}/dav/provision/${token}.mobileconfig`,
|
url: `${base}/dav/provision/${token}.mobileconfig`,
|
||||||
|
shortUrl: `${base}/dav/p/${code}`,
|
||||||
|
code,
|
||||||
expiresAt: new Date(expiresAt).toISOString(),
|
expiresAt: new Date(expiresAt).toISOString(),
|
||||||
signed: signed !== null,
|
signed: signed !== null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ import { createSidecarProxy } from '@@/sidecar/create-proxy';
|
|||||||
//
|
//
|
||||||
// Hence one registration, two forwarders. The factory's own comment says it must never grow per-app
|
// Hence one registration, two forwarders. The factory's own comment says it must never grow per-app
|
||||||
// logic, so the DAV-specific half is a separate file rather than a branch inside it.
|
// logic, so the DAV-specific half is a separate file rather than a branch inside it.
|
||||||
const proxy = createSidecarProxy({ name: 'caldav', prefix: '/api/caldav' });
|
// `proxiedSubPath`: this sidecar answers `_officer/*` and nothing else, so the proxy claims only that.
|
||||||
|
//
|
||||||
|
// Claiming the whole prefix is what stopped this plugin's OWN routes from seeing a request body —
|
||||||
|
// `registerProxiedPrefix` tells bodyParser to leave the prefix alone, which is right for forwarded
|
||||||
|
// traffic and wrong for `/passwords` and `/provision/ios`, which are served here.
|
||||||
|
const proxy = createSidecarProxy({ name: 'caldav', prefix: '/api/caldav', proxiedSubPath: '/_officer' });
|
||||||
|
|
||||||
export const caldavRouter = proxy.router;
|
export const caldavRouter = proxy.router;
|
||||||
export const getCaldavUrl = proxy.getHttpUrl;
|
export const getCaldavUrl = proxy.getHttpUrl;
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@ import type { PluginManifest } from '@@/plugins/manifest';
|
|||||||
*/
|
*/
|
||||||
export const manifest: PluginManifest = {
|
export const manifest: PluginManifest = {
|
||||||
publisher: 'officerdev',
|
publisher: 'officerdev',
|
||||||
version: '2026.08.27.01',
|
version: '2026.08.27.03',
|
||||||
platform: '>=1.0.0',
|
platform: '>=1.0.0',
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+89
-10
@@ -1,4 +1,13 @@
|
|||||||
import { parseContacts, parseEvents, type CalendarEvent, type Contact } from './ical';
|
import {
|
||||||
|
buildContact,
|
||||||
|
buildEvent,
|
||||||
|
parseContacts,
|
||||||
|
parseEvents,
|
||||||
|
type CalendarEvent,
|
||||||
|
type Contact,
|
||||||
|
type NewContact,
|
||||||
|
type NewEvent,
|
||||||
|
} from './ical';
|
||||||
|
|
||||||
// The JSON door: what Officer's own web UI reads.
|
// The JSON door: what Officer's own web UI reads.
|
||||||
//
|
//
|
||||||
@@ -11,8 +20,14 @@ import { parseContacts, parseEvents, type CalendarEvent, type Contact } from './
|
|||||||
// docs/nextcloud-replacement.md, "two front doors, not one".
|
// docs/nextcloud-replacement.md, "two front doors, not one".
|
||||||
|
|
||||||
export type Collection = {
|
export type Collection = {
|
||||||
/** DAV path, relative to the /dav mount — the id the UI passes back. */
|
/** DAV path, relative to the /dav mount — the id the UI passes back on every read. */
|
||||||
path: string;
|
path: string;
|
||||||
|
/**
|
||||||
|
* The last path segment, and the only part of the path that identifies anything. The web URLs select
|
||||||
|
* with this rather than the whole path: `?collection=personal` instead of `?collection=%2Fdav%2F1%2Fpersonal%2F`,
|
||||||
|
* which is the same information without the user id, the mount point and six escaped slashes.
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
kind: 'calendar' | 'addressbook';
|
kind: 'calendar' | 'addressbook';
|
||||||
/** Radicale stores a per-collection colour when a client sets one; used for the UI's dot. */
|
/** Radicale stores a per-collection colour when a client sets one; used for the UI's dot. */
|
||||||
@@ -77,6 +92,7 @@ export async function listCollections(fetcher: Fetcher, userId: string): Promise
|
|||||||
|
|
||||||
collections.push({
|
collections.push({
|
||||||
path,
|
path,
|
||||||
|
name: lastSegment,
|
||||||
// Radicale's default displayname is the collection's own path ("1/work"), which is an id, not a
|
// Radicale's default displayname is the collection's own path ("1/work"), which is an id, not a
|
||||||
// name. A real client sets something human when it creates a collection; until one does, the last
|
// name. A real client sets something human when it creates a collection; until one does, the last
|
||||||
// segment is the better label — and it is what every DAV client falls back to anyway.
|
// segment is the better label — and it is what every DAV client falls back to anyway.
|
||||||
@@ -99,7 +115,11 @@ export async function listCollections(fetcher: Fetcher, userId: string): Promise
|
|||||||
* One report rather than a listing plus a GET per item: a calendar with a thousand events would
|
* One report rather than a listing plus a GET per item: a calendar with a thousand events would
|
||||||
* otherwise be a thousand round trips.
|
* otherwise be a thousand round trips.
|
||||||
*/
|
*/
|
||||||
async function readObjects(fetcher: Fetcher, path: string, kind: 'calendar' | 'addressbook'): Promise<string[]> {
|
async function readObjects(
|
||||||
|
fetcher: Fetcher,
|
||||||
|
path: string,
|
||||||
|
kind: 'calendar' | 'addressbook',
|
||||||
|
): Promise<{ href: string; data: string }[]> {
|
||||||
const dataTag = kind === 'calendar' ? 'calendar-data' : 'address-data';
|
const dataTag = kind === 'calendar' ? 'calendar-data' : 'address-data';
|
||||||
const ns = kind === 'calendar' ? 'urn:ietf:params:xml:ns:caldav' : 'urn:ietf:params:xml:ns:carddav';
|
const ns = kind === 'calendar' ? 'urn:ietf:params:xml:ns:caldav' : 'urn:ietf:params:xml:ns:carddav';
|
||||||
const queryTag = kind === 'calendar' ? 'calendar-query' : 'addressbook-query';
|
const queryTag = kind === 'calendar' ? 'calendar-query' : 'addressbook-query';
|
||||||
@@ -114,17 +134,76 @@ async function readObjects(fetcher: Fetcher, path: string, kind: 'calendar' | 'a
|
|||||||
});
|
});
|
||||||
if (!res.ok && res.status !== 207) return [];
|
if (!res.ok && res.status !== 207) return [];
|
||||||
const xml = await res.text();
|
const xml = await res.text();
|
||||||
return between(xml, dataTag)
|
|
||||||
.map((chunk) => decodeEntities(chunk).trim())
|
/*
|
||||||
.filter(Boolean);
|
Paired with the HREF, because a UID is not an address.
|
||||||
|
|
||||||
|
Reading returned only the object's data, which is enough to SHOW something and not enough to change
|
||||||
|
it: DELETE and PUT both need the item's URL, and a `.ics` filename is only conventionally the UID —
|
||||||
|
Radicale stores what the client that wrote it chose. Deriving `<uid>.ics` works for everything Officer
|
||||||
|
wrote and fails for everything a phone did, which is the worst kind of nearly-right.
|
||||||
|
|
||||||
|
So each `<response>` is split first and the href taken from it, rather than pulling every
|
||||||
|
`calendar-data` out of the whole document and losing which response each came from.
|
||||||
|
*/
|
||||||
|
return between(xml, 'response')
|
||||||
|
.map((block) => {
|
||||||
|
const href = decodeEntities(first(block, 'href') ?? '').trim();
|
||||||
|
const data = decodeEntities(first(block, dataTag) ?? '').trim();
|
||||||
|
return { href, data };
|
||||||
|
})
|
||||||
|
.filter((o) => o.href && o.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listEvents(fetcher: Fetcher, path: string): Promise<CalendarEvent[]> {
|
export async function listEvents(fetcher: Fetcher, path: string): Promise<CalendarEvent[]> {
|
||||||
const blobs = await readObjects(fetcher, path, 'calendar');
|
const objects = await readObjects(fetcher, path, 'calendar');
|
||||||
return blobs.flatMap(parseEvents);
|
// One .ics can hold several VEVENTs (a recurring series with overrides), and they share its href —
|
||||||
|
// which is correct: deleting any of them deletes the file they live in.
|
||||||
|
return objects.flatMap((o) => parseEvents(o.data).map((e) => ({ ...e, href: o.href })));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listContacts(fetcher: Fetcher, path: string): Promise<Contact[]> {
|
export async function listContacts(fetcher: Fetcher, path: string): Promise<Contact[]> {
|
||||||
const blobs = await readObjects(fetcher, path, 'addressbook');
|
const objects = await readObjects(fetcher, path, 'addressbook');
|
||||||
return blobs.flatMap(parseContacts);
|
return objects.flatMap((o) => parseContacts(o.data).map((c) => ({ ...c, href: o.href })));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create one event, in its own file.
|
||||||
|
*
|
||||||
|
* THE FILENAME IS A FRESH UUID, and it is also the UID. Not because a client may assume they match —
|
||||||
|
* nothing here does, and the listing carries the real href precisely because they often do not — but
|
||||||
|
* because a new name has to come from somewhere, and one that cannot collide is the cheapest kind.
|
||||||
|
*
|
||||||
|
* `If-None-Match: *` makes the PUT conditional on the file NOT existing, so a retried request cannot
|
||||||
|
* quietly overwrite an event created by the first attempt.
|
||||||
|
*/
|
||||||
|
export async function createEvent(fetcher: Fetcher, collection: string, event: NewEvent): Promise<{ href: string }> {
|
||||||
|
const uid = crypto.randomUUID();
|
||||||
|
const href = `${collection.replace(/\/$/, '')}/${uid}.ics`;
|
||||||
|
|
||||||
|
const res = await fetcher(href, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'text/calendar; charset=utf-8', 'If-None-Match': '*' },
|
||||||
|
body: buildEvent(uid, event),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`radicale answered ${res.status}`);
|
||||||
|
return { href };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The same contract as `createEvent`, for a card. */
|
||||||
|
export async function createContact(
|
||||||
|
fetcher: Fetcher,
|
||||||
|
collection: string,
|
||||||
|
contact: NewContact,
|
||||||
|
): Promise<{ href: string }> {
|
||||||
|
const uid = crypto.randomUUID();
|
||||||
|
const href = `${collection.replace(/\/$/, '')}/${uid}.vcf`;
|
||||||
|
|
||||||
|
const res = await fetcher(href, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'text/vcard; charset=utf-8', 'If-None-Match': '*' },
|
||||||
|
body: buildContact(uid, contact),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`radicale answered ${res.status}`);
|
||||||
|
return { href };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { buildEvent, fold, parseEvents } from './ical';
|
||||||
|
|
||||||
|
// The two rules here are the ones that are invisible when broken: DTEND is exclusive for an all-day
|
||||||
|
// event, and a content line is folded at 75 OCTETS. Both produce a file that looks fine and renders
|
||||||
|
// wrong somewhere else.
|
||||||
|
|
||||||
|
describe('buildEvent', () => {
|
||||||
|
test('a timed event is written in UTC, with both ends', () => {
|
||||||
|
const ics = buildEvent('u1', {
|
||||||
|
summary: 'Lunch',
|
||||||
|
start: '2026-08-28T13:00:00.000Z',
|
||||||
|
end: '2026-08-28T14:00:00.000Z',
|
||||||
|
allDay: false,
|
||||||
|
});
|
||||||
|
expect(ics).toContain('DTSTART:20260828T130000Z');
|
||||||
|
expect(ics).toContain('DTEND:20260828T140000Z');
|
||||||
|
expect(ics.endsWith('END:VCALENDAR\r\n')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no end means an hour', () => {
|
||||||
|
const ics = buildEvent('u1', { summary: 'Lunch', start: '2026-08-28T13:00:00.000Z', allDay: false });
|
||||||
|
expect(ics).toContain('DTEND:20260828T140000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an all-day event ends the NEXT day, because DTEND is exclusive', () => {
|
||||||
|
const ics = buildEvent('u1', { summary: 'Holiday', start: '2026-08-29', allDay: true });
|
||||||
|
expect(ics).toContain('DTSTART;VALUE=DATE:20260829');
|
||||||
|
expect(ics).toContain('DTEND;VALUE=DATE:20260830');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a multi-day all-day event takes the last day inclusive', () => {
|
||||||
|
const ics = buildEvent('u1', { summary: 'Trip', start: '2026-08-29', end: '2026-08-31', allDay: true });
|
||||||
|
expect(ics).toContain('DTEND;VALUE=DATE:20260901');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a month boundary rolls the month, not just the day', () => {
|
||||||
|
const ics = buildEvent('u1', { summary: 'Last of the month', start: '2026-08-31', allDay: true });
|
||||||
|
expect(ics).toContain('DTEND;VALUE=DATE:20260901');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TEXT separators are escaped, and survive the parser', () => {
|
||||||
|
const summary = 'Comma, semicolon; and a backslash \\ walk in';
|
||||||
|
const ics = buildEvent('u1', { summary, start: '2026-08-28T13:00:00.000Z', allDay: false });
|
||||||
|
expect(ics).toContain(String.raw`SUMMARY:Comma\, semicolon\; and a backslash \\ walk in`);
|
||||||
|
expect(parseEvents(ics)[0]!.summary).toBe(summary);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a long summary folds, and the parser reads it back whole', () => {
|
||||||
|
const summary = 'x'.repeat(200);
|
||||||
|
const ics = buildEvent('u1', { summary, start: '2026-08-28T13:00:00.000Z', allDay: false });
|
||||||
|
expect(ics.split('\r\n').every((l) => Buffer.byteLength(l, 'utf8') <= 75)).toBe(true);
|
||||||
|
expect(parseEvents(ics)[0]!.summary).toBe(summary);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fold', () => {
|
||||||
|
test('a short line is left alone', () => {
|
||||||
|
expect(fold('SUMMARY:short')).toBe('SUMMARY:short');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every produced line fits in 75 octets, continuations included', () => {
|
||||||
|
const folded = fold(`SUMMARY:${'a'.repeat(400)}`);
|
||||||
|
expect(folded.split('\r\n').every((l) => Buffer.byteLength(l, 'utf8') <= 75)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('continuation lines start with exactly one space', () => {
|
||||||
|
const [, ...rest] = fold(`SUMMARY:${'a'.repeat(200)}`).split('\r\n');
|
||||||
|
expect(rest.every((l) => l.startsWith(' ') && !l.startsWith(' a'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a multi-byte character is never cut in half', () => {
|
||||||
|
// 'é' is two octets, so a boundary lands mid-character unless the fold counts encoded length.
|
||||||
|
const folded = fold(`SUMMARY:${'é'.repeat(100)}`);
|
||||||
|
expect(folded).not.toContain('�');
|
||||||
|
expect(folded.split('\r\n').every((l) => Buffer.byteLength(l, 'utf8') <= 75)).toBe(true);
|
||||||
|
expect(folded.split('\r\n').map((l, i) => (i ? l.slice(1) : l.slice(8))).join('')).toBe('é'.repeat(100));
|
||||||
|
});
|
||||||
|
});
|
||||||
+145
-2
@@ -6,8 +6,12 @@
|
|||||||
// owns correctness. `rrule` is passed through raw so the UI can say "repeats" without either of us
|
// owns correctness. `rrule` is passed through raw so the UI can say "repeats" without either of us
|
||||||
// pretending to know when.
|
// pretending to know when.
|
||||||
//
|
//
|
||||||
// Everything here is display-only. The authoritative copy of an event is the .ics on disk that DAV
|
// Reading is display-only: the authoritative copy of an event is the .ics on disk that DAV clients read
|
||||||
// clients read and write; nothing in this file ever writes one back.
|
// and write, and an edit patches THAT text rather than rebuilding it from what was parsed here.
|
||||||
|
//
|
||||||
|
// `buildEvent` is the one exception, and only because a NEW event has no original to preserve. It is
|
||||||
|
// deliberately the minimum a VEVENT can legally be — no RRULE, no alarms, no attendees. Officer's screens
|
||||||
|
// cannot express those, and writing a field the UI cannot show is how an editor starts lying.
|
||||||
|
|
||||||
/** Unfold RFC 5545 continuation lines (a leading space or tab continues the previous line). */
|
/** Unfold RFC 5545 continuation lines (a leading space or tab continues the previous line). */
|
||||||
function unfold(text: string): string[] {
|
function unfold(text: string): string[] {
|
||||||
@@ -63,6 +67,8 @@ export function parseDate(prop: Prop): { iso: string; allDay: boolean } | null {
|
|||||||
|
|
||||||
export type CalendarEvent = {
|
export type CalendarEvent = {
|
||||||
uid: string;
|
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;
|
summary: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
location?: string;
|
location?: string;
|
||||||
@@ -140,6 +146,8 @@ export function parseEvents(ics: string): CalendarEvent[] {
|
|||||||
|
|
||||||
export type Contact = {
|
export type Contact = {
|
||||||
uid: string;
|
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;
|
fullName: string;
|
||||||
emails: string[];
|
emails: string[];
|
||||||
phones: string[];
|
phones: string[];
|
||||||
@@ -200,3 +208,138 @@ export function parseContacts(vcf: string): Contact[] {
|
|||||||
}
|
}
|
||||||
return contacts;
|
return contacts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Writing ────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const escapeText = (value: string) =>
|
||||||
|
value
|
||||||
|
.replace(/\\/g, '\\\\')
|
||||||
|
.replace(/\r?\n/g, '\\n')
|
||||||
|
.replace(/([,;])/g, '\\$1');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC 5545 §3.1: no content line over 75 OCTETS, continued by a line starting with one space.
|
||||||
|
*
|
||||||
|
* Octets, not characters — so this measures encoded length and breaks between code points. A pasted
|
||||||
|
* title with an accent in it is exactly where a naive `slice(0, 75)` splits a multi-byte character in
|
||||||
|
* half and produces a file some clients refuse and others render as mojibake.
|
||||||
|
*/
|
||||||
|
export 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 const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
|
||||||
|
/** `20260827T140000Z`. Always UTC, so the file carries no timezone this codebase would have to resolve. */
|
||||||
|
export const utcStamp = (date: Date) =>
|
||||||
|
`${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}T` +
|
||||||
|
`${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}Z`;
|
||||||
|
|
||||||
|
/** `2026-08-27` → `20260827`. */
|
||||||
|
export const dateStamp = (iso: string) => iso.slice(0, 10).replace(/-/g, '');
|
||||||
|
|
||||||
|
export const addDay = (iso: string) => {
|
||||||
|
const d = new Date(`${iso.slice(0, 10)}T00:00:00Z`);
|
||||||
|
d.setUTCDate(d.getUTCDate() + 1);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NewEvent = {
|
||||||
|
summary: string;
|
||||||
|
location?: string;
|
||||||
|
/** An ISO instant for a timed event; `YYYY-MM-DD` when `allDay`. */
|
||||||
|
start: string;
|
||||||
|
/** Same shape as `start`. For an all-day event this is the LAST day, inclusive, as a person means it. */
|
||||||
|
end?: string;
|
||||||
|
allDay: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A minimal VEVENT, wrapped in its own VCALENDAR — one event per file, which is what every DAV client
|
||||||
|
* writes and what makes an href addressable.
|
||||||
|
*
|
||||||
|
* TIMES ARE WRITTEN IN UTC. The alternative is `DTSTART;TZID=Europe/Lisbon` plus a VTIMEZONE block
|
||||||
|
* defining that zone's rules, and getting a VTIMEZONE subtly wrong moves an event by an hour twice a
|
||||||
|
* year. UTC needs no definition and every client renders it in local time.
|
||||||
|
*
|
||||||
|
* ALL-DAY IS DIFFERENT, and its trap is DTEND. RFC 5545 makes the end date EXCLUSIVE, so a one-day
|
||||||
|
* event on the 27th ends on the 28th. Writing the 27th produces a zero-length event that some clients
|
||||||
|
* hide entirely; callers pass the last day they mean and this adds the day.
|
||||||
|
*/
|
||||||
|
export function buildEvent(uid: string, event: NewEvent): string {
|
||||||
|
const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Officer//caldav//EN', 'BEGIN:VEVENT', `UID:${uid}`];
|
||||||
|
lines.push(`DTSTAMP:${utcStamp(new Date())}`);
|
||||||
|
|
||||||
|
if (event.allDay) {
|
||||||
|
const last = event.end?.slice(0, 10) || event.start.slice(0, 10);
|
||||||
|
lines.push(`DTSTART;VALUE=DATE:${dateStamp(event.start)}`);
|
||||||
|
lines.push(`DTEND;VALUE=DATE:${dateStamp(addDay(last))}`);
|
||||||
|
} else {
|
||||||
|
const start = new Date(event.start);
|
||||||
|
// An hour is the default a calendar has taught everyone to expect from an event with no end.
|
||||||
|
const end = event.end ? new Date(event.end) : new Date(start.getTime() + 60 * 60 * 1000);
|
||||||
|
lines.push(`DTSTART:${utcStamp(start)}`);
|
||||||
|
lines.push(`DTEND:${utcStamp(end)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(`SUMMARY:${escapeText(event.summary)}`);
|
||||||
|
if (event.location) lines.push(`LOCATION:${escapeText(event.location)}`);
|
||||||
|
lines.push('END:VEVENT', 'END:VCALENDAR');
|
||||||
|
|
||||||
|
return lines.map(fold).join('\r\n') + '\r\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The inverse of `addDay`. Turns a file's EXCLUSIVE all-day DTEND back into the last day a person means. */
|
||||||
|
export const subDay = (iso: string) => {
|
||||||
|
const d = new Date(`${iso.slice(0, 10)}T00:00:00Z`);
|
||||||
|
d.setUTCDate(d.getUTCDate() - 1);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NewContact = {
|
||||||
|
fullName: string;
|
||||||
|
emails?: string[];
|
||||||
|
phones?: string[];
|
||||||
|
org?: string;
|
||||||
|
title?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A minimal vCard 3.0 — the version CardDAV clients, iOS included, actually write.
|
||||||
|
*
|
||||||
|
* `N` is REQUIRED in 3.0 and is `Family;Given;Middle;Prefix;Suffix`. Nobody supplies it, so it is split
|
||||||
|
* out of the full name: last word is the family name, everything before it is given. That is wrong for
|
||||||
|
* some names in the world and right for most, and `FN` — which is what a phone displays — is whatever
|
||||||
|
* was actually typed either way.
|
||||||
|
*/
|
||||||
|
export function buildContact(uid: string, contact: NewContact): string {
|
||||||
|
const [family, ...given] = contact.fullName.trim().split(/\s+/).reverse();
|
||||||
|
const lines = [
|
||||||
|
'BEGIN:VCARD',
|
||||||
|
'VERSION:3.0',
|
||||||
|
`UID:${uid}`,
|
||||||
|
`FN:${escapeText(contact.fullName)}`,
|
||||||
|
`N:${escapeText(family ?? '')};${escapeText(given.reverse().join(' '))};;;`,
|
||||||
|
];
|
||||||
|
for (const email of contact.emails ?? []) lines.push(`EMAIL;TYPE=INTERNET:${escapeText(email)}`);
|
||||||
|
for (const phone of contact.phones ?? []) lines.push(`TEL;TYPE=CELL:${escapeText(phone)}`);
|
||||||
|
if (contact.org) lines.push(`ORG:${escapeText(contact.org)}`);
|
||||||
|
if (contact.title) lines.push(`TITLE:${escapeText(contact.title)}`);
|
||||||
|
lines.push('END:VCARD');
|
||||||
|
return lines.map(fold).join('\r\n') + '\r\n';
|
||||||
|
}
|
||||||
|
|||||||
+177
-2
@@ -1,7 +1,8 @@
|
|||||||
import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
|
import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
|
||||||
import { createSidecarConnector } from '@@/sidecar/connect';
|
import { createSidecarConnector } from '@@/sidecar/connect';
|
||||||
import { startRadicale, davPaths } from './radicale';
|
import { startRadicale, davPaths } from './radicale';
|
||||||
import { listCollections, listEvents, listContacts } from './collections';
|
import { listCollections, listEvents, listContacts, createEvent, createContact } from './collections';
|
||||||
|
import { patchFor, summarise } from './patch';
|
||||||
import { DATA_PATH } from '@@/data-path';
|
import { DATA_PATH } from '@@/data-path';
|
||||||
import { API_URL } from '@@/officer-url.mjs';
|
import { API_URL } from '@@/officer-url.mjs';
|
||||||
|
|
||||||
@@ -16,7 +17,10 @@ import { API_URL } from '@@/officer-url.mjs';
|
|||||||
// Reached from the platform's TOP-LEVEL /dav mount, which authenticates a DAV
|
// Reached from the platform's TOP-LEVEL /dav mount, which authenticates a DAV
|
||||||
// app password over HTTP Basic. This is the door phones use.
|
// app password over HTTP Basic. This is the door phones use.
|
||||||
// GET /_health is Radicale up, and does it answer.
|
// GET /_health is Radicale up, and does it answer.
|
||||||
// GET /_officer/* the JSON door for Officer's own web UI (see collections.ts).
|
// * /_officer/* the JSON door. Reads, and field-level writes: POST /_officer/event and
|
||||||
|
// /_officer/contact create, PATCH /_officer/item changes fields, DELETE removes.
|
||||||
|
// Built for the web UI; the agent skill speaks it too, which is why the writes
|
||||||
|
// take FIELDS rather than iCalendar text (see patch.ts).
|
||||||
//
|
//
|
||||||
// WHY TWO DOORS. A browser should not speak DAV — rendering a month view out of multistatus XML is a
|
// WHY TWO DOORS. A browser should not speak DAV — rendering a month view out of multistatus XML is a
|
||||||
// lot of machinery, and it would make the web UI as fragile as the protocol. The collections are on
|
// lot of machinery, and it would make the web UI as fragile as the protocol. The collections are on
|
||||||
@@ -185,6 +189,161 @@ const server = Bun.serve({
|
|||||||
return Response.json({ collections: await listCollections(fetcher, String(userId)) });
|
return Response.json({ collections: await listCollections(fetcher, String(userId)) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
One item's ORIGINAL text, for an editor that must not lose what it does not understand.
|
||||||
|
|
||||||
|
Rebuilding an .ics from the parsed fields would silently drop alarms, attendees, recurrence rules
|
||||||
|
and every X- property the file came with — an edit that quietly deletes half an event is worse
|
||||||
|
than no editing at all. So the editor reads the real thing, changes the lines it owns, and puts
|
||||||
|
the whole file back.
|
||||||
|
*/
|
||||||
|
if (url.pathname === '/_officer/item' && req.method === 'GET') {
|
||||||
|
const href = url.searchParams.get('href') ?? '';
|
||||||
|
if (!href.startsWith(`/dav/${userId}/`)) {
|
||||||
|
return Response.json({ error: 'href is required and must be your own' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const res = await fetcher(href, { method: 'GET' });
|
||||||
|
if (!res.ok) return Response.json({ error: `radicale answered ${res.status}` }, { status: 502 });
|
||||||
|
return Response.json({ href, data: await res.text() });
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Change some FIELDS of an item, rather than replacing its text.
|
||||||
|
|
||||||
|
This is the route anything sane should use. PUT is still here because the raw text is sometimes
|
||||||
|
genuinely what a caller has, but a caller that only wants to move an event or fix a phone number
|
||||||
|
should not have to learn iCalendar escaping to do it — and every caller that did learn it learned
|
||||||
|
it slightly differently. The read, the patch and the write happen here, in one round trip, over
|
||||||
|
the ORIGINAL text, so nothing the patcher does not recognise is disturbed.
|
||||||
|
*/
|
||||||
|
if (url.pathname === '/_officer/item' && req.method === 'PATCH') {
|
||||||
|
const body = (await req.json().catch(() => ({}))) as { href?: string; [field: string]: unknown };
|
||||||
|
const href = String(body.href ?? '');
|
||||||
|
if (!href.startsWith(`/dav/${userId}/`)) {
|
||||||
|
return Response.json({ error: 'href is required and must be your own' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const read = await fetcher(href, { method: 'GET' });
|
||||||
|
if (!read.ok) return Response.json({ error: `radicale answered ${read.status}` }, { status: 502 });
|
||||||
|
|
||||||
|
let patched: string;
|
||||||
|
try {
|
||||||
|
const { href: _ignored, ...fields } = body;
|
||||||
|
patched = patchFor(href, fields, await read.text());
|
||||||
|
} catch (err) {
|
||||||
|
return Response.json({ error: String(err) }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCard = href.endsWith('.vcf');
|
||||||
|
const written = await fetcher(href, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': isCard ? 'text/vcard; charset=utf-8' : 'text/calendar; charset=utf-8' },
|
||||||
|
body: patched,
|
||||||
|
});
|
||||||
|
if (!written.ok) return Response.json({ error: `radicale answered ${written.status}` }, { status: 502 });
|
||||||
|
// The item as it now reads, so a caller can confirm what it did without a second call.
|
||||||
|
return Response.json({ ok: true, item: summarise(href, patched) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/_officer/item' && (req.method === 'PUT' || req.method === 'DELETE')) {
|
||||||
|
const body = (await req.json().catch(() => ({}))) as { href?: string; data?: string };
|
||||||
|
const href = String(body.href ?? '');
|
||||||
|
if (!href.startsWith(`/dav/${userId}/`)) {
|
||||||
|
return Response.json({ error: 'href is required and must be your own' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'DELETE') {
|
||||||
|
const res = await fetcher(href, { method: 'DELETE' });
|
||||||
|
// 404 is success from the caller's point of view: the thing they asked to be gone is gone.
|
||||||
|
if (!res.ok && res.status !== 404) {
|
||||||
|
return Response.json({ error: `radicale answered ${res.status}` }, { status: 502 });
|
||||||
|
}
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.data) return Response.json({ error: 'data is required' }, { status: 400 });
|
||||||
|
const isCard = href.endsWith('.vcf');
|
||||||
|
const res = await fetcher(href, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': isCard ? 'text/vcard; charset=utf-8' : 'text/calendar; charset=utf-8',
|
||||||
|
},
|
||||||
|
body: body.data,
|
||||||
|
});
|
||||||
|
if (!res.ok) return Response.json({ error: `radicale answered ${res.status}` }, { status: 502 });
|
||||||
|
return Response.json({ ok: true, status: res.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Create an event. POST rather than PUT, because the caller does not choose the address — the
|
||||||
|
sidecar mints the filename, so this is "add one to that collection", not "put this here".
|
||||||
|
|
||||||
|
The BODY carries the collection, which is why this sits above the guard below: that one reads a
|
||||||
|
collection out of the query string, and a create has none there.
|
||||||
|
*/
|
||||||
|
if (url.pathname === '/_officer/event' && req.method === 'POST') {
|
||||||
|
const body = (await req.json().catch(() => ({}))) as {
|
||||||
|
collection?: string;
|
||||||
|
summary?: string;
|
||||||
|
location?: string;
|
||||||
|
start?: string;
|
||||||
|
end?: string;
|
||||||
|
allDay?: boolean;
|
||||||
|
};
|
||||||
|
const collection = String(body.collection ?? '');
|
||||||
|
if (!collection.startsWith(`/dav/${userId}/`)) {
|
||||||
|
return Response.json({ error: 'collection is required and must be your own' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const summary = (body.summary ?? '').trim();
|
||||||
|
if (!summary) return Response.json({ error: 'summary is required' }, { status: 400 });
|
||||||
|
if (!body.start) return Response.json({ error: 'start is required' }, { status: 400 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await createEvent(fetcher, collection, {
|
||||||
|
summary,
|
||||||
|
location: (body.location ?? '').trim() || undefined,
|
||||||
|
start: body.start,
|
||||||
|
end: body.end,
|
||||||
|
allDay: Boolean(body.allDay),
|
||||||
|
});
|
||||||
|
return Response.json(created, { status: 201 });
|
||||||
|
} catch (err) {
|
||||||
|
return Response.json({ error: String(err) }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Same contract as the event route above, for an address book. */
|
||||||
|
if (url.pathname === '/_officer/contact' && req.method === 'POST') {
|
||||||
|
const body = (await req.json().catch(() => ({}))) as {
|
||||||
|
collection?: string;
|
||||||
|
fullName?: string;
|
||||||
|
emails?: string[];
|
||||||
|
phones?: string[];
|
||||||
|
org?: string;
|
||||||
|
title?: string;
|
||||||
|
};
|
||||||
|
const collection = String(body.collection ?? '');
|
||||||
|
if (!collection.startsWith(`/dav/${userId}/`)) {
|
||||||
|
return Response.json({ error: 'collection is required and must be your own' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const fullName = (body.fullName ?? '').trim();
|
||||||
|
// FN is what a phone displays and vCard requires it. A card without one shows as a blank row.
|
||||||
|
if (!fullName) return Response.json({ error: 'fullName is required' }, { status: 400 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await createContact(fetcher, collection, {
|
||||||
|
fullName,
|
||||||
|
emails: (body.emails ?? []).filter(Boolean),
|
||||||
|
phones: (body.phones ?? []).filter(Boolean),
|
||||||
|
org: (body.org ?? '').trim() || undefined,
|
||||||
|
title: (body.title ?? '').trim() || undefined,
|
||||||
|
});
|
||||||
|
return Response.json(created, { status: 201 });
|
||||||
|
} catch (err) {
|
||||||
|
return Response.json({ error: String(err) }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const path = url.searchParams.get('collection');
|
const path = url.searchParams.get('collection');
|
||||||
// Confine reads to this user's own tree. The header already scopes Radicale, but a path from a
|
// Confine reads to this user's own tree. The header already scopes Radicale, but a path from a
|
||||||
// query string is attacker-shaped input and should not be handed on unchecked.
|
// query string is attacker-shaped input and should not be handed on unchecked.
|
||||||
@@ -198,6 +357,22 @@ const server = Bun.serve({
|
|||||||
if (url.pathname === '/_officer/contacts') {
|
if (url.pathname === '/_officer/contacts') {
|
||||||
return Response.json({ contacts: await listContacts(fetcher, path) });
|
return Response.json({ contacts: await listContacts(fetcher, path) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
On the writing routes above.
|
||||||
|
|
||||||
|
── Why the href is required and never derived ──
|
||||||
|
|
||||||
|
A `.ics` filename only conventionally matches the UID. Radicale stores whatever the client that
|
||||||
|
wrote it chose, so `<uid>.ics` finds everything Officer wrote and misses everything a phone did.
|
||||||
|
The listing hands back the real href; these take it back unchanged.
|
||||||
|
|
||||||
|
── Why it is checked against this user's tree ──
|
||||||
|
|
||||||
|
`X-Remote-User` already scopes Radicale, and an href arriving in a request body is
|
||||||
|
attacker-shaped input either way. Two gates rather than one, for the same reason the reads above
|
||||||
|
check `collection`.
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response('not found', { status: 404 });
|
return new Response('not found', { status: 404 });
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { patchContact, patchEvent, setProperty, setPropertyList } from './patch';
|
||||||
|
import { parseContacts, parseEvents } from './ical';
|
||||||
|
|
||||||
|
// What these protect: an edit must change what was asked and NOTHING else. Every failure mode here is
|
||||||
|
// silent — the write succeeds, the list still looks right, and something the UI cannot show is gone.
|
||||||
|
|
||||||
|
const EVENT = [
|
||||||
|
'BEGIN:VCALENDAR',
|
||||||
|
'VERSION:2.0',
|
||||||
|
'BEGIN:VEVENT',
|
||||||
|
'UID:abc',
|
||||||
|
'DTSTAMP:20260827T100000Z',
|
||||||
|
'DTSTART;TZID=Europe/Lisbon:20260828T130000',
|
||||||
|
'DTEND;TZID=Europe/Lisbon:20260828T140000',
|
||||||
|
'SUMMARY:Before',
|
||||||
|
'RRULE:FREQ=WEEKLY',
|
||||||
|
'ATTENDEE;CN=Someone:mailto:someone@example.com',
|
||||||
|
'X-APPLE-TRAVEL-ADVISORY-BEHAVIOR:AUTOMATIC',
|
||||||
|
'BEGIN:VALARM',
|
||||||
|
'ACTION:DISPLAY',
|
||||||
|
'TRIGGER:-PT15M',
|
||||||
|
'END:VALARM',
|
||||||
|
'END:VEVENT',
|
||||||
|
'END:VCALENDAR',
|
||||||
|
].join('\r\n');
|
||||||
|
|
||||||
|
const ALL_DAY = [
|
||||||
|
'BEGIN:VCALENDAR',
|
||||||
|
'VERSION:2.0',
|
||||||
|
'BEGIN:VEVENT',
|
||||||
|
'UID:def',
|
||||||
|
'DTSTAMP:20260827T100000Z',
|
||||||
|
'DTSTART;VALUE=DATE:20260829',
|
||||||
|
'DTEND;VALUE=DATE:20260830',
|
||||||
|
'SUMMARY:Holiday',
|
||||||
|
'END:VEVENT',
|
||||||
|
'END:VCALENDAR',
|
||||||
|
].join('\r\n');
|
||||||
|
|
||||||
|
const CARD = [
|
||||||
|
'BEGIN:VCARD',
|
||||||
|
'VERSION:3.0',
|
||||||
|
'UID:ghi',
|
||||||
|
'FN:Peter Griffin',
|
||||||
|
'N:Griffin;Peter;;;',
|
||||||
|
'EMAIL;TYPE=INTERNET:peter@quahog.example',
|
||||||
|
'EMAIL;TYPE=WORK:peter@brewery.example',
|
||||||
|
'TEL;TYPE=CELL:+351912345678',
|
||||||
|
'X-SOMETHING-ELSE:kept',
|
||||||
|
'END:VCARD',
|
||||||
|
].join('\r\n');
|
||||||
|
|
||||||
|
describe('setProperty', () => {
|
||||||
|
test('changes only the line it owns, and keeps what it does not understand', () => {
|
||||||
|
const out = setProperty(EVENT, 'SUMMARY', 'After');
|
||||||
|
expect(out).toContain('SUMMARY:After');
|
||||||
|
for (const kept of ['RRULE:FREQ=WEEKLY', 'ATTENDEE;CN=Someone', 'X-APPLE-TRAVEL', 'BEGIN:VALARM'])
|
||||||
|
expect(out).toContain(kept);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves parameters by default, and replaces them when told', () => {
|
||||||
|
expect(setProperty(EVENT, 'DTSTART', '20260901T090000')).toContain('DTSTART;TZID=Europe/Lisbon:20260901T090000');
|
||||||
|
expect(setProperty(EVENT, 'DTSTART', '20260901T090000Z', '')).toContain('DTSTART:20260901T090000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('appends after UID when absent, so it lands in the right component', () => {
|
||||||
|
const lines = setProperty(EVENT, 'LOCATION', 'Ramiro').split('\r\n');
|
||||||
|
expect(lines[lines.indexOf('UID:abc') + 1]).toBe('LOCATION:Ramiro');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('null removes it', () => {
|
||||||
|
expect(setProperty(EVENT, 'RRULE', null)).not.toContain('RRULE');
|
||||||
|
});
|
||||||
|
|
||||||
|
// The regression that shipped once: replacing only the first line of a FOLDED value left the rest
|
||||||
|
// behind, and each edit inherited the tail of the last.
|
||||||
|
test('replaces the whole of a folded value', () => {
|
||||||
|
const folded = EVENT.replace('SUMMARY:Before', 'SUMMARY:Before and a great deal more text\r\n folded onto line two');
|
||||||
|
const out = setProperty(folded, 'SUMMARY', 'After');
|
||||||
|
expect(parseEvents(out)[0]!.summary).toBe('After');
|
||||||
|
expect(out).not.toContain('folded onto line two');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a long value is folded to 75 octets and reads back whole', () => {
|
||||||
|
const long = 'y'.repeat(300);
|
||||||
|
const out = setProperty(EVENT, 'SUMMARY', long);
|
||||||
|
expect(out.split('\r\n').every((l) => Buffer.byteLength(l, 'utf8') <= 75)).toBe(true);
|
||||||
|
expect(parseEvents(out)[0]!.summary).toBe(long);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('repeated edits do not accumulate', () => {
|
||||||
|
let out = EVENT;
|
||||||
|
for (const v of ['one'.repeat(40), 'two'.repeat(40), 'three']) out = setProperty(out, 'SUMMARY', v);
|
||||||
|
expect(parseEvents(out)[0]!.summary).toBe('three');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setPropertyList', () => {
|
||||||
|
test('replaces the whole list and carries parameters over by position', () => {
|
||||||
|
const out = setPropertyList(CARD, 'EMAIL', ['new@example.com'], ';TYPE=INTERNET');
|
||||||
|
expect(out).toContain('EMAIL;TYPE=INTERNET:new@example.com');
|
||||||
|
expect(out).not.toContain('brewery');
|
||||||
|
expect(parseContacts(out)[0]!.emails).toEqual(['new@example.com']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extra values get the default parameters', () => {
|
||||||
|
const out = setPropertyList(CARD, 'TEL', ['+351911111111', '+351922222222'], ';TYPE=CELL');
|
||||||
|
expect(out).toContain('TEL;TYPE=CELL:+351911111111');
|
||||||
|
expect(out).toContain('TEL;TYPE=CELL:+351922222222');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an empty list removes them all, and leaves the rest of the card', () => {
|
||||||
|
const out = setPropertyList(CARD, 'EMAIL', [], ';TYPE=INTERNET');
|
||||||
|
expect(out).not.toContain('EMAIL');
|
||||||
|
expect(out).toContain('X-SOMETHING-ELSE:kept');
|
||||||
|
expect(out).toContain('TEL;TYPE=CELL:+351912345678');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('patchEvent', () => {
|
||||||
|
test('a text-only patch does not touch the times', () => {
|
||||||
|
const out = patchEvent(EVENT, { summary: 'After' });
|
||||||
|
expect(out).toContain('DTSTART;TZID=Europe/Lisbon:20260828T130000');
|
||||||
|
expect(out).not.toContain('SEQUENCE');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('moving a timed event writes UTC and DROPS the stale TZID', () => {
|
||||||
|
const out = patchEvent(EVENT, { start: '2026-09-01T08:00:00Z', end: '2026-09-01T09:00:00Z' });
|
||||||
|
expect(out).toContain('DTSTART:20260901T080000Z');
|
||||||
|
expect(out).toContain('DTEND:20260901T090000Z');
|
||||||
|
expect(out).not.toContain('TZID');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('moving bumps SEQUENCE, so a phone reads an update rather than a duplicate', () => {
|
||||||
|
const once = patchEvent(EVENT, { start: '2026-09-01T08:00:00Z' });
|
||||||
|
expect(once).toContain('SEQUENCE:1');
|
||||||
|
expect(patchEvent(once, { start: '2026-09-02T08:00:00Z' })).toContain('SEQUENCE:2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('changing only the end leaves the start exactly as it was', () => {
|
||||||
|
const out = patchEvent(ALL_DAY, { end: '2026-08-31' });
|
||||||
|
expect(out).toContain('DTSTART;VALUE=DATE:20260829');
|
||||||
|
expect(out).toContain('DTEND;VALUE=DATE:20260901'); // inclusive in, exclusive out
|
||||||
|
});
|
||||||
|
|
||||||
|
// The one that would silently move a holiday: the file's DTEND is exclusive, so editing anything else
|
||||||
|
// about the event must not re-add a day to it.
|
||||||
|
test('editing an all-day event does not drift its end', () => {
|
||||||
|
const out = patchEvent(patchEvent(ALL_DAY, { summary: 'A' }), { start: '2026-08-29' });
|
||||||
|
expect(out).toContain('DTEND;VALUE=DATE:20260830');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('timed becomes all-day, both ends rewritten', () => {
|
||||||
|
const out = patchEvent(EVENT, { allDay: true, start: '2026-08-28' });
|
||||||
|
expect(out).toContain('DTSTART;VALUE=DATE:20260828');
|
||||||
|
expect(out).toContain('DTEND;VALUE=DATE:20260829');
|
||||||
|
expect(parseEvents(out)[0]!.allDay).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('null removes a field, an empty string does not', () => {
|
||||||
|
const withPlace = patchEvent(EVENT, { location: 'Ramiro' });
|
||||||
|
expect(patchEvent(withPlace, { location: null })).not.toContain('LOCATION');
|
||||||
|
expect(patchEvent(withPlace, { location: '' })).toContain('LOCATION:');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('separators are escaped and survive the round trip', () => {
|
||||||
|
const out = patchEvent(EVENT, { summary: 'a, b; c' });
|
||||||
|
expect(parseEvents(out)[0]!.summary).toBe('a, b; c');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('patchContact', () => {
|
||||||
|
test('renaming updates N as well as FN', () => {
|
||||||
|
const out = patchContact(CARD, { fullName: 'Lois Griffin' });
|
||||||
|
expect(out).toContain('FN:Lois Griffin');
|
||||||
|
expect(out).toContain('N:Griffin;Lois;;;');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a patch that names no list leaves both lists alone', () => {
|
||||||
|
const out = patchContact(CARD, { org: 'Pawtucket' });
|
||||||
|
expect(parseContacts(out)[0]!.emails).toEqual(['peter@quahog.example', 'peter@brewery.example']);
|
||||||
|
expect(out).toContain('ORG:Pawtucket');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('everything unknown survives', () => {
|
||||||
|
expect(patchContact(CARD, { fullName: 'Peter L. Griffin' })).toContain('X-SOMETHING-ELSE:kept');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('REV moves, because some clients break a tie with it', () => {
|
||||||
|
expect(patchContact(CARD, { title: 'Safety Inspector' })).toContain('REV:');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The bug that live testing caught, and the reason `within` exists. Radicale stores a VTIMEZONE beside
|
||||||
|
// any event carrying a TZID, and that block has DTSTART lines of its own — the summer-time rule. Taking
|
||||||
|
// the first DTSTART in the file rewrote the timezone and left the event where it was.
|
||||||
|
const WITH_VTIMEZONE = [
|
||||||
|
'BEGIN:VCALENDAR',
|
||||||
|
'VERSION:2.0',
|
||||||
|
'BEGIN:VTIMEZONE',
|
||||||
|
'TZID:Europe/Lisbon',
|
||||||
|
'BEGIN:DAYLIGHT',
|
||||||
|
'DTSTART:20000326T010000',
|
||||||
|
'TZOFFSETFROM:+0000',
|
||||||
|
'RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3',
|
||||||
|
'END:DAYLIGHT',
|
||||||
|
'END:VTIMEZONE',
|
||||||
|
'BEGIN:VEVENT',
|
||||||
|
'UID:jkl',
|
||||||
|
'DTSTAMP:20260827T100000Z',
|
||||||
|
'DTSTART;TZID=Europe/Lisbon:20260828T130000',
|
||||||
|
'DTEND;TZID=Europe/Lisbon:20260828T140000',
|
||||||
|
'SUMMARY:Probe',
|
||||||
|
'END:VEVENT',
|
||||||
|
'END:VCALENDAR',
|
||||||
|
].join('\r\n');
|
||||||
|
|
||||||
|
describe('a file with a VTIMEZONE', () => {
|
||||||
|
test('moving the event does not touch the timezone rule', () => {
|
||||||
|
const out = patchEvent(WITH_VTIMEZONE, { start: '2026-09-01T08:00:00Z', end: '2026-09-01T09:00:00Z' });
|
||||||
|
expect(out).toContain('DTSTART:20000326T010000'); // the summer-time rule, untouched
|
||||||
|
expect(out).toContain('DTSTART:20260901T080000Z'); // the event, moved
|
||||||
|
expect(out).not.toContain('DTSTART;TZID=Europe/Lisbon:20260828T130000');
|
||||||
|
expect(parseEvents(out)[0]!.start).toBe('2026-09-01T08:00:00Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the VTIMEZONE block survives intact', () => {
|
||||||
|
const out = patchEvent(WITH_VTIMEZONE, { summary: 'Renamed' });
|
||||||
|
for (const kept of ['BEGIN:VTIMEZONE', 'TZID:Europe/Lisbon', 'BEGIN:DAYLIGHT', 'TZOFFSETFROM:+0000'])
|
||||||
|
expect(out).toContain(kept);
|
||||||
|
expect(parseEvents(out)[0]!.summary).toBe('Renamed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a summary is not appended into the wrong component', () => {
|
||||||
|
const out = patchEvent(WITH_VTIMEZONE, { location: 'Ramiro' });
|
||||||
|
const lines = out.split('\r\n');
|
||||||
|
expect(lines.indexOf('LOCATION:Ramiro')).toBeGreaterThan(lines.indexOf('BEGIN:VEVENT'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import {
|
||||||
|
addDay,
|
||||||
|
dateStamp,
|
||||||
|
escapeText,
|
||||||
|
fold,
|
||||||
|
parseContacts,
|
||||||
|
parseEvents,
|
||||||
|
subDay,
|
||||||
|
utcStamp,
|
||||||
|
type CalendarEvent,
|
||||||
|
} from './ical';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changing one field of an event or a card, without losing what this codebase does not understand.
|
||||||
|
*
|
||||||
|
* ── Read, patch, put — never rebuild ──
|
||||||
|
*
|
||||||
|
* A stored object holds far more than any of our screens show: alarms, attendees, recurrence rules,
|
||||||
|
* categories, every `X-` property the client that wrote it invented. Rebuilding it from the fields we
|
||||||
|
* parsed would silently drop all of that, and an edit that quietly deletes half an event is worse than
|
||||||
|
* no editing at all. So every function here takes the ORIGINAL text, replaces only the lines it owns,
|
||||||
|
* and hands the whole thing back.
|
||||||
|
*
|
||||||
|
* ── Why this is on the server ──
|
||||||
|
*
|
||||||
|
* It lived in the browser first, next to the form, and the folding rules had to be duplicated to get it
|
||||||
|
* there. Two copies of "escape a TEXT value and fold at 75 octets" is two places to be subtly wrong, and
|
||||||
|
* the agent skill needed the same logic a THIRD time in bash. One implementation, one round trip, three
|
||||||
|
* callers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace a property's line, or append it when the file has none. `null` removes it.
|
||||||
|
*
|
||||||
|
* A property is not always one line. RFC 5545 folds anything over 75 octets onto continuation lines
|
||||||
|
* starting 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.
|
||||||
|
*
|
||||||
|
* `params` REPLACES the property's parameters when given, and preserves them when omitted. Both matter:
|
||||||
|
* `DTSTART;TZID=Europe/Lisbon` must not become a bare `DTSTART` when its value changes, and it must not
|
||||||
|
* KEEP that TZID when the new value is a UTC instant ending in Z — a timezone and a Z together are a
|
||||||
|
* contradiction some clients resolve by moving the event.
|
||||||
|
*/
|
||||||
|
export function setProperty(raw: string, prop: string, value: string | null, params?: string): 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');
|
||||||
|
|
||||||
|
if (at !== -1) {
|
||||||
|
const existing = /^[A-Z-]+(;[^:]*)?:/.exec(lines[at]!)?.[1] ?? '';
|
||||||
|
return [...lines.slice(0, at), fold(`${prop}${params ?? existing}:${value}`), ...lines.slice(after)].join('\r\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Appended immediately after UID, so it lands in the right block of a file holding 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}${params ?? ''}:${value}`), ...lines.slice(insertAt)].join('\r\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace every occurrence of a repeatable property — a card's EMAILs and TELs.
|
||||||
|
*
|
||||||
|
* Parameters are carried over BY POSITION: the first new phone keeps whatever the first old one had, so
|
||||||
|
* replacing a number does not quietly turn a mobile into an unlabelled one. Extra values get the default.
|
||||||
|
*/
|
||||||
|
export function setPropertyList(raw: string, prop: string, values: string[], fallbackParams: string): string {
|
||||||
|
const lines = raw.split(/\r?\n/);
|
||||||
|
const kept: string[] = [];
|
||||||
|
const oldParams: string[] = [];
|
||||||
|
let insertAt = -1;
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i]!;
|
||||||
|
if (line.startsWith(`${prop}:`) || line.startsWith(`${prop};`)) {
|
||||||
|
if (insertAt === -1) insertAt = kept.length;
|
||||||
|
oldParams.push(/^[A-Z-]+(;[^:]*)?:/.exec(line)?.[1] ?? '');
|
||||||
|
while (i + 1 < lines.length && /^[ \t]/.test(lines[i + 1]!)) i++; // its continuation lines go too
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
kept.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (insertAt === -1) {
|
||||||
|
const uidAt = kept.findIndex((l) => l.startsWith('UID:'));
|
||||||
|
insertAt = uidAt === -1 ? Math.max(0, kept.length - 1) : uidAt + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const written = values.map((v, i) => fold(`${prop}${oldParams[i] ?? fallbackParams}:${escapeText(v)}`));
|
||||||
|
return [...kept.slice(0, insertAt), ...written, ...kept.slice(insertAt)].join('\r\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confine a patch to one component's lines.
|
||||||
|
*
|
||||||
|
* FOUND THE HARD WAY, against a real file. A stored .ics is not a flat list of properties: an event with
|
||||||
|
* a TZID carries a VTIMEZONE block, and that block has DTSTART lines of its own — the rule for when
|
||||||
|
* summer time begins. A patch that takes the FIRST `DTSTART:` in the file rewrites the timezone
|
||||||
|
* definition, leaves the event's own start exactly where it was, and reports success. The calendar then
|
||||||
|
* shows the old time, and the file is quietly corrupt.
|
||||||
|
*
|
||||||
|
* So every write goes through here first. The properties it owns live inside `BEGIN:VEVENT` (or
|
||||||
|
* `BEGIN:VCARD`), and nothing outside is ever seen.
|
||||||
|
*
|
||||||
|
* THE FIRST component, when there are several. An .ics holding a recurring series plus overrides has one
|
||||||
|
* VEVENT per override, and the first is the master — which is what a list row shows and what an edit
|
||||||
|
* from it should change.
|
||||||
|
*/
|
||||||
|
function within(raw: string, component: 'VEVENT' | 'VCARD', edit: (body: string) => string): string {
|
||||||
|
const lines = raw.split(/\r?\n/);
|
||||||
|
const start = lines.indexOf(`BEGIN:${component}`);
|
||||||
|
const end = start === -1 ? -1 : lines.indexOf(`END:${component}`, start + 1);
|
||||||
|
// No wrapper — a bare fragment. Patch what there is rather than refusing.
|
||||||
|
if (start === -1 || end === -1) return edit(raw);
|
||||||
|
|
||||||
|
const body = edit(lines.slice(start + 1, end).join('\r\n'));
|
||||||
|
return [...lines.slice(0, start + 1), ...body.split(/\r?\n/), ...lines.slice(end)].join('\r\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A TEXT property: escaped, or removed when explicitly null. */
|
||||||
|
const text = (raw: string, prop: string, value: string | null | undefined) =>
|
||||||
|
value === undefined ? raw : setProperty(raw, prop, value === null ? null : escapeText(value));
|
||||||
|
|
||||||
|
export type EventPatch = {
|
||||||
|
summary?: string;
|
||||||
|
location?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
/** ISO instant, or `YYYY-MM-DD` for an all-day event. */
|
||||||
|
start?: string;
|
||||||
|
/** Same shape. For an all-day event this is the LAST day, inclusive — as a person means it. */
|
||||||
|
end?: string;
|
||||||
|
allDay?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a field patch to one .ics.
|
||||||
|
*
|
||||||
|
* TIMES ARE THE DELICATE PART. Three things have to move together and any one of them alone is a bug:
|
||||||
|
* the value's format (`20260901T090000Z` or `20260901`), the `VALUE=DATE` parameter that says which of
|
||||||
|
* those it is, and — for all-day — the exclusive DTEND. Changing a timed event to all-day rewrites both
|
||||||
|
* ends; changing only the end time leaves the start exactly as it was, TZID and all.
|
||||||
|
*/
|
||||||
|
export function patchEvent(raw: string, patch: EventPatch): string {
|
||||||
|
return within(raw, 'VEVENT', (body) => patchEventBody(body, raw, patch));
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchEventBody(raw: string, whole: string, patch: EventPatch): string {
|
||||||
|
let out = text(raw, 'SUMMARY', patch.summary);
|
||||||
|
out = text(out, 'LOCATION', patch.location);
|
||||||
|
out = text(out, 'DESCRIPTION', patch.description);
|
||||||
|
|
||||||
|
const current: CalendarEvent | undefined = parseEvents(whole)[0];
|
||||||
|
const wasAllDay = Boolean(current?.allDay);
|
||||||
|
const allDay = patch.allDay ?? wasAllDay;
|
||||||
|
const movesTime = patch.start !== undefined || patch.end !== undefined || allDay !== wasAllDay;
|
||||||
|
if (!movesTime) return out;
|
||||||
|
|
||||||
|
const start = patch.start ?? current?.start;
|
||||||
|
if (!start) throw new Error('cannot move an event whose start could not be read');
|
||||||
|
|
||||||
|
// The file's all-day DTEND is EXCLUSIVE, so reusing it as-is would shift the event a day every time
|
||||||
|
// something else about it was edited. Normalise to the inclusive form the callers speak.
|
||||||
|
const existingEnd = current?.end && wasAllDay ? subDay(current.end) : current?.end;
|
||||||
|
const end = patch.end ?? existingEnd;
|
||||||
|
|
||||||
|
if (allDay) {
|
||||||
|
out = setProperty(out, 'DTSTART', dateStamp(start), ';VALUE=DATE');
|
||||||
|
out = setProperty(out, 'DTEND', dateStamp(addDay(end ?? start)), ';VALUE=DATE');
|
||||||
|
} else {
|
||||||
|
const startDate = new Date(start);
|
||||||
|
// An hour, matching what a new event with no end gets.
|
||||||
|
const endDate = end ? new Date(end) : new Date(startDate.getTime() + 60 * 60 * 1000);
|
||||||
|
out = setProperty(out, 'DTSTART', utcStamp(startDate), '');
|
||||||
|
out = setProperty(out, 'DTEND', utcStamp(endDate <= startDate ? startDate : endDate), '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phones use SEQUENCE to tell an update from a duplicate, and LAST-MODIFIED is what a sync log shows.
|
||||||
|
const sequence = Number(/^SEQUENCE:(\d+)/m.exec(out)?.[1] ?? '0');
|
||||||
|
out = setProperty(out, 'SEQUENCE', String(sequence + 1), '');
|
||||||
|
return setProperty(out, 'LAST-MODIFIED', utcStamp(new Date()), '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContactPatch = {
|
||||||
|
fullName?: string;
|
||||||
|
emails?: string[];
|
||||||
|
phones?: string[];
|
||||||
|
org?: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a field patch to one .vcf.
|
||||||
|
*
|
||||||
|
* `emails` and `phones` REPLACE the whole list, because that is the shape they are read in — what comes
|
||||||
|
* back from the listing is what goes into a patch. Sending one email to a card that had two removes the
|
||||||
|
* other, which is the only reading of "set the emails to this" that is not a guess.
|
||||||
|
*
|
||||||
|
* `N` follows `FN`, since a card whose display name and structured name disagree sorts under one and
|
||||||
|
* displays the other.
|
||||||
|
*/
|
||||||
|
export function patchContact(raw: string, patch: ContactPatch): string {
|
||||||
|
return within(raw, 'VCARD', (body) => patchContactBody(body, patch));
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchContactBody(raw: string, patch: ContactPatch): string {
|
||||||
|
let out = raw;
|
||||||
|
if (patch.fullName !== undefined) {
|
||||||
|
const [family, ...given] = patch.fullName.trim().split(/\s+/).reverse();
|
||||||
|
out = setProperty(out, 'FN', escapeText(patch.fullName));
|
||||||
|
out = setProperty(out, 'N', `${escapeText(family ?? '')};${escapeText(given.reverse().join(' '))};;;`, '');
|
||||||
|
}
|
||||||
|
if (patch.emails !== undefined) out = setPropertyList(out, 'EMAIL', patch.emails, ';TYPE=INTERNET');
|
||||||
|
if (patch.phones !== undefined) out = setPropertyList(out, 'TEL', patch.phones, ';TYPE=CELL');
|
||||||
|
out = text(out, 'ORG', patch.org);
|
||||||
|
out = text(out, 'TITLE', patch.title);
|
||||||
|
// Not merely informational: some clients keep the copy with the later REV when two disagree.
|
||||||
|
return setProperty(out, 'REV', utcStamp(new Date()), '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which patcher a href needs. The extension is the only thing that distinguishes them. */
|
||||||
|
export const patchFor = (href: string, patch: EventPatch & ContactPatch, raw: string): string =>
|
||||||
|
href.endsWith('.vcf') ? patchContact(raw, patch) : patchEvent(raw, patch);
|
||||||
|
|
||||||
|
/** Present only so a caller can confirm what it read back. */
|
||||||
|
export const summarise = (href: string, raw: string) =>
|
||||||
|
href.endsWith('.vcf') ? parseContacts(raw)[0] : parseEvents(raw)[0];
|
||||||
@@ -1,155 +1,152 @@
|
|||||||
---
|
---
|
||||||
name: Calendar and contacts
|
name: calendar-and-contacts
|
||||||
description: Use when the owner asks you to add, move, cancel or look up an event, or to add, remove or find a contact — "dinner Saturday with my wife", "cancel that", "when am I seeing the CEO", "add Peter Griffin, this number". Carries the working recipes, the two credentials, and the timezone trap that puts every event an hour out.
|
title: Calendar and contacts
|
||||||
version: 1
|
description: Use when the owner asks you to add, move, cancel or look up an event, or to add, remove or find a contact — "dinner Saturday with my wife", "move that to 9", "when am I seeing the CEO", "add Peter Griffin, this number". Carries the JSON routes, the one credential, and the timezone trap that puts every event an hour out.
|
||||||
|
version: 2
|
||||||
---
|
---
|
||||||
|
|
||||||
You can read and write the owner's calendars and address book directly. **Reading goes through Officer's
|
You can read and write the owner's calendars and address books. **It is a JSON API and you already have
|
||||||
API. Writing goes through the DAV door.** Both use credentials you already have; there is nothing to ask
|
the key.** Nothing to ask for, no password to be given, no XML.
|
||||||
for and no password to be given.
|
|
||||||
|
|
||||||
## The two doors
|
You do not write iCalendar or vCard by hand. You did once, and the recipes were long because the format
|
||||||
|
is full of things that fail silently — escaping, 75-octet line folding, an end date that is exclusive,
|
||||||
|
a timezone definition with dates of its own. All of that now happens on the server. Send fields.
|
||||||
|
|
||||||
**Read — Officer's own API, with `$OFFICER_AGENT_KEY`.** Parsed JSON, no XML.
|
## The one door
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -H "Authorization: Bearer $OFFICER_AGENT_KEY" -H "Host: $OFFICER_API_HOST" \
|
OFFICER=(-H "Authorization: Bearer $OFFICER_AGENT_KEY" -H "Host: $OFFICER_API_HOST"
|
||||||
"$OFFICER_API_URL/api/caldav/_officer/collections"
|
-H 'content-type: application/json')
|
||||||
```
|
curl -s "${OFFICER[@]}" "$OFFICER_API_URL/api/caldav/_officer/collections"
|
||||||
|
|
||||||
**Write — the DAV door, HTTP Basic, the SAME key as the password.**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ME=$(curl -s -H "Authorization: Bearer $OFFICER_AGENT_KEY" -H "Host: $OFFICER_API_HOST" \
|
|
||||||
"$OFFICER_API_URL/api/auth/me" | python3 -c 'import json,sys; print(json.load(sys.stdin)["email"])')
|
|
||||||
AUTH="$ME:$OFFICER_AGENT_KEY"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Your key is bound to ONE account — the owner's. You cannot reach anyone else's calendar with it and you
|
Your key is bound to ONE account — the owner's. You cannot reach anyone else's calendar with it and you
|
||||||
do not need to guard against doing so: another principal answers 403, and naming a different user in the
|
do not need to guard against doing so: another user's path answers 400. Write the owner's own paths and
|
||||||
Basic header answers 401. Write the owner's own paths and stop thinking about it.
|
stop thinking about it.
|
||||||
|
|
||||||
## Find the collections first — never guess a path
|
## Find the collections first — never guess a path
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -H "Authorization: Bearer $OFFICER_AGENT_KEY" -H "Host: $OFFICER_API_HOST" \
|
# {"collections":[{"path":"/dav/1/personal/","name":"personal","displayName":"Personal","kind":"calendar"},
|
||||||
"$OFFICER_API_URL/api/caldav/_officer/collections"
|
# {"path":"/dav/1/contacts/","name":"contacts","displayName":"Contacts","kind":"addressbook"}]}
|
||||||
# {"collections":[{"path":"/dav/1/personal/","displayName":"Personal","kind":"calendar", ...},
|
|
||||||
# {"path":"/dav/1/contacts/","displayName":"Contacts","kind":"addressbook"}]}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`kind` is `calendar` or `addressbook`. There are usually several calendars — **"Personal" is the default
|
`kind` is `calendar` or `addressbook`. Pass the **`path`** to every route below. There are usually
|
||||||
for anything the owner does not place**, and Tasks holds VTODOs that show up in Reminders on a phone.
|
several calendars — **"Personal" is the default for anything the owner does not place** — and Tasks holds
|
||||||
|
VTODOs, which show up in Reminders on a phone.
|
||||||
|
|
||||||
## THE TIMEZONE TRAP — read this before writing any time
|
## THE TIMEZONE TRAP — read this before writing any time
|
||||||
|
|
||||||
The owner says "Saturday at 8". Calendars store UTC. **`date -u -d` does not convert** — `-u` makes GNU
|
The owner says "Saturday at 8". The API takes UTC. **`date -u -d` does not convert** — `-u` makes GNU
|
||||||
date parse the input as UTC *and* print UTC, so "8pm" becomes 20:00Z, which is 21:00 in Lisbon. Every
|
date parse the input as UTC *and* print it as UTC, so "8pm" becomes 20:00Z, which is 21:00 in Lisbon.
|
||||||
event lands an hour out and nothing errors.
|
Every event lands an hour out and nothing errors.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# WRONG — prints 20260829T200000Z
|
# WRONG — prints 2026-08-29T20:00:00Z
|
||||||
date -u -d 'saturday 20:00' +%Y%m%dT%H%M%SZ
|
date -u -d 'saturday 20:00' +%Y-%m-%dT%H:%M:%SZ
|
||||||
# RIGHT — prints 20260829T190000Z, via epoch so the local zone is applied
|
# RIGHT — prints 2026-08-29T19:00:00Z, via epoch so the local zone is applied
|
||||||
date -u -d "@$(date -d 'saturday 20:00' +%s)" +%Y%m%dT%H%M%SZ
|
date -u -d "@$(date -d 'saturday 20:00' +%s)" +%Y-%m-%dT%H:%M:%SZ
|
||||||
```
|
```
|
||||||
|
|
||||||
The machine's clock is the owner's timezone. Parse what they said with plain `date -d`, convert through
|
The machine's clock is the owner's timezone. Parse what they said with plain `date -d`, convert through
|
||||||
`+%s`, and **say the LOCAL time back to them** when you confirm — never the UTC you stored.
|
`+%s`, and **say the LOCAL time back to them** when you confirm — never the UTC you stored.
|
||||||
|
|
||||||
## Create an event
|
|
||||||
|
|
||||||
The filename must be `<UID>.ics`, and iCalendar lines are **CRLF-terminated**. `DTSTAMP` is required.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
UID="officer-$(date +%s)-$RANDOM"
|
|
||||||
S=$(date -u -d "@$(date -d 'saturday 20:00' +%s)" +%Y%m%dT%H%M%SZ)
|
|
||||||
E=$(date -u -d "@$(date -d 'saturday 22:00' +%s)" +%Y%m%dT%H%M%SZ)
|
|
||||||
printf "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Officer//agent//EN\r\nBEGIN:VEVENT\r\nUID:$UID\r\nDTSTAMP:$(date -u +%Y%m%dT%H%M%SZ)\r\nDTSTART:$S\r\nDTEND:$E\r\nSUMMARY:Dinner with my wife\r\nLOCATION:Cervejaria Ramiro\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n" \
|
|
||||||
| curl -s -o /dev/null -w '%{http_code}\n' -u "$AUTH" -X PUT \
|
|
||||||
-H 'Content-Type: text/calendar; charset=utf-8' --data-binary @- \
|
|
||||||
"$OFFICER_API_URL/dav/1/personal/$UID.ics"
|
|
||||||
# 201 created
|
|
||||||
```
|
|
||||||
|
|
||||||
An **all-day** event uses dates, not times, and DTEND is the day AFTER:
|
|
||||||
`DTSTART;VALUE=DATE:20260829` with `DTEND;VALUE=DATE:20260830`.
|
|
||||||
|
|
||||||
## Look something up
|
## Look something up
|
||||||
|
|
||||||
"When am I having dinner with the CEO?" — read, filter, answer in local time.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -H "Authorization: Bearer $OFFICER_AGENT_KEY" -H "Host: $OFFICER_API_HOST" \
|
curl -s "${OFFICER[@]}" "$OFFICER_API_URL/api/caldav/_officer/events?collection=%2Fdav%2F1%2Fpersonal%2F"
|
||||||
"$OFFICER_API_URL/api/caldav/_officer/events?collection=%2Fdav%2F1%2Fpersonal%2F"
|
# {"events":[{"uid":"…","href":"/dav/1/personal/….ics","summary":"Dinner with my wife",
|
||||||
# {"events":[{"uid":"…","summary":"Dinner with my wife","start":"2026-08-29T19:00:00Z","end":"…",
|
# "start":"2026-08-29T19:00:00Z","location":"Cervejaria Ramiro","allDay":false}]}
|
||||||
# "location":"Cervejaria Ramiro","allDay":false}]}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`start` and `end` are UTC. **Convert before speaking**: `date -d '2026-08-29T19:00:00Z' '+%A %-d %B, %H:%M'`.
|
Contacts are the same shape at `_officer/contacts`, returning `fullName`, `emails` and `phones`.
|
||||||
|
|
||||||
Search every calendar, not just Personal — the answer is often in another one. `location` is where
|
**Search every calendar, not just Personal** — the answer is often in another one. `start` and `end` are
|
||||||
"where did I set it up" is answered from; if it is empty, say so rather than inventing somewhere.
|
UTC, so convert before speaking: `date -d '2026-08-29T19:00:00Z' '+%A %-d %B, %H:%M'`.
|
||||||
|
|
||||||
`rrule` present means the event REPEATS and the listed `start` is the series start, not the next
|
`rrule` present means the event REPEATS and the listed `start` is the series start, not the next
|
||||||
occurrence. Do not compute occurrences yourself — say it repeats and give the rule in words.
|
occurrence. Do not compute occurrences yourself — say it repeats and give the rule in words.
|
||||||
|
|
||||||
## Move an event
|
**`href` is how you change or delete a thing, and it only ever comes from a read.** Never build one. A
|
||||||
|
file is only conventionally named after its UID: everything a phone wrote is named something else, and a
|
||||||
|
constructed href finds nothing or, worse, finds something.
|
||||||
|
|
||||||
**Same UID, same filename, new times.** A new UID creates a second event and leaves the first in place,
|
## Add an event
|
||||||
which is the most common way this goes wrong.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# fetch the current copy, so nothing else in it is lost
|
curl -s "${OFFICER[@]}" -X POST "$OFFICER_API_URL/api/caldav/_officer/event" \
|
||||||
curl -s -u "$AUTH" "$OFFICER_API_URL/dav/1/personal/$UID.ics" > /tmp/ev.ics
|
-d '{"collection":"/dav/1/personal/","summary":"Dinner with my wife","location":"Cervejaria Ramiro",
|
||||||
# then PUT a version with new DTSTART/DTEND and SEQUENCE bumped by one
|
"start":"2026-08-29T19:00:00Z","end":"2026-08-29T21:00:00Z","allDay":false}'
|
||||||
|
# {"href":"/dav/1/personal/<uuid>.ics"}
|
||||||
```
|
```
|
||||||
|
|
||||||
Bump `SEQUENCE` (add `SEQUENCE:1`, or increment what is there). Phones use it to tell an update from a
|
All-day takes plain dates and no times: `{"start":"2026-08-29","allDay":true}`. `end` is the LAST day as
|
||||||
duplicate. A successful update answers **204**, not 201 — 201 means you created something new, so check
|
a person means it — a two-day trip is `"start":"2026-08-29","end":"2026-08-30"`. No end means one hour,
|
||||||
your filename.
|
or one day.
|
||||||
|
|
||||||
## Delete an event, or a contact
|
## Add a contact
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -o /dev/null -w '%{http_code}\n' -u "$AUTH" -X DELETE "$OFFICER_API_URL/dav/1/personal/$UID.ics"
|
curl -s "${OFFICER[@]}" -X POST "$OFFICER_API_URL/api/caldav/_officer/contact" \
|
||||||
# 200
|
-d '{"collection":"/dav/1/contacts/","fullName":"Peter Griffin",
|
||||||
|
"emails":["peter@quahog.example"],"phones":["+351912345678"],"org":"Pawtucket Brewery"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
**Find the UID by reading first — never guess.** The file is normally `<UID>.ics`, and if that 404s, list
|
`fullName` is what a phone displays and is required. Phone numbers keep their international prefix.
|
||||||
the collection and match on href:
|
|
||||||
|
## Change something
|
||||||
|
|
||||||
|
One PATCH, with only the fields that change. Everything else in the file is left exactly as it was —
|
||||||
|
including alarms, attendees, recurrence rules and anything a phone put there that Officer has no idea
|
||||||
|
about.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -u "$AUTH" -X PROPFIND -H 'Depth: 1' "$OFFICER_API_URL/dav/1/personal/" | grep -o '<D:href>[^<]*'
|
curl -s "${OFFICER[@]}" -X PATCH "$OFFICER_API_URL/api/caldav/_officer/item" \
|
||||||
|
-d '{"href":"/dav/1/personal/….ics","start":"2026-08-29T20:00:00Z","end":"2026-08-29T22:00:00Z"}'
|
||||||
|
# {"ok":true,"item":{…the whole thing, as it now reads…}}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Events**: `summary`, `location`, `description`, `start`, `end`, `allDay`.
|
||||||
|
- **Contacts**: `fullName`, `emails`, `phones`, `org`, `title`.
|
||||||
|
- `null` REMOVES a field. `""` leaves it there and empty. Omit it to leave it alone.
|
||||||
|
- `emails` and `phones` replace the whole list. To add a number, send the existing ones back with the new
|
||||||
|
one — read first.
|
||||||
|
|
||||||
|
**Moving an event is a PATCH, not a new event.** Creating a second one and leaving the first is the most
|
||||||
|
common way this goes wrong, and the owner finds out when two of them ring.
|
||||||
|
|
||||||
|
**Confirm from `item`, not from what you sent.** It is the file re-read after the write.
|
||||||
|
|
||||||
|
## Delete
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s "${OFFICER[@]}" -X DELETE "$OFFICER_API_URL/api/caldav/_officer/item" \
|
||||||
|
-d '{"href":"/dav/1/personal/….ics"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
**Confirm before deleting when the match is not exact.** "Cancel dinner on Saturday" with one dinner on
|
**Confirm before deleting when the match is not exact.** "Cancel dinner on Saturday" with one dinner on
|
||||||
Saturday is unambiguous — do it. Two dinners, or a recurring one, is a question: deleting the wrong event
|
Saturday is unambiguous — do it. Two dinners, or a recurring one, is a question: this cannot be undone
|
||||||
cannot be undone from here, and the owner will discover it when someone is stood up.
|
from here, and the owner discovers it when somebody is stood up.
|
||||||
|
|
||||||
Deleting one occurrence of a REPEATING event is not a DELETE — it needs an EXDATE added to the series.
|
Deleting one occurrence of a REPEATING event is not a delete — it needs an EXDATE on the series. Say so
|
||||||
If that is what they meant, say so rather than removing every occurrence.
|
rather than removing every occurrence.
|
||||||
|
|
||||||
## Add or remove a contact
|
## What this API deliberately cannot do
|
||||||
|
|
||||||
Same shape, `.vcf` in the addressbook collection.
|
It writes ordinary events and ordinary cards. **Recurrence rules, alarms, attendees, EXDATEs and photos
|
||||||
|
are not in it** — not an oversight, but the same rule that makes a PATCH safe: a field the screens cannot
|
||||||
|
show is a field an editor should not invent.
|
||||||
|
|
||||||
```bash
|
When the owner genuinely needs one, the DAV door is still there — HTTP Basic, their email as the
|
||||||
UID="officer-$(date +%s)-$RANDOM"
|
username and `$OFFICER_AGENT_KEY` as the password, PUT the whole `.ics` to its href. Read the file first
|
||||||
printf "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:$UID\r\nFN:Peter Griffin\r\nN:Griffin;Peter;;;\r\nTEL;TYPE=CELL:+351912345678\r\nEMAIL;TYPE=INTERNET:peter@quahog.example\r\nEND:VCARD\r\n" \
|
with `_officer/item?href=…`, change what you must, and put it back with the same UID and the same
|
||||||
| curl -s -o /dev/null -w '%{http_code}\n' -u "$AUTH" -X PUT \
|
filename. **Bump `SEQUENCE`** so a phone reads an update rather than a duplicate. If you are doing this,
|
||||||
-H 'Content-Type: text/vcard; charset=utf-8' --data-binary @- \
|
say so — it is the part where a mistake is silent.
|
||||||
"$OFFICER_API_URL/dav/1/contacts/$UID.vcf"
|
|
||||||
```
|
|
||||||
|
|
||||||
`FN` is what shows on a phone and is REQUIRED. `N` is `Family;Given;Middle;Prefix;Suffix` — fill what you
|
|
||||||
know and leave the rest empty rather than omitting the line. Read contacts back with
|
|
||||||
`_officer/contacts?collection=…`, which returns `fullName`, `emails` and `phones` already parsed.
|
|
||||||
|
|
||||||
## Confirm in one line, in their words
|
## Confirm in one line, in their words
|
||||||
|
|
||||||
"Added — Dinner with my wife, Saturday 29 August, 20:00, Cervejaria Ramiro." Local time, the name they
|
"Added — Dinner with my wife, Saturday 29 August, 20:00, Cervejaria Ramiro." Local time, the name they
|
||||||
used, and where. Not the UID, not the UTC, not the HTTP status.
|
used, and where. Not the href, not the UTC, not the HTTP status.
|
||||||
|
|
||||||
If a write fails, say what failed and stop. **Never retry a PUT with a new UID** — the usual cause of a
|
If a write fails, say what failed and stop. **Never retry a create after a failure** — the usual cause is
|
||||||
failure is that the first one partly worked, and a retry leaves two.
|
that the first one partly worked, and a retry leaves two.
|
||||||
|
|||||||
+297
-40
@@ -1,6 +1,9 @@
|
|||||||
import type { CalendarEvent } from './shared';
|
import type { CalendarEvent } from './shared';
|
||||||
import { CalendarDays, MapPin, Repeat } from 'lucide-react';
|
import { useState } from 'react';
|
||||||
|
import { CalendarDays, MapPin, Repeat, Pencil, Trash2, Check, X, Plus } from 'lucide-react';
|
||||||
import { useCalendarEvents, useSelectedCollection } from './useDavData';
|
import { useCalendarEvents, useSelectedCollection } from './useDavData';
|
||||||
|
import { useDavEdit, CREATING, type NewEvent } from './useDavEdit';
|
||||||
|
import { useFilter, matches } from './useFilter';
|
||||||
import { useTranslation } from 'officerdev';
|
import { useTranslation } from 'officerdev';
|
||||||
|
|
||||||
// Right panel of /calendar. Deliberately a grouped agenda list, not a month grid: the sidecar returns
|
// Right panel of /calendar. Deliberately a grouped agenda list, not a month grid: the sidecar returns
|
||||||
@@ -26,18 +29,79 @@ const formatTime = (ev: CalendarEvent) => {
|
|||||||
return `${start.toLocaleTimeString(undefined, opts)} – ${end.toLocaleTimeString(undefined, opts)}`;
|
return `${start.toLocaleTimeString(undefined, opts)} – ${end.toLocaleTimeString(undefined, opts)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type Draft = { summary: string; location: string; date: string; start: string; end: string; allDay: boolean };
|
||||||
|
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
const localDate = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||||
|
const localTime = (d: Date) => `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
|
||||||
|
/** The next whole hour, for an hour. What every calendar has trained people to expect from "new event". */
|
||||||
|
const blankDraft = (): Draft => {
|
||||||
|
const start = new Date(Date.now() + 60 * 60 * 1000);
|
||||||
|
start.setMinutes(0, 0, 0);
|
||||||
|
const end = new Date(start.getTime() + 60 * 60 * 1000);
|
||||||
|
return {
|
||||||
|
summary: '',
|
||||||
|
location: '',
|
||||||
|
date: localDate(start),
|
||||||
|
start: localTime(start),
|
||||||
|
end: localTime(end),
|
||||||
|
allDay: false,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The form's local fields become what the sidecar takes: an ISO instant, or a plain date for all-day.
|
||||||
|
*
|
||||||
|
* The clock is read as LOCAL — a person typing 14:00 means 14:00 where they are — and `toISOString`
|
||||||
|
* converts it, so what reaches the file is UTC and unambiguous.
|
||||||
|
*/
|
||||||
|
const toEvent = (d: Draft): NewEvent => {
|
||||||
|
const base = { summary: d.summary.trim(), location: d.location.trim() || undefined };
|
||||||
|
if (d.allDay) return { ...base, start: d.date, allDay: true };
|
||||||
|
|
||||||
|
const start = new Date(`${d.date}T${d.start}`);
|
||||||
|
let end = new Date(`${d.date}T${d.end}`);
|
||||||
|
// 23:00–01:00 is an event that crosses midnight, not one that ends two hours before it started.
|
||||||
|
if (end <= start) end = new Date(end.getTime() + 24 * 60 * 60 * 1000);
|
||||||
|
return { ...base, start: start.toISOString(), end: end.toISOString(), allDay: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
const FIELD = 'rounded-md border bg-background px-2 py-1 text-sm';
|
||||||
|
|
||||||
export const CalendarView = () => {
|
export const CalendarView = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { selected, isLoading: loadingCollections } = useSelectedCollection('calendar');
|
const { selected, isLoading: loadingCollections } = useSelectedCollection('calendar');
|
||||||
const { events, isLoading, error } = useCalendarEvents(selected?.path ?? null);
|
const { events, isLoading, error } = useCalendarEvents(selected?.path ?? null);
|
||||||
|
// Keyed on the COLLECTION, so a save refreshes the list this event came from.
|
||||||
|
const edit = useDavEdit(['caldav', 'events', selected?.path ?? null]);
|
||||||
|
const [editing, setEditing] = useState<string | null>(null);
|
||||||
|
const [draft, setDraft] = useState({ summary: '', location: '' });
|
||||||
|
const [confirming, setConfirming] = useState<string | null>(null);
|
||||||
|
const [draftNew, setDraftNew] = useState<Draft | null>(null);
|
||||||
|
const filter = useFilter('Filter events');
|
||||||
|
|
||||||
if (loadingCollections) return <Placeholder text="Loading calendars…" />;
|
if (loadingCollections) return <Placeholder text="Loading calendars…" />;
|
||||||
if (!selected) return <Placeholder text="No calendars yet. Create one from a CalDAV client." />;
|
if (!selected) return <Placeholder text="No calendars yet. Create one from a CalDAV client." />;
|
||||||
if (error) return <Placeholder text={t('dav.readFailed', { name: selected.displayName, reason: String(error) })} />;
|
|
||||||
if (isLoading) return <Placeholder text="Loading events…" />;
|
|
||||||
if (events.length === 0) return <Placeholder text={`${selected.displayName} has no events.`} />;
|
|
||||||
|
|
||||||
const sorted = [...events].sort((a, b) => (a.start ?? '').localeCompare(b.start ?? ''));
|
// Title, place, notes — and the DAY, so "saturday" or "2026-08-29" narrows to a date the way a person
|
||||||
|
// expects. `start` is an ISO string, which is why the date is searchable at all without parsing it.
|
||||||
|
const shown = events.filter((ev) => matches(filter.query, ev.summary, ev.location, ev.description, ev.start));
|
||||||
|
|
||||||
|
// Not an early return any more: the header carries the only way to ADD an event, and a calendar that
|
||||||
|
// is empty or still loading is exactly when somebody wants it. A filter that matches nothing says so,
|
||||||
|
// rather than looking like a calendar that lost its events.
|
||||||
|
const notice = error
|
||||||
|
? t('dav.readFailed', { name: selected.displayName, reason: String(error) })
|
||||||
|
: isLoading
|
||||||
|
? 'Loading events…'
|
||||||
|
: events.length === 0
|
||||||
|
? `${selected.displayName} has no events.`
|
||||||
|
: shown.length === 0
|
||||||
|
? `No event matches “${filter.query}”.`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const sorted = [...shown].sort((a, b) => (a.start ?? '').localeCompare(b.start ?? ''));
|
||||||
const days: Array<{ key: string; events: CalendarEvent[] }> = [];
|
const days: Array<{ key: string; events: CalendarEvent[] }> = [];
|
||||||
for (const ev of sorted) {
|
for (const ev of sorted) {
|
||||||
const key = dayKey(ev);
|
const key = dayKey(ev);
|
||||||
@@ -45,49 +109,242 @@ export const CalendarView = () => {
|
|||||||
if (last && last.key === key) last.events.push(ev);
|
if (last && last.key === key) last.events.push(ev);
|
||||||
else days.push({ key, events: [ev] });
|
else days.push({ key, events: [ev] });
|
||||||
}
|
}
|
||||||
|
// Newest day at the top. A calendar accumulates years, and an ascending list opens on whatever was
|
||||||
|
// imported first — scrolling to the bottom to find this week is the wrong default. Within a day the
|
||||||
|
// order stays chronological, because that is what a day IS.
|
||||||
|
days.reverse();
|
||||||
|
|
||||||
|
const submitNew = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!draftNew?.summary.trim()) return;
|
||||||
|
void edit.createEvent(selected.path, toEvent(draftNew)).then((ok) => ok && setDraftNew(null));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-y-auto">
|
<div className="flex h-full flex-col">
|
||||||
<div className="mx-auto flex max-w-3xl flex-col gap-6 p-6">
|
<header className="flex shrink-0 items-center justify-between gap-3 border-b px-6 py-3">
|
||||||
{days.map((day) => (
|
<div className="min-w-0">
|
||||||
<section key={day.key} className="flex flex-col gap-2">
|
<div className="truncate text-sm font-semibold leading-tight">{selected.displayName}</div>
|
||||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{formatDay(day.key)}
|
{filter.query ? `${shown.length} of ${events.length}` : events.length}{' '}
|
||||||
</h2>
|
{events.length === 1 ? 'event' : 'events'}
|
||||||
{day.events.map((ev) => (
|
</div>
|
||||||
<article key={ev.uid} className="flex gap-3 rounded-xl border bg-card p-3">
|
</div>
|
||||||
<span
|
{filter.input}
|
||||||
className="mt-1 h-2.5 w-2.5 shrink-0 rounded-full ring-1 ring-black/10"
|
<button
|
||||||
style={{ backgroundColor: selected.color || '#94a3b8' }}
|
onClick={() => setDraftNew((d) => (d ? null : blankDraft()))}
|
||||||
|
className="flex shrink-0 items-center gap-1.5 rounded-lg bg-primary/10 px-3 py-1.5 text-xs font-medium text-primary transition-colors hover:bg-primary/20"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
New event
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<div className="mx-auto flex max-w-3xl flex-col gap-6 p-6">
|
||||||
|
{/* Title, when, and where. No recurrence and no alarms: this form writes a minimal VEVENT, and
|
||||||
|
a field the list cannot show afterwards is a field that silently disappears. */}
|
||||||
|
{draftNew && (
|
||||||
|
<form onSubmit={submitNew} className="flex flex-col gap-3 rounded-xl border bg-card p-4">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={draftNew.summary}
|
||||||
|
onChange={(e) => setDraftNew({ ...draftNew, summary: e.target.value })}
|
||||||
|
placeholder="Title"
|
||||||
|
className={FIELD}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={draftNew.date}
|
||||||
|
onChange={(e) => setDraftNew({ ...draftNew, date: e.target.value })}
|
||||||
|
className={`${FIELD} tabular-nums`}
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0 flex-1">
|
{!draftNew.allDay && (
|
||||||
<div className="flex items-center gap-2">
|
<>
|
||||||
<span className="truncate text-sm font-medium">{ev.summary || '(no title)'}</span>
|
<input
|
||||||
{ev.rrule && (
|
type="time"
|
||||||
<span
|
value={draftNew.start}
|
||||||
title={ev.rrule}
|
onChange={(e) => setDraftNew({ ...draftNew, start: e.target.value })}
|
||||||
className="flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[10px] text-muted-foreground"
|
className={`${FIELD} tabular-nums`}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">to</span>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={draftNew.end}
|
||||||
|
onChange={(e) => setDraftNew({ ...draftNew, end: e.target.value })}
|
||||||
|
className={`${FIELD} tabular-nums`}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={draftNew.allDay}
|
||||||
|
onChange={(e) => setDraftNew({ ...draftNew, allDay: e.target.checked })}
|
||||||
|
/>
|
||||||
|
All day
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
value={draftNew.location}
|
||||||
|
onChange={(e) => setDraftNew({ ...draftNew, location: e.target.value })}
|
||||||
|
placeholder="Where"
|
||||||
|
className={`${FIELD} text-xs`}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={edit.busy === CREATING || !draftNew.summary.trim()}
|
||||||
|
className="flex items-center gap-1 rounded-md bg-primary/10 px-3 py-1.5 text-xs font-medium text-primary disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Check className="h-3 w-3" /> {edit.busy === CREATING ? 'Creating…' : 'Create'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDraftNew(null)}
|
||||||
|
className="rounded-md px-2 py-1.5 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{edit.error && <span className="text-xs text-destructive">{edit.error}</span>}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{notice && <Placeholder text={notice} />}
|
||||||
|
{days.map((day) => (
|
||||||
|
<section key={day.key} className="flex flex-col gap-2">
|
||||||
|
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
{formatDay(day.key)}
|
||||||
|
</h2>
|
||||||
|
{day.events.map((ev) => (
|
||||||
|
<article key={ev.uid} className="flex gap-3 rounded-xl border bg-card p-3">
|
||||||
|
<span
|
||||||
|
className="mt-1 h-2.5 w-2.5 shrink-0 rounded-full ring-1 ring-black/10"
|
||||||
|
style={{ backgroundColor: selected.color || '#94a3b8' }}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="truncate text-sm font-medium">{ev.summary || '(no title)'}</span>
|
||||||
|
{ev.rrule && (
|
||||||
|
<span
|
||||||
|
title={ev.rrule}
|
||||||
|
className="flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[10px] text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Repeat className="h-3 w-3" />
|
||||||
|
repeats
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-xs tabular-nums text-muted-foreground">{formatTime(ev)}</div>
|
||||||
|
|
||||||
|
{/* The editor is the title and the place, and nothing else. Those are the two fields a
|
||||||
|
person actually corrects; times and recurrence are where an editor does damage, and
|
||||||
|
a half-editor that silently rewrites an RRULE is worse than none. */}
|
||||||
|
{editing === ev.href && (
|
||||||
|
<form
|
||||||
|
className="mt-2 flex flex-col gap-2"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!ev.href) return;
|
||||||
|
// `null` removes the line; an empty string would leave an empty LOCATION behind.
|
||||||
|
void edit
|
||||||
|
.save(ev.href, { summary: draft.summary, location: draft.location || null })
|
||||||
|
.then((ok) => ok && setEditing(null));
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Repeat className="h-3 w-3" />
|
<input
|
||||||
repeats
|
autoFocus
|
||||||
</span>
|
value={draft.summary}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, summary: e.target.value }))}
|
||||||
|
placeholder="Title"
|
||||||
|
className="rounded-md border bg-background px-2 py-1 text-sm"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={draft.location}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, location: e.target.value }))}
|
||||||
|
placeholder="Where"
|
||||||
|
className="rounded-md border bg-background px-2 py-1 text-xs"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={edit.busy === ev.href}
|
||||||
|
className="flex items-center gap-1 rounded-md bg-primary/10 px-2 py-1 text-xs text-primary disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Check className="h-3 w-3" /> {edit.busy === ev.href ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditing(null)}
|
||||||
|
className="rounded-md px-2 py-1 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
{ev.location && (
|
||||||
|
<div className="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<MapPin className="h-3 w-3 shrink-0" />
|
||||||
|
<span className="truncate">{ev.location}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ev.description && (
|
||||||
|
<p className="mt-1 whitespace-pre-wrap text-xs text-muted-foreground">{ev.description}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 text-xs tabular-nums text-muted-foreground">{formatTime(ev)}</div>
|
{/* Only for an event we can actually address. An item with no href came from somewhere
|
||||||
{ev.location && (
|
that did not give us one, and offering a delete that cannot work is worse than not
|
||||||
<div className="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
|
offering it. */}
|
||||||
<MapPin className="h-3 w-3 shrink-0" />
|
{ev.href && editing !== ev.href && (
|
||||||
<span className="truncate">{ev.location}</span>
|
<div className="flex shrink-0 items-start gap-1">
|
||||||
|
{confirming === ev.href ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => void edit.remove(ev.href!).then(() => setConfirming(null))}
|
||||||
|
disabled={edit.busy === ev.href}
|
||||||
|
title="Delete this event"
|
||||||
|
className="rounded p-1 text-destructive hover:bg-destructive/10 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirming(null)}
|
||||||
|
className="rounded p-1 opacity-50 hover:opacity-100"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setDraft({ summary: ev.summary ?? '', location: ev.location ?? '' });
|
||||||
|
setEditing(ev.href!);
|
||||||
|
}}
|
||||||
|
title="Edit"
|
||||||
|
className="rounded p-1 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirming(ev.href!)}
|
||||||
|
title="Delete"
|
||||||
|
className="rounded p-1 text-muted-foreground hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{ev.description && (
|
</article>
|
||||||
<p className="mt-1 whitespace-pre-wrap text-xs text-muted-foreground">{ev.description}</p>
|
))}
|
||||||
)}
|
</section>
|
||||||
</div>
|
))}
|
||||||
</article>
|
</div>
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import type { DavCollection } from './shared';
|
|||||||
import { Link, useSearchParams } from 'react-router';
|
import { Link, useSearchParams } from 'react-router';
|
||||||
import { CalendarDays, Contact as ContactIcon, Smartphone } from 'lucide-react';
|
import { CalendarDays, Contact as ContactIcon, Smartphone } from 'lucide-react';
|
||||||
import { davCollectionPath, COLLECTION_PARAM } from './shared';
|
import { davCollectionPath, COLLECTION_PARAM } from './shared';
|
||||||
import { useDavCollections } from './useDavData';
|
import { useDavCollections, matchCollection } from './useDavData';
|
||||||
import { AppPasswordsModal, useAppPasswordsModal } from '../AppPasswordsModal';
|
import { AppPasswordsModal, useAppPasswordsModal } from '../AppPasswordsModal';
|
||||||
import { useTranslation } from 'officerdev';
|
import { useTranslation } from 'officerdev';
|
||||||
|
|
||||||
// Left panel for both /calendar and /contacts: the list of collections belonging to this user.
|
// Left panel for both /calendar and /contacts: the list of collections belonging to this user.
|
||||||
//
|
//
|
||||||
// Rows are real links carrying ?collection=<dav path>, so a calendar is a bookmarkable address and the
|
// Rows are real links carrying ?collection=<name>, so a calendar is a bookmarkable address and the
|
||||||
// right-hand panel reads the same URL rather than being told over a channel. Active state is compared
|
// right-hand panel reads the same URL rather than being told over a channel. Active state is compared
|
||||||
// against the URL directly instead of NavLink's isActive, because every row shares one pathname and
|
// against the URL directly instead of NavLink's isActive, because every row shares one pathname and
|
||||||
// differs only in the query string.
|
// differs only in the query string.
|
||||||
@@ -28,9 +28,9 @@ const CollectionNav = ({ kind, base, title, icon: Icon }: Props) => {
|
|||||||
const [, setSyncOpen] = useAppPasswordsModal();
|
const [, setSyncOpen] = useAppPasswordsModal();
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
const { collections, isLoading } = useDavCollections(kind);
|
const { collections, isLoading } = useDavCollections(kind);
|
||||||
const fromUrl = params.get(COLLECTION_PARAM);
|
// Resolved the same way the right-hand panel resolves it, so the highlight cannot disagree with what
|
||||||
// Nothing in the URL means the view falls back to the first collection — highlight the same one.
|
// is on screen — including the fallback to the first collection when the URL names nothing.
|
||||||
const activePath = collections.some((c) => c.path === fromUrl) ? fromUrl : (collections[0]?.path ?? null);
|
const active = matchCollection(collections, params.get(COLLECTION_PARAM));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
|
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
|
||||||
@@ -50,11 +50,11 @@ const CollectionNav = ({ kind, base, title, icon: Icon }: Props) => {
|
|||||||
|
|
||||||
<nav className="flex flex-col gap-0.5 px-2 pb-3">
|
<nav className="flex flex-col gap-0.5 px-2 pb-3">
|
||||||
{collections.map((collection) => {
|
{collections.map((collection) => {
|
||||||
const isActive = collection.path === activePath;
|
const isActive = collection.path === active?.path;
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={collection.path}
|
key={collection.path}
|
||||||
to={davCollectionPath(base, collection.path)}
|
to={davCollectionPath(base, collection.name)}
|
||||||
className={`${ROW} ${
|
className={`${ROW} ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-primary/10 font-medium text-primary'
|
? 'bg-primary/10 font-medium text-primary'
|
||||||
|
|||||||
+247
-40
@@ -1,5 +1,8 @@
|
|||||||
import { Building2, Mail, Phone, User } from 'lucide-react';
|
import { useState } from 'react';
|
||||||
|
import { Building2, Mail, Phone, User, Pencil, Trash2, Check, X, Plus } from 'lucide-react';
|
||||||
import { useDavContacts, useSelectedCollection } from './useDavData';
|
import { useDavContacts, useSelectedCollection } from './useDavData';
|
||||||
|
import { useDavEdit, CREATING } from './useDavEdit';
|
||||||
|
import { useFilter, matches } from './useFilter';
|
||||||
import { useTranslation } from 'officerdev';
|
import { useTranslation } from 'officerdev';
|
||||||
|
|
||||||
// Right panel of /contacts: the selected address book as cards, sorted by name.
|
// Right panel of /contacts: the selected address book as cards, sorted by name.
|
||||||
@@ -16,58 +19,262 @@ const initials = (name: string) =>
|
|||||||
.map((part) => part[0]?.toUpperCase() ?? '')
|
.map((part) => part[0]?.toUpperCase() ?? '')
|
||||||
.join('') || '?';
|
.join('') || '?';
|
||||||
|
|
||||||
|
const FIELD = 'rounded-md border bg-background px-2 py-1 text-sm';
|
||||||
|
|
||||||
|
const blankDraft = () => ({ fullName: '', email: '', phone: '' });
|
||||||
|
|
||||||
export const ContactsView = () => {
|
export const ContactsView = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { selected, isLoading: loadingCollections } = useSelectedCollection('addressbook');
|
const { selected, isLoading: loadingCollections } = useSelectedCollection('addressbook');
|
||||||
const { contacts, isLoading, error } = useDavContacts(selected?.path ?? null);
|
const { contacts, isLoading, error } = useDavContacts(selected?.path ?? null);
|
||||||
|
const edit = useDavEdit(['caldav', 'contacts', selected?.path ?? null]);
|
||||||
|
const [editing, setEditing] = useState<string | null>(null);
|
||||||
|
const [draft, setDraft] = useState({ fullName: '', email: '', phone: '' });
|
||||||
|
const [confirming, setConfirming] = useState<string | null>(null);
|
||||||
|
const [draftNew, setDraftNew] = useState<ReturnType<typeof blankDraft> | null>(null);
|
||||||
|
const filter = useFilter('Filter contacts');
|
||||||
|
|
||||||
if (loadingCollections) return <Placeholder text="Loading address books…" />;
|
if (loadingCollections) return <Placeholder text="Loading address books…" />;
|
||||||
if (!selected) return <Placeholder text="No address books yet. Create one from a CardDAV client." />;
|
if (!selected) return <Placeholder text="No address books yet. Create one from a CardDAV client." />;
|
||||||
if (error) return <Placeholder text={t('dav.readFailed', { name: selected.displayName, reason: String(error) })} />;
|
|
||||||
if (isLoading) return <Placeholder text="Loading contacts…" />;
|
|
||||||
if (contacts.length === 0) return <Placeholder text={`${selected.displayName} has no contacts.`} />;
|
|
||||||
|
|
||||||
const sorted = [...contacts].sort((a, b) => a.fullName.localeCompare(b.fullName));
|
// Everything a person might half-remember about someone: the name, either address, the company.
|
||||||
|
const shown = contacts.filter((c) => matches(filter.query, c.fullName, c.org, c.title, ...c.emails, ...c.phones));
|
||||||
|
|
||||||
|
// Not an early return: the header carries the only way to ADD a card, and an empty address book is
|
||||||
|
// exactly when somebody wants it. A filter that matches nothing says SO — otherwise it reads as an
|
||||||
|
// address book that lost its contacts.
|
||||||
|
const notice = error
|
||||||
|
? t('dav.readFailed', { name: selected.displayName, reason: String(error) })
|
||||||
|
: isLoading
|
||||||
|
? 'Loading contacts…'
|
||||||
|
: contacts.length === 0
|
||||||
|
? `${selected.displayName} has no contacts.`
|
||||||
|
: shown.length === 0
|
||||||
|
? `No contact matches “${filter.query}”.`
|
||||||
|
: null;
|
||||||
|
const sorted = [...shown].sort((a, b) => a.fullName.localeCompare(b.fullName));
|
||||||
|
|
||||||
|
const submitNew = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!draftNew?.fullName.trim()) return;
|
||||||
|
void edit
|
||||||
|
.createContact(selected.path, {
|
||||||
|
fullName: draftNew.fullName.trim(),
|
||||||
|
emails: [draftNew.email].filter(Boolean),
|
||||||
|
phones: [draftNew.phone].filter(Boolean),
|
||||||
|
})
|
||||||
|
.then((ok) => ok && setDraftNew(null));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-y-auto">
|
<div className="flex h-full flex-col">
|
||||||
<div className="mx-auto grid max-w-4xl grid-cols-1 gap-3 p-6 sm:grid-cols-2">
|
<header className="flex shrink-0 items-center justify-between gap-3 border-b px-6 py-3">
|
||||||
{sorted.map((contact) => (
|
<div className="min-w-0">
|
||||||
<article key={contact.uid} className="flex gap-3 rounded-xl border bg-card p-3">
|
<div className="truncate text-sm font-semibold leading-tight">{selected.displayName}</div>
|
||||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-sky-500/15 text-sm font-semibold text-sky-600">
|
<div className="text-xs text-muted-foreground">
|
||||||
{initials(contact.fullName)}
|
{filter.query ? `${shown.length} of ${contacts.length}` : contacts.length}{' '}
|
||||||
|
{contacts.length === 1 ? 'contact' : 'contacts'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{filter.input}
|
||||||
|
<button
|
||||||
|
onClick={() => setDraftNew((d) => (d ? null : blankDraft()))}
|
||||||
|
className="flex shrink-0 items-center gap-1.5 rounded-lg bg-primary/10 px-3 py-1.5 text-xs font-medium text-primary transition-colors hover:bg-primary/20"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
New contact
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{/* Name, one email, one phone. A card can hold far more, and a CardDAV client can put it there —
|
||||||
|
this is the form for adding somebody from their business card, not for modelling a person. */}
|
||||||
|
{draftNew && (
|
||||||
|
<form onSubmit={submitNew} className="mx-auto flex max-w-4xl flex-col gap-3 px-6 pt-6">
|
||||||
|
<div className="flex flex-col gap-3 rounded-xl border bg-card p-4">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={draftNew.fullName}
|
||||||
|
onChange={(e) => setDraftNew({ ...draftNew, fullName: e.target.value })}
|
||||||
|
placeholder="Name"
|
||||||
|
className={FIELD}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<input
|
||||||
|
value={draftNew.email}
|
||||||
|
onChange={(e) => setDraftNew({ ...draftNew, email: e.target.value })}
|
||||||
|
placeholder="Email"
|
||||||
|
className={`${FIELD} min-w-0 flex-1 text-xs`}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={draftNew.phone}
|
||||||
|
onChange={(e) => setDraftNew({ ...draftNew, phone: e.target.value })}
|
||||||
|
placeholder="Phone"
|
||||||
|
className={`${FIELD} min-w-0 flex-1 text-xs tabular-nums`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={edit.busy === CREATING || !draftNew.fullName.trim()}
|
||||||
|
className="flex items-center gap-1 rounded-md bg-primary/10 px-3 py-1.5 text-xs font-medium text-primary disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Check className="h-3 w-3" /> {edit.busy === CREATING ? 'Creating…' : 'Create'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDraftNew(null)}
|
||||||
|
className="rounded-md px-2 py-1.5 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{edit.error && <span className="text-xs text-destructive">{edit.error}</span>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
</form>
|
||||||
<div className="truncate text-sm font-medium">{contact.fullName || '(no name)'}</div>
|
)}
|
||||||
{(contact.title || contact.org) && (
|
|
||||||
<div className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
|
{notice && <Placeholder text={notice} />}
|
||||||
<Building2 className="h-3 w-3 shrink-0" />
|
<div className="mx-auto grid max-w-4xl grid-cols-1 gap-3 p-6 sm:grid-cols-2">
|
||||||
<span className="truncate">{[contact.title, contact.org].filter(Boolean).join(' · ')}</span>
|
{sorted.map((contact) => (
|
||||||
|
<article key={contact.uid} className="flex gap-3 rounded-xl border bg-card p-3">
|
||||||
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-sky-500/15 text-sm font-semibold text-sky-600">
|
||||||
|
{initials(contact.fullName)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-sm font-medium">{contact.fullName || '(no name)'}</div>
|
||||||
|
{(contact.title || contact.org) && (
|
||||||
|
<div className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<Building2 className="h-3 w-3 shrink-0" />
|
||||||
|
<span className="truncate">{[contact.title, contact.org].filter(Boolean).join(' · ')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{contact.emails.map((email) => (
|
||||||
|
<a
|
||||||
|
key={email}
|
||||||
|
href={`mailto:${email}`}
|
||||||
|
className="mt-1 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Mail className="h-3 w-3 shrink-0" />
|
||||||
|
<span className="truncate">{email}</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
{contact.phones.map((phone) => (
|
||||||
|
<a
|
||||||
|
key={phone}
|
||||||
|
href={`tel:${phone}`}
|
||||||
|
className="mt-1 flex items-center gap-1 text-xs tabular-nums text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Phone className="h-3 w-3 shrink-0" />
|
||||||
|
<span className="truncate">{phone}</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Name, one email, one phone — the three fields worth correcting from a list. A card can
|
||||||
|
hold several of each; editing rewrites the FIRST and leaves the rest untouched, which
|
||||||
|
is why `setProperty` replaces a line rather than rebuilding the card. */}
|
||||||
|
{editing === contact.href && (
|
||||||
|
<form
|
||||||
|
className="mt-2 flex flex-col gap-2"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!contact.href) return;
|
||||||
|
/* The form shows the FIRST email and phone; a card can hold several. Sending just
|
||||||
|
the one would delete the rest, so the others are carried through untouched. */
|
||||||
|
void edit
|
||||||
|
.save(contact.href, {
|
||||||
|
fullName: draft.fullName,
|
||||||
|
emails: [draft.email, ...contact.emails.slice(1)].filter(Boolean),
|
||||||
|
phones: [draft.phone, ...contact.phones.slice(1)].filter(Boolean),
|
||||||
|
})
|
||||||
|
.then((ok) => ok && setEditing(null));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={draft.fullName}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, fullName: e.target.value }))}
|
||||||
|
placeholder="Name"
|
||||||
|
className="rounded-md border bg-background px-2 py-1 text-sm"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={draft.email}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, email: e.target.value }))}
|
||||||
|
placeholder="Email"
|
||||||
|
className="rounded-md border bg-background px-2 py-1 text-xs"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={draft.phone}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, phone: e.target.value }))}
|
||||||
|
placeholder="Phone"
|
||||||
|
className="rounded-md border bg-background px-2 py-1 text-xs tabular-nums"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={edit.busy === contact.href}
|
||||||
|
className="flex items-center gap-1 rounded-md bg-primary/10 px-2 py-1 text-xs text-primary disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Check className="h-3 w-3" /> {edit.busy === contact.href ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditing(null)}
|
||||||
|
className="rounded-md px-2 py-1 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{contact.href && editing !== contact.href && (
|
||||||
|
<div className="flex shrink-0 items-start gap-1">
|
||||||
|
{confirming === contact.href ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => void edit.remove(contact.href!).then(() => setConfirming(null))}
|
||||||
|
disabled={edit.busy === contact.href}
|
||||||
|
title="Delete this contact"
|
||||||
|
className="rounded p-1 text-destructive hover:bg-destructive/10 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setConfirming(null)} className="rounded p-1 opacity-50 hover:opacity-100">
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setDraft({
|
||||||
|
fullName: contact.fullName ?? '',
|
||||||
|
email: contact.emails[0] ?? '',
|
||||||
|
phone: contact.phones[0] ?? '',
|
||||||
|
});
|
||||||
|
setEditing(contact.href!);
|
||||||
|
}}
|
||||||
|
title="Edit"
|
||||||
|
className="rounded p-1 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirming(contact.href!)}
|
||||||
|
title="Delete"
|
||||||
|
className="rounded p-1 text-muted-foreground hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{contact.emails.map((email) => (
|
</article>
|
||||||
<a
|
))}
|
||||||
key={email}
|
</div>
|
||||||
href={`mailto:${email}`}
|
|
||||||
className="mt-1 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<Mail className="h-3 w-3 shrink-0" />
|
|
||||||
<span className="truncate">{email}</span>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
{contact.phones.map((phone) => (
|
|
||||||
<a
|
|
||||||
key={phone}
|
|
||||||
href={`tel:${phone}`}
|
|
||||||
className="mt-1 flex items-center gap-1 text-xs tabular-nums text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<Phone className="h-3 w-3 shrink-0" />
|
|
||||||
<span className="truncate">{phone}</span>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+18
-6
@@ -4,9 +4,12 @@
|
|||||||
// see src/servers/sidecar/caldav/{collections,ical}.ts, which is where they are produced. The sidecar
|
// see src/servers/sidecar/caldav/{collections,ical}.ts, which is where they are produced. The sidecar
|
||||||
// parses the iCalendar/vCard itself so nothing here ever sees DAV XML.
|
// parses the iCalendar/vCard itself so nothing here ever sees DAV XML.
|
||||||
|
|
||||||
/** One calendar or address book. `path` is the DAV path (/dav/<userId>/<name>/) and doubles as the id. */
|
/** One calendar or address book. `path` addresses it over DAV; `name` is what the URL selects it by. */
|
||||||
export type DavCollection = {
|
export type DavCollection = {
|
||||||
|
/** The DAV path, `/dav/<userId>/<name>/`. What every API call takes. */
|
||||||
path: string;
|
path: string;
|
||||||
|
/** The last segment of that path. Unique within a user, and the only part worth putting in a URL. */
|
||||||
|
name: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
kind: 'calendar' | 'addressbook';
|
kind: 'calendar' | 'addressbook';
|
||||||
color?: string;
|
color?: string;
|
||||||
@@ -14,6 +17,8 @@ export type DavCollection = {
|
|||||||
|
|
||||||
export type CalendarEvent = {
|
export type CalendarEvent = {
|
||||||
uid: string;
|
uid: string;
|
||||||
|
/** The DAV href — where this event lives. What edit and delete address; never derived from the uid. */
|
||||||
|
href?: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
location?: string;
|
location?: string;
|
||||||
@@ -27,6 +32,8 @@ export type CalendarEvent = {
|
|||||||
|
|
||||||
export type DavContact = {
|
export type DavContact = {
|
||||||
uid: string;
|
uid: string;
|
||||||
|
/** The DAV href — where this contact lives. What edit and delete address; never derived from the uid. */
|
||||||
|
href?: string;
|
||||||
fullName: string;
|
fullName: string;
|
||||||
emails: string[];
|
emails: string[];
|
||||||
phones: string[];
|
phones: string[];
|
||||||
@@ -36,12 +43,17 @@ export type DavContact = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The query param both screens select with. A collection id contains slashes, so it cannot be a path
|
* The query param both screens select with. Per the navigation rules the selection lives in the URL, and
|
||||||
* segment without escaping — and per the navigation rules the selection still has to live in the URL,
|
* a query param rather than a path segment because the detail IS the screen — there is nothing to route to.
|
||||||
* so it is a query param on the screen you are already on.
|
*
|
||||||
|
* IT CARRIES THE NAME, NOT THE PATH. `?collection=personal`, not
|
||||||
|
* `?collection=%2Fdav%2F1%2Fpersonal%2F`. The path is `/dav/<userId>/<name>/`, so the escaped version
|
||||||
|
* spent six characters on slashes to say `personal` and put the owner's user id in a URL people paste at
|
||||||
|
* each other. The name is unique within a user, which is all a selector needs; the full path is
|
||||||
|
* recovered from the collections list, which the screen has already loaded.
|
||||||
*/
|
*/
|
||||||
export const COLLECTION_PARAM = 'collection';
|
export const COLLECTION_PARAM = 'collection';
|
||||||
|
|
||||||
/** The one place the two screens' URLs are spelled, so nav links and guards cannot drift. */
|
/** The one place the two screens' URLs are spelled, so nav links and guards cannot drift. */
|
||||||
export const davCollectionPath = (base: '/calendar' | '/contacts', collection: string) =>
|
export const davCollectionPath = (base: '/calendar' | '/contacts', name: string) =>
|
||||||
`${base}?${COLLECTION_PARAM}=${encodeURIComponent(collection)}`;
|
`${base}?${COLLECTION_PARAM}=${encodeURIComponent(name)}`;
|
||||||
|
|||||||
+16
-2
@@ -62,6 +62,21 @@ export function useDavContacts(collection: string | null) {
|
|||||||
return { contacts: query.data?.contacts ?? EMPTY_CONTACTS, isLoading: query.isLoading, error: query.error };
|
return { contacts: query.data?.contacts ?? EMPTY_CONTACTS, isLoading: query.isLoading, error: query.error };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve what the URL asked for against what this user has.
|
||||||
|
*
|
||||||
|
* The param is the collection NAME. It also accepts a full DAV path, because that is what the URL used
|
||||||
|
* to carry and somebody's bookmark still has one — a one-line fallback against a link that would
|
||||||
|
* otherwise silently land on the wrong calendar.
|
||||||
|
*
|
||||||
|
* No match falls back to the first collection rather than to nothing, so a stale or mistyped link shows
|
||||||
|
* a calendar instead of an empty screen.
|
||||||
|
*/
|
||||||
|
export function matchCollection(collections: DavCollection[], fromUrl: string | null): DavCollection | null {
|
||||||
|
if (!fromUrl) return collections[0] ?? null;
|
||||||
|
return collections.find((c) => c.name === fromUrl || c.path === fromUrl) ?? collections[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The collection the URL selects, or the first one of that kind once the list arrives. Both panels of a
|
* The collection the URL selects, or the first one of that kind once the list arrives. Both panels of a
|
||||||
* screen call this and get the same answer, because the answer is derived from the URL and the query
|
* screen call this and get the same answer, because the answer is derived from the URL and the query
|
||||||
@@ -70,7 +85,6 @@ export function useDavContacts(collection: string | null) {
|
|||||||
export function useSelectedCollection(kind: DavCollection['kind']) {
|
export function useSelectedCollection(kind: DavCollection['kind']) {
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
const { collections, isLoading } = useDavCollections(kind);
|
const { collections, isLoading } = useDavCollections(kind);
|
||||||
const fromUrl = params.get(COLLECTION_PARAM);
|
const selected = matchCollection(collections, params.get(COLLECTION_PARAM));
|
||||||
const selected = collections.find((c) => c.path === fromUrl) ?? collections[0] ?? null;
|
|
||||||
return { selected, collections, isLoading };
|
return { selected, collections, isLoading };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creating, changing and deleting one DAV object, in FIELDS.
|
||||||
|
*
|
||||||
|
* Nothing here knows what an .ics looks like, and that is the point. This used to read the object's
|
||||||
|
* text, patch it in the browser and put it back, which meant the browser carried its own copy of the
|
||||||
|
* iCalendar escaping and 75-octet folding rules — a second place to be subtly wrong about a format
|
||||||
|
* neither screen displays. That work moved to the sidecar (see sidecar/patch.ts), which held the only
|
||||||
|
* other copy, and the agent skill now calls the same routes instead of writing a third one in bash.
|
||||||
|
*
|
||||||
|
* The guarantee it makes is unchanged: a patch replaces only the lines it owns, so alarms, attendees,
|
||||||
|
* recurrence rules and every unknown `X-` property survive an edit made from a form that cannot show them.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NewContact = {
|
||||||
|
fullName: string;
|
||||||
|
emails?: string[];
|
||||||
|
phones?: string[];
|
||||||
|
org?: string;
|
||||||
|
title?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** What a patch may set. Absent leaves a field alone; `null` removes it. */
|
||||||
|
export type ItemPatch = {
|
||||||
|
summary?: string;
|
||||||
|
location?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
start?: string;
|
||||||
|
end?: string;
|
||||||
|
allDay?: boolean;
|
||||||
|
fullName?: string;
|
||||||
|
emails?: string[];
|
||||||
|
phones?: string[];
|
||||||
|
org?: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** `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<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const refresh = () => qc.invalidateQueries({ queryKey: invalidateKey });
|
||||||
|
|
||||||
|
/** Every verb has the same shape: mark busy, do it, refresh, and report rather than throw. */
|
||||||
|
const run = async (key: string, fallback: string, work: () => Promise<unknown>) => {
|
||||||
|
setBusy(key);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await work();
|
||||||
|
await refresh();
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : fallback);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
busy,
|
||||||
|
error,
|
||||||
|
|
||||||
|
save: (href: string, patch: ItemPatch) =>
|
||||||
|
run(href, 'Could not save.', () => client.patch('/caldav/_officer/item', { href, ...patch })),
|
||||||
|
|
||||||
|
remove: (href: string) => run(href, 'Could not delete.', () => client.delete('/caldav/_officer/item', { href })),
|
||||||
|
|
||||||
|
createEvent: (collection: string, event: NewEvent) =>
|
||||||
|
run(CREATING, 'Could not create.', () => client.post('/caldav/_officer/event', { collection, ...event })),
|
||||||
|
|
||||||
|
createContact: (collection: string, contact: NewContact) =>
|
||||||
|
run(CREATING, 'Could not create.', () => client.post('/caldav/_officer/contact', { collection, ...contact })),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { matches } from './useFilter';
|
||||||
|
|
||||||
|
// The accent rule is the one that matters on this machine: an address book of Portuguese names is
|
||||||
|
// unsearchable if "jose" does not find "José".
|
||||||
|
|
||||||
|
describe('matches', () => {
|
||||||
|
test('an empty query keeps everything', () => {
|
||||||
|
expect(matches('', 'anything')).toBe(true);
|
||||||
|
expect(matches(' ', 'anything')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is case insensitive', () => {
|
||||||
|
expect(matches('RAMIRO', 'Cervejaria Ramiro')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores accents in BOTH directions', () => {
|
||||||
|
expect(matches('jose', 'José Conceição')).toBe(true);
|
||||||
|
expect(matches('josé', 'Jose Conceicao')).toBe(true);
|
||||||
|
expect(matches('conceicao', 'José Conceição')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every term must match, but they may land in different fields', () => {
|
||||||
|
expect(matches('ana lisboa', 'Ana Silva', 'Lisboa')).toBe(true);
|
||||||
|
expect(matches('ana porto', 'Ana Silva', 'Lisboa')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips absent fields rather than matching on "undefined"', () => {
|
||||||
|
expect(matches('undefined', 'Ana Silva', undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a date is searchable because start is an ISO string', () => {
|
||||||
|
expect(matches('2026-08-29', 'Dinner', undefined, undefined, '2026-08-29T19:00:00Z')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Search, X } from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A filter box for a list that is already in the browser.
|
||||||
|
*
|
||||||
|
* Client-side, deliberately. Both screens load a whole collection in one request and hold it — a
|
||||||
|
* calendar of a few hundred events, an address book of a few hundred cards — so filtering is a string
|
||||||
|
* comparison over data already in hand. A server round trip per keystroke would be slower, would need a
|
||||||
|
* search endpoint on the sidecar, and would show a spinner where a person expects a list to narrow.
|
||||||
|
*
|
||||||
|
* Matching is CASE- AND ACCENT-INSENSITIVE, and that second half is not decoration here: this owner's
|
||||||
|
* contacts are Portuguese. Typing "jose" must find "José" or the box is useless for half the address
|
||||||
|
* book. `NFD` splits a letter from its accent and the range strips what is left behind.
|
||||||
|
*
|
||||||
|
* Every term must match SOMEWHERE, not all in one field — so "ana lisboa" finds the Ana whose address is
|
||||||
|
* in Lisbon, which is how people actually search a contact list.
|
||||||
|
*/
|
||||||
|
const fold = (value: string) => value.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
|
||||||
|
|
||||||
|
export const matches = (query: string, ...fields: (string | undefined)[]): boolean => {
|
||||||
|
const terms = fold(query).split(/\s+/).filter(Boolean);
|
||||||
|
if (!terms.length) return true;
|
||||||
|
const haystack = fold(fields.filter(Boolean).join(' '));
|
||||||
|
return terms.every((term) => haystack.includes(term));
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useFilter(placeholder: string) {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
|
||||||
|
const input = (
|
||||||
|
<div className="relative min-w-0 flex-1 sm:max-w-64">
|
||||||
|
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="w-full rounded-md border bg-background py-1 pl-7 pr-7 text-xs"
|
||||||
|
/>
|
||||||
|
{query && (
|
||||||
|
<button
|
||||||
|
onClick={() => setQuery('')}
|
||||||
|
title="Clear"
|
||||||
|
className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded p-0.5 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return { query, input };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user