Calendar: edit and delete a single occurrence
Closes #132. Stalwart 0.16.20 accepts a synthetic id on `CalendarEvent/set`, writing a `recurrenceOverrides` entry rather than touching the series, so editing one date of a recurring event is now something the server does and this does too. Editing asks the scope *before* the form opens, because it decides which event the form is even about: a form populated from the master shows the series' start date, so editing Wednesday's standup would have offered to move Monday's. Deleting asks in place of the old confirm. The patch is narrowed rather than posted hopefully. 0.16.20 sorts per-occurrence properties into three groups and only one is honest: ten are refused with `invalidProperties`, twelve more are dropped from the patch while the response still reports success, and the rest are applied. That silent middle group is how #26 reached a live server - a successful response is not evidence anything was written - so `occurrencePatch` throws on the first group, reports the second to the caller, and the editor leaves out the five it always sends. A patch that would be entirely dropped is not sent at all. The refusal for an occurrence of a this-and-future change offers the series instead of a bare error toast. Nothing here writes one of those, but an event synced from another client can carry one. Two things the scope prompt cost, both worth knowing. A dialog is queued in a store the moment it is asked for, so it outlives the effect that asked: without a ref guard a remount queues a second prompt the first answer cannot retract. And gating the *answer* on the effect's cleanup flag is worse - StrictMode runs mount, cleanup, mount, so the flag is already set by the time anyone clicks and the editor never opens. The mock expands recurrences for the first time, which is what makes any of this developable. It hands out synthetic ids for everything including one-offs, gives occurrences a `recurrenceId` and no rule, and reproduces the refusals - including the silent drops, since a mock that applied them would let a client that sends them look correct everywhere but a real server.
This commit is contained in:
+172
-9
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, splitOccurrencePatch, syntheticId, type Occurrence } from "./recurrence.js";
|
||||||
import { parseOtpauthUrl, verifyTotp } from "../totp.js";
|
import { parseOtpauthUrl, verifyTotp } from "../totp.js";
|
||||||
import { holdUntilOf, undoStatusOf } from "./futurerelease.js";
|
import { holdUntilOf, undoStatusOf } from "./futurerelease.js";
|
||||||
|
|
||||||
@@ -399,6 +400,24 @@ function genericGet(list: Obj[]) {
|
|||||||
return { accountId: ACCOUNT, state: String(state.n), list: found.map((x) => pick(x, a.properties as string[] | null)), notFound: ids ? ids.filter((id) => !list.some((x) => x.id === id)) : [] };
|
return { accountId: ACCOUNT, state: String(state.n), list: found.map((x) => pick(x, a.properties as string[] | null)), notFound: ids ? ids.filter((id) => !list.some((x) => x.id === id)) : [] };
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* An id, as either a stored event or one occurrence of one.
|
||||||
|
*
|
||||||
|
* A synthetic id whose base is gone, or whose index falls outside the series
|
||||||
|
* (deleted, or past a `count`), resolves to nothing — `notFound`, the way the
|
||||||
|
* server answers for an occurrence that is not there any more.
|
||||||
|
*/
|
||||||
|
function resolveEvent(list: Obj[], id: string): { base: Obj; occ?: Occurrence } | null {
|
||||||
|
const direct = list.find((x) => x.id === id);
|
||||||
|
if (direct) return { base: direct };
|
||||||
|
const parsed = parseSyntheticId(id);
|
||||||
|
if (!parsed) return null;
|
||||||
|
const base = list.find((x) => x.id === parsed.baseId);
|
||||||
|
if (!base) return null;
|
||||||
|
const occ = occurrenceAt(base, parsed.index);
|
||||||
|
return occ ? { base, occ } : null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Thrown from an onCreate hook to refuse a create the way a real server would. */
|
/** Thrown from an onCreate hook to refuse a create the way a real server would. */
|
||||||
class SetError extends Error {
|
class SetError extends Error {
|
||||||
constructor(readonly type: string, readonly description: string, readonly properties?: string[]) { super(description); }
|
constructor(readonly type: string, readonly description: string, readonly properties?: string[]) { super(description); }
|
||||||
@@ -436,6 +455,125 @@ function genericSet(list: Obj[], prefix: string, onCreate?: (o: Obj) => void) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- calendar events ---------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `CalendarEvent/set`, including the synthetic-id handling 0.16.20 added.
|
||||||
|
*
|
||||||
|
* An update or destroy aimed at an occurrence does not touch the series: it
|
||||||
|
* writes a `recurrenceOverrides` entry keyed by that date, exactly as Stalwart
|
||||||
|
* does — `{ excluded: true }` for a destroy, the patch merged in for an update.
|
||||||
|
*
|
||||||
|
* The refusals are the point of reproducing this at all:
|
||||||
|
*
|
||||||
|
* - a base event and one of its instances in the same request is refused, both
|
||||||
|
* ids at once, because the server cannot apply them in a defined order;
|
||||||
|
* - the same id twice is "Duplicate event id.";
|
||||||
|
* - the ten event-level properties are refused with `invalidProperties`;
|
||||||
|
* - and the twelve inherited ones are dropped in silence, with the response
|
||||||
|
* still saying the update succeeded. A mock that applied them would let a
|
||||||
|
* client that sends them look correct everywhere except a real server.
|
||||||
|
*/
|
||||||
|
function calendarEventSet(a: Obj) {
|
||||||
|
const created: Obj = {};
|
||||||
|
const updated: Obj = {};
|
||||||
|
const destroyed: string[] = [];
|
||||||
|
const notCreated: Obj = {};
|
||||||
|
const notUpdated: Obj = {};
|
||||||
|
const notDestroyed: Obj = {};
|
||||||
|
|
||||||
|
for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) {
|
||||||
|
const o: Obj = { ...(obj as Obj), id: `ev${randomUUID().slice(0, 6)}` };
|
||||||
|
// Stalwart 0.16 rejects the RFC 8984 array outright and silently discards
|
||||||
|
// participants addressed the RFC 8984 way. The mock did neither, which is
|
||||||
|
// how #26 and #30 reached a live server unnoticed — so it does both.
|
||||||
|
if (o.recurrenceRules) { notCreated[cid] = new SetError("invalidProperties", "Invalid property.", ["recurrenceRules"]).toJSON(); continue; }
|
||||||
|
const parts = o.participants as Record<string, Obj> | undefined;
|
||||||
|
if (parts && Object.values(parts).some((p) => !p.calendarAddress)) delete o.participants;
|
||||||
|
if (o.replyTo && !o.organizerCalendarAddress) delete o.replyTo;
|
||||||
|
o.uid = o.uid ?? randomUUID();
|
||||||
|
events.push(o);
|
||||||
|
created[cid] = { id: o.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates = Object.entries((a.update as Obj) ?? {});
|
||||||
|
const destroys = ((a.destroy as string[]) ?? []).slice();
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
/* A base and one of its instances cannot be settled in the same request. */
|
||||||
|
const baseOf = (id: string): string | null => {
|
||||||
|
const r = resolveEvent(events, id);
|
||||||
|
return r ? (r.base.id as string) : null;
|
||||||
|
};
|
||||||
|
const touched = new Map<string, { base: string[]; instance: string[] }>();
|
||||||
|
for (const id of [...updates.map(([id]) => id), ...destroys]) {
|
||||||
|
const b = baseOf(id);
|
||||||
|
if (!b) continue;
|
||||||
|
const entry = touched.get(b) ?? { base: [], instance: [] };
|
||||||
|
(parseSyntheticId(id) ? entry.instance : entry.base).push(id);
|
||||||
|
touched.set(b, entry);
|
||||||
|
}
|
||||||
|
const conflicted = new Set<string>();
|
||||||
|
for (const [, e] of touched) {
|
||||||
|
if (e.base.length && e.instance.length) for (const id of [...e.base, ...e.instance]) conflicted.add(id);
|
||||||
|
}
|
||||||
|
const conflict = () => new SetError("invalidProperties", "A base event and its instances cannot be modified in the same request.", ["id"]).toJSON();
|
||||||
|
|
||||||
|
for (const [id, patch] of updates) {
|
||||||
|
if (conflicted.has(id)) { notUpdated[id] = conflict(); continue; }
|
||||||
|
if (seen.has(id)) { notUpdated[id] = new SetError("invalidProperties", "Duplicate event id.", ["id"]).toJSON(); continue; }
|
||||||
|
seen.add(id);
|
||||||
|
const resolved = resolveEvent(events, id);
|
||||||
|
if (!resolved) { notUpdated[id] = { type: "notFound" }; continue; }
|
||||||
|
if (!resolved.occ) { applyPatch(resolved.base, patch as Obj); updated[id] = null; continue; }
|
||||||
|
const { rejected, applied } = splitOccurrencePatch(patch as Obj);
|
||||||
|
if (rejected) { notUpdated[id] = new SetError("invalidProperties", "This property cannot be modified on a single occurrence.", [rejected]).toJSON(); continue; }
|
||||||
|
writeOverride(resolved.base, resolved.occ, applied);
|
||||||
|
updated[id] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of destroys) {
|
||||||
|
if (conflicted.has(id)) { notDestroyed[id] = conflict(); continue; }
|
||||||
|
const resolved = resolveEvent(events, id);
|
||||||
|
if (!resolved) { notDestroyed[id] = { type: "notFound" }; continue; }
|
||||||
|
if (resolved.occ) {
|
||||||
|
// One date off a series, which is an override rather than a deletion.
|
||||||
|
writeOverride(resolved.base, resolved.occ, { excluded: true }, true);
|
||||||
|
destroyed.push(id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const i = events.findIndex((x) => x.id === id);
|
||||||
|
if (i >= 0) { events.splice(i, 1); destroyed.push(id); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return setResp({
|
||||||
|
created, updated, destroyed,
|
||||||
|
...(Object.keys(notCreated).length ? { notCreated } : {}),
|
||||||
|
...(Object.keys(notUpdated).length ? { notUpdated } : {}),
|
||||||
|
...(Object.keys(notDestroyed).length ? { notDestroyed } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge a patch into the override for one date.
|
||||||
|
*
|
||||||
|
* Stalwart fills `start` and `duration` in when the patch leaves them out, so
|
||||||
|
* an override always carries its own timing; the mock does the same, or a
|
||||||
|
* client could depend on inheriting them and be right only here.
|
||||||
|
*/
|
||||||
|
function writeOverride(base: Obj, occ: Occurrence, patch: Obj, replace = false) {
|
||||||
|
const overrides = (base.recurrenceOverrides as Record<string, Obj> | undefined) ?? {};
|
||||||
|
const existing = replace ? {} : (overrides[occ.recurrenceId] ?? {});
|
||||||
|
const next: Obj = { ...existing };
|
||||||
|
if (!replace) {
|
||||||
|
if (!("start" in next)) next.start = occ.start;
|
||||||
|
if (!("duration" in next) && base.duration) next.duration = base.duration;
|
||||||
|
}
|
||||||
|
applyPatch(next, patch);
|
||||||
|
overrides[occ.recurrenceId] = next;
|
||||||
|
base.recurrenceOverrides = overrides;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- submissions ---------- */
|
/* ---------- submissions ---------- */
|
||||||
/**
|
/**
|
||||||
* Held messages, the way Stalwart models them: `sendAt` is derived from the
|
* Held messages, the way Stalwart models them: `sendAt` is derived from the
|
||||||
@@ -774,18 +912,43 @@ const handlers: Record<string, Handler> = {
|
|||||||
"SieveScript/validate": () => ({ accountId: ACCOUNT, error: null }),
|
"SieveScript/validate": () => ({ accountId: ACCOUNT, error: null }),
|
||||||
"Calendar/get": (a) => hideShareWithUnlessAsked(a, genericGet(calendarsFor(a.accountId))(a) as { list: Obj[] }) as never,
|
"Calendar/get": (a) => hideShareWithUnlessAsked(a, genericGet(calendarsFor(a.accountId))(a) as { list: Obj[] }) as never,
|
||||||
"Calendar/set": (a) => genericSet(calendarsFor(a.accountId), "c", (o) => Object.assign(o, { color: "#0f766e", isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", timeZone: null, shareWith: null, myRights: rightsCal(), description: null, sortOrder: 0, ...o }))(a),
|
"Calendar/set": (a) => genericSet(calendarsFor(a.accountId), "c", (o) => Object.assign(o, { color: "#0f766e", isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", timeZone: null, shareWith: null, myRights: rightsCal(), description: null, sortOrder: 0, ...o }))(a),
|
||||||
"CalendarEvent/query": (a) => { const list = eventsFor(a.accountId); return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.filter((e) => !(a.filter as Obj)?.uid || e.uid === (a.filter as Obj).uid).map((e) => e.id), total: list.length }; },
|
/*
|
||||||
"CalendarEvent/get": (a) => genericGet(eventsFor(a.accountId))(a),
|
* With `expandRecurrences` every id that comes back is synthetic — a one-off
|
||||||
|
* included, which is what a live 0.16.19 does and what makes `baseEventId`
|
||||||
|
* useless as a test for a series. Without it (the `findByUid` path) the
|
||||||
|
* stored ids come back untouched, because callers hand those straight to a
|
||||||
|
* destroy and mean the whole event.
|
||||||
|
*/
|
||||||
|
"CalendarEvent/query": (a) => {
|
||||||
|
const list = eventsFor(a.accountId);
|
||||||
|
const filter = (a.filter as Obj) ?? {};
|
||||||
|
const matching = list.filter((e) => !filter.uid || e.uid === filter.uid);
|
||||||
|
if (!a.expandRecurrences) {
|
||||||
|
return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: matching.map((e) => e.id), total: matching.length };
|
||||||
|
}
|
||||||
|
const from = filter.after ? new Date(filter.after as string) : new Date(-8640000000000);
|
||||||
|
const to = filter.before ? new Date(filter.before as string) : new Date(8640000000000);
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (const e of matching) for (const occ of expandOccurrences(e, from, to)) ids.push(syntheticId(e.id as string, occ.index));
|
||||||
|
return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids, total: ids.length };
|
||||||
|
},
|
||||||
|
"CalendarEvent/get": (a) => {
|
||||||
|
const list = eventsFor(a.accountId);
|
||||||
|
const ids = a.ids as string[] | null | undefined;
|
||||||
|
if (!ids) return genericGet(list)(a);
|
||||||
|
const found: Obj[] = [];
|
||||||
|
const notFound: string[] = [];
|
||||||
|
for (const id of ids) {
|
||||||
|
const resolved = resolveEvent(list, id);
|
||||||
|
if (!resolved) { notFound.push(id); continue; }
|
||||||
|
found.push(resolved.occ ? occurrenceView(resolved.base, resolved.occ) : resolved.base);
|
||||||
|
}
|
||||||
|
return { accountId: ACCOUNT, state: String(state.n), list: found.map((x) => pick(x, a.properties as string[] | null)), notFound };
|
||||||
|
},
|
||||||
// Stalwart 0.16 rejects the RFC 8984 array outright and silently discards
|
// Stalwart 0.16 rejects the RFC 8984 array outright and silently discards
|
||||||
// participants addressed the RFC 8984 way. The mock did neither, which is how
|
// participants addressed the RFC 8984 way. The mock did neither, which is how
|
||||||
// #26 and #30 reached a live server unnoticed — so it now does both.
|
// #26 and #30 reached a live server unnoticed — so it now does both.
|
||||||
"CalendarEvent/set": genericSet(events, "ev", (o) => {
|
"CalendarEvent/set": (a) => calendarEventSet(a),
|
||||||
if (o.recurrenceRules) throw new SetError("invalidProperties", "Invalid property.", ["recurrenceRules"]);
|
|
||||||
const parts = o.participants as Record<string, Obj> | undefined;
|
|
||||||
if (parts && Object.values(parts).some((p) => !p.calendarAddress)) delete o.participants;
|
|
||||||
if (o.replyTo && !o.organizerCalendarAddress) delete o.replyTo;
|
|
||||||
return Object.assign(o, { uid: o.uid ?? randomUUID() });
|
|
||||||
}),
|
|
||||||
"CalendarEvent/parse": (a) => { const parsed: Obj = {}; for (const b of a.blobIds as string[]) { const blob = blobs.get(b); if (!blob) continue; const t = blob.data.toString(); const g = (k: string) => new RegExp(`^${k}[^:]*:(.*)$`, "m").exec(t)?.[1]?.trim(); const ds = g("DTSTART") ?? "20260101T000000Z"; const de = g("DTEND") ?? ds; const toLocal = (s: string) => `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}T${s.slice(9, 11)}:${s.slice(11, 13)}:00`; const start = new Date(`${toLocal(ds)}Z`); const end = new Date(`${toLocal(de)}Z`); parsed[b] = { "@type": "Event", uid: g("UID"), title: g("SUMMARY"), start: toLocal(ds), timeZone: "Etc/UTC", duration: `PT${Math.round((end.getTime() - start.getTime()) / 60000)}M`, method: g("METHOD"), locations: g("LOCATION") ? { l: { name: g("LOCATION") } } : undefined, participants: { org: { name: "Ada Lovelace", calendarAddress: "mailto:[email protected]", roles: { owner: true } }, me: { name: "Demo User", calendarAddress: `mailto:${USER}`, roles: { attendee: true, required: true }, participationStatus: "needs-action" } } }; } return { accountId: ACCOUNT, parsed, notParsable: [] }; },
|
"CalendarEvent/parse": (a) => { const parsed: Obj = {}; for (const b of a.blobIds as string[]) { const blob = blobs.get(b); if (!blob) continue; const t = blob.data.toString(); const g = (k: string) => new RegExp(`^${k}[^:]*:(.*)$`, "m").exec(t)?.[1]?.trim(); const ds = g("DTSTART") ?? "20260101T000000Z"; const de = g("DTEND") ?? ds; const toLocal = (s: string) => `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}T${s.slice(9, 11)}:${s.slice(11, 13)}:00`; const start = new Date(`${toLocal(ds)}Z`); const end = new Date(`${toLocal(de)}Z`); parsed[b] = { "@type": "Event", uid: g("UID"), title: g("SUMMARY"), start: toLocal(ds), timeZone: "Etc/UTC", duration: `PT${Math.round((end.getTime() - start.getTime()) / 60000)}M`, method: g("METHOD"), locations: g("LOCATION") ? { l: { name: g("LOCATION") } } : undefined, participants: { org: { name: "Ada Lovelace", calendarAddress: "mailto:[email protected]", roles: { owner: true } }, me: { name: "Demo User", calendarAddress: `mailto:${USER}`, roles: { attendee: true, required: true }, participationStatus: "needs-action" } } }; } return { accountId: ACCOUNT, parsed, notParsable: [] }; },
|
||||||
"ParticipantIdentity/get": genericGet(participantIdentities),
|
"ParticipantIdentity/get": genericGet(participantIdentities),
|
||||||
"Principal/query": () => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: principals.map((p) => p.id) }),
|
"Principal/query": () => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: principals.map((p) => p.id) }),
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { describe, it } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, splitOccurrencePatch, syntheticId } from "./recurrence.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mock expands recurrences so that per-occurrence editing can be developed
|
||||||
|
* against something. What it has to get right is not the expansion — that is
|
||||||
|
* the easy half — but the three things a live server does that a client will
|
||||||
|
* otherwise be written against wrongly:
|
||||||
|
*
|
||||||
|
* - every expanded id is synthetic, one-offs included;
|
||||||
|
* - an occurrence carries a `recurrenceId` and no rule;
|
||||||
|
* - a per-occurrence patch loses some properties in silence.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const WEEKDAYS = { "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ day: "mo" }, { day: "tu" }, { day: "we" }, { day: "th" }, { day: "fr" }] };
|
||||||
|
|
||||||
|
/** A standup at 09:00 every weekday, starting Monday 2026-09-07. */
|
||||||
|
const series = () => ({ id: "ev1", "@type": "Event", uid: "u1", title: "Standup", start: "2026-09-07T09:00:00", duration: "PT30M", recurrenceRule: WEEKDAYS } as Record<string, unknown>);
|
||||||
|
const oneOff = () => ({ id: "ev2", "@type": "Event", uid: "u2", title: "Lunch", start: "2026-09-08T12:00:00", duration: "PT1H" } as Record<string, unknown>);
|
||||||
|
|
||||||
|
const week = (from: string, to: string) => [new Date(from), new Date(to)] as const;
|
||||||
|
|
||||||
|
describe("expandOccurrences", () => {
|
||||||
|
it("gives a weekday rule five dates in a week and skips the weekend", () => {
|
||||||
|
const [a, b] = week("2026-09-07T00:00:00", "2026-09-14T00:00:00");
|
||||||
|
const out = expandOccurrences(series(), a, b);
|
||||||
|
assert.deepEqual(out.map((o) => o.start), [
|
||||||
|
"2026-09-07T09:00:00", "2026-09-08T09:00:00", "2026-09-09T09:00:00",
|
||||||
|
"2026-09-10T09:00:00", "2026-09-11T09:00:00",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives a one-off exactly one occurrence, at index 0", () => {
|
||||||
|
const [a, b] = week("2026-09-01T00:00:00", "2026-10-01T00:00:00");
|
||||||
|
const out = expandOccurrences(oneOff(), a, b);
|
||||||
|
assert.equal(out.length, 1);
|
||||||
|
assert.equal(out[0]!.index, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honours count", () => {
|
||||||
|
const ev = { ...series(), recurrenceRule: { ...WEEKDAYS, count: 3 } };
|
||||||
|
const [a, b] = week("2026-09-07T00:00:00", "2026-10-01T00:00:00");
|
||||||
|
assert.equal(expandOccurrences(ev, a, b).length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips an excluded date but does not renumber the ones after it", () => {
|
||||||
|
// The whole reason an index rather than a position is the id: deleting
|
||||||
|
// Tuesday must not turn Wednesday's id into Tuesday's.
|
||||||
|
const ev = { ...series(), recurrenceOverrides: { "2026-09-08T09:00:00": { excluded: true } } };
|
||||||
|
const [a, b] = week("2026-09-07T00:00:00", "2026-09-14T00:00:00");
|
||||||
|
const out = expandOccurrences(ev, a, b);
|
||||||
|
assert.deepEqual(out.map((o) => o.start), [
|
||||||
|
"2026-09-07T09:00:00", "2026-09-09T09:00:00", "2026-09-10T09:00:00", "2026-09-11T09:00:00",
|
||||||
|
]);
|
||||||
|
// Wednesday is still index 2, as it was before Tuesday went.
|
||||||
|
assert.equal(out[1]!.index, 2);
|
||||||
|
assert.equal(occurrenceAt(ev, 2)!.start, "2026-09-09T09:00:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries an override onto the occurrence it keys", () => {
|
||||||
|
const ev = { ...series(), recurrenceOverrides: { "2026-09-09T09:00:00": { title: "Standup (long)" } } };
|
||||||
|
const [a, b] = week("2026-09-07T00:00:00", "2026-09-14T00:00:00");
|
||||||
|
const out = expandOccurrences(ev, a, b);
|
||||||
|
assert.deepEqual(out.find((o) => o.start === "2026-09-09T09:00:00")!.override, { title: "Standup (long)" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("occurrenceView", () => {
|
||||||
|
it("strips the rule, sets recurrenceId, and points baseEventId at the master", () => {
|
||||||
|
const base = series();
|
||||||
|
const occ = occurrenceAt(base, 1)!;
|
||||||
|
const view = occurrenceView(base, occ);
|
||||||
|
assert.equal(view.id, syntheticId("ev1", 1));
|
||||||
|
assert.equal(view.baseEventId, "ev1");
|
||||||
|
assert.equal(view.recurrenceId, "2026-09-08T09:00:00");
|
||||||
|
assert.equal(view.recurrenceRule, undefined);
|
||||||
|
assert.equal(view.recurrenceOverrides, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives a one-off a synthetic id over a different base, and no recurrenceId", () => {
|
||||||
|
// Both halves matter. The id is why `baseEventId` proves nothing about a
|
||||||
|
// series; the absent `recurrenceId` is why a one-off does not read as one.
|
||||||
|
const base = oneOff();
|
||||||
|
const view = occurrenceView(base, occurrenceAt(base, 0)!);
|
||||||
|
assert.equal(view.id, "ev2-o0");
|
||||||
|
assert.equal(view.baseEventId, "ev2");
|
||||||
|
assert.notEqual(view.id, view.baseEventId);
|
||||||
|
assert.equal(view.recurrenceId, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets an override win over the series", () => {
|
||||||
|
const base = { ...series(), recurrenceOverrides: { "2026-09-08T09:00:00": { title: "Moved" } } };
|
||||||
|
const view = occurrenceView(base, occurrenceAt(base, 1)!);
|
||||||
|
assert.equal(view.title, "Moved");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseSyntheticId", () => {
|
||||||
|
it("round-trips", () => {
|
||||||
|
assert.deepEqual(parseSyntheticId(syntheticId("ev1", 12)), { baseId: "ev1", index: 12 });
|
||||||
|
});
|
||||||
|
it("does not claim a stored id", () => {
|
||||||
|
assert.equal(parseSyntheticId("ev1"), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("splitOccurrencePatch", () => {
|
||||||
|
it("applies what an occurrence takes", () => {
|
||||||
|
const { rejected, applied } = splitOccurrencePatch({ title: "Just today", color: "#f00" });
|
||||||
|
assert.equal(rejected, undefined);
|
||||||
|
assert.deepEqual(applied, { title: "Just today", color: "#f00" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an event-level property by name", () => {
|
||||||
|
assert.equal(splitOccurrencePatch({ calendarIds: { c2: true } }).rejected, "calendarIds");
|
||||||
|
assert.equal(splitOccurrencePatch({ hideAttendees: true }).rejected, "hideAttendees");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops an inherited property in silence, which is the dangerous half", () => {
|
||||||
|
// No `rejected`, nothing applied, and a real server would still answer
|
||||||
|
// "updated". Anything that trusts the response believes this landed.
|
||||||
|
const { rejected, applied } = splitOccurrencePatch({ privacy: "private", recurrenceRule: null });
|
||||||
|
assert.equal(rejected, undefined);
|
||||||
|
assert.deepEqual(applied, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("judges a pointer patch on its first token", () => {
|
||||||
|
assert.deepEqual(splitOccurrencePatch({ "participants/me/participationStatus": "accepted" }).applied,
|
||||||
|
{ "participants/me/participationStatus": "accepted" });
|
||||||
|
assert.deepEqual(splitOccurrencePatch({ "participants/me/calendarAddress": "mailto:x@y" }).applied, {});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
/**
|
||||||
|
* Enough recurrence expansion for the mock to behave like Stalwart 0.16.20.
|
||||||
|
*
|
||||||
|
* The mock used to hand a recurring event back once, as its stored self. Three
|
||||||
|
* things that only a live server showed were therefore impossible to develop
|
||||||
|
* against, and all three had already cost a debugging session:
|
||||||
|
*
|
||||||
|
* - an expanded query gives *everything* a synthetic id over a `baseEventId`,
|
||||||
|
* a one-off included, so `baseEventId` is no evidence of a series;
|
||||||
|
* - an occurrence carries a `recurrenceId` and no rule of its own;
|
||||||
|
* - 0.16.20 takes a write aimed at a synthetic id and turns it into a
|
||||||
|
* `recurrenceOverrides` entry rather than touching the series.
|
||||||
|
*
|
||||||
|
* A mock that agrees with the client rather than with the server is how #26 and
|
||||||
|
* #30 reached a live instance, so the refusals matter as much as the successes:
|
||||||
|
* what Stalwart rejects is rejected here, and what it drops in silence is
|
||||||
|
* dropped here, in silence, on purpose.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Obj = Record<string, unknown>;
|
||||||
|
|
||||||
|
/** How far the expander will walk before giving up on a rule. */
|
||||||
|
const MAX_ITERATIONS = 750;
|
||||||
|
|
||||||
|
const DAYS = ["su", "mo", "tu", "we", "th", "fr", "sa"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The id an occurrence is addressed by.
|
||||||
|
*
|
||||||
|
* Stalwart's are opaque; the mock's are parseable because it has to resolve
|
||||||
|
* them, and nothing in ihasmail may read either. The index counts from the
|
||||||
|
* start of the series and survives an excluded date, so an id keeps meaning the
|
||||||
|
* same occurrence after one of its neighbours is deleted.
|
||||||
|
*/
|
||||||
|
export const syntheticId = (baseId: string, index: number): string => `${baseId}-o${index}`;
|
||||||
|
|
||||||
|
export function parseSyntheticId(id: string): { baseId: string; index: number } | null {
|
||||||
|
const m = /^(.+)-o(\d+)$/.exec(id);
|
||||||
|
return m ? { baseId: m[1]!, index: Number(m[2]) } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `2026-08-31T09:00:00` — the naive local form the mock stores `start` in. */
|
||||||
|
export function localDateTime(d: Date): string {
|
||||||
|
const p = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseLocal = (s: string): Date => new Date(s);
|
||||||
|
|
||||||
|
export interface Occurrence {
|
||||||
|
index: number;
|
||||||
|
/** The slot in the series this instance fills, which keys any override. */
|
||||||
|
recurrenceId: string;
|
||||||
|
start: string;
|
||||||
|
/** Set when a `recurrenceOverrides` entry applies to this date. */
|
||||||
|
override?: Obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Rule {
|
||||||
|
frequency?: string;
|
||||||
|
interval?: number;
|
||||||
|
count?: number;
|
||||||
|
until?: string;
|
||||||
|
byDay?: { day: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every occurrence of `base` between `from` and `to`, in series order.
|
||||||
|
*
|
||||||
|
* An event with no rule has exactly one, at index 0 — which is what gives a
|
||||||
|
* one-off the synthetic id a real server would give it.
|
||||||
|
*/
|
||||||
|
export function expandOccurrences(base: Obj, from: Date, to: Date): Occurrence[] {
|
||||||
|
const overrides = (base.recurrenceOverrides as Record<string, Obj> | undefined) ?? {};
|
||||||
|
const startStr = base.start as string;
|
||||||
|
if (!startStr) return [];
|
||||||
|
const first = parseLocal(startStr);
|
||||||
|
const rule = base.recurrenceRule as Rule | undefined;
|
||||||
|
|
||||||
|
const out: Occurrence[] = [];
|
||||||
|
const emit = (index: number, at: Date): boolean => {
|
||||||
|
const recurrenceId = localDateTime(at);
|
||||||
|
const override = overrides[recurrenceId];
|
||||||
|
// An excluded date still consumes its index: ids have to stay stable when a
|
||||||
|
// neighbour is deleted, or every occurrence after it silently renumbers.
|
||||||
|
if (override?.excluded === true) return true;
|
||||||
|
if (at >= from && at < to) {
|
||||||
|
out.push({ index, recurrenceId, start: recurrenceId, ...(override ? { override } : {}) });
|
||||||
|
}
|
||||||
|
return at < to;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!rule?.frequency) {
|
||||||
|
emit(0, first);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const interval = Math.max(1, rule.interval ?? 1);
|
||||||
|
const until = rule.until ? parseLocal(rule.until) : null;
|
||||||
|
const byDay = rule.byDay?.length ? new Set(rule.byDay.map((d) => d.day.toLowerCase())) : null;
|
||||||
|
|
||||||
|
let index = 0;
|
||||||
|
let emitted = 0;
|
||||||
|
const cursor = new Date(first);
|
||||||
|
|
||||||
|
for (let step = 0; step < MAX_ITERATIONS; step++) {
|
||||||
|
if (until && cursor > until) break;
|
||||||
|
if (rule.count != null && emitted >= rule.count) break;
|
||||||
|
|
||||||
|
const matches = !byDay || byDay.has(DAYS[cursor.getDay()]!);
|
||||||
|
if (matches) {
|
||||||
|
emitted++;
|
||||||
|
const keepGoing = emit(index, new Date(cursor));
|
||||||
|
index++;
|
||||||
|
if (!keepGoing) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A rule with byDay walks day by day and keeps the days it names; without
|
||||||
|
// one it steps by its own frequency.
|
||||||
|
if (byDay) cursor.setDate(cursor.getDate() + 1);
|
||||||
|
else if (rule.frequency === "daily") cursor.setDate(cursor.getDate() + interval);
|
||||||
|
else if (rule.frequency === "weekly") cursor.setDate(cursor.getDate() + 7 * interval);
|
||||||
|
else if (rule.frequency === "monthly") cursor.setMonth(cursor.getMonth() + interval);
|
||||||
|
else if (rule.frequency === "yearly") cursor.setFullYear(cursor.getFullYear() + interval);
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fields that describe the series and never travel down to one instance. */
|
||||||
|
const SERIES_ONLY = ["recurrenceRule", "recurrenceRules", "excludedRecurrenceRules", "recurrenceOverrides"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The object a `CalendarEvent/get` returns for one occurrence.
|
||||||
|
*
|
||||||
|
* The rule is stripped, `recurrenceId` is set, and `baseEventId` points at the
|
||||||
|
* master — so an occurrence is recognisable by its `recurrenceId` and by
|
||||||
|
* nothing else, which is the shape `isRecurring` was written against.
|
||||||
|
*/
|
||||||
|
export function occurrenceView(base: Obj, occ: Occurrence): Obj {
|
||||||
|
const view: Obj = { ...base };
|
||||||
|
for (const k of SERIES_ONLY) delete view[k];
|
||||||
|
Object.assign(view, occ.override ?? {});
|
||||||
|
view.id = syntheticId(base.id as string, occ.index);
|
||||||
|
view.baseEventId = base.id;
|
||||||
|
view.start = occ.start;
|
||||||
|
// Only a genuine instance of a series carries one. A one-off expanded into
|
||||||
|
// its single occurrence does not, or every one-off would look recurring.
|
||||||
|
if (base.recurrenceRule) view.recurrenceId = occ.recurrenceId;
|
||||||
|
delete view.excluded;
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- what a single occurrence will not take ---------- */
|
||||||
|
|
||||||
|
/** Refused outright, with `invalidProperties`. */
|
||||||
|
export const OCCURRENCE_REJECTED = new Set([
|
||||||
|
"baseEventId", "calendarIds", "isDraft", "isOrigin", "utcStart", "utcEnd",
|
||||||
|
"useDefaultAlerts", "mayInviteSelf", "mayInviteOthers", "hideAttendees",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dropped from the patch, with the response still reporting success.
|
||||||
|
*
|
||||||
|
* This is the half that has to be reproduced most carefully. A mock that
|
||||||
|
* *applied* these would agree with a client that sends them, and the belief
|
||||||
|
* would ship — which is exactly the road #26 took to a live server.
|
||||||
|
*/
|
||||||
|
export const OCCURRENCE_INHERITED = new Set([
|
||||||
|
"@type", "method", "organizerCalendarAddress", "privacy", "prodId",
|
||||||
|
"recurrenceId", "recurrenceIdTimeZone", "sentBy", "uid",
|
||||||
|
"recurrenceOverrides", "recurrenceRule", "relatedTo",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split a per-occurrence patch the way the server's validator does.
|
||||||
|
*
|
||||||
|
* `rejected` is the first property that would be refused, if any; `applied` is
|
||||||
|
* what actually lands on the override. Everything else vanishes without a word.
|
||||||
|
*/
|
||||||
|
export function splitOccurrencePatch(patch: Obj): { rejected?: string; applied: Obj } {
|
||||||
|
const applied: Obj = {};
|
||||||
|
for (const [key, value] of Object.entries(patch)) {
|
||||||
|
const [head, , third] = key.split("/");
|
||||||
|
const root = head ?? key;
|
||||||
|
if (OCCURRENCE_REJECTED.has(root)) return { rejected: root, applied };
|
||||||
|
if (OCCURRENCE_INHERITED.has(root)) continue;
|
||||||
|
if (root === "participants" && third === "calendarAddress") continue;
|
||||||
|
if (root === "id") continue;
|
||||||
|
applied[key] = value;
|
||||||
|
}
|
||||||
|
return { applied };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One occurrence by its index, wherever in the series it falls. */
|
||||||
|
export function occurrenceAt(base: Obj, index: number): Occurrence | null {
|
||||||
|
const all = expandOccurrences(base, new Date(-8640000000000), new Date(8640000000000));
|
||||||
|
return all.find((o) => o.index === index) ?? null;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { CAP, client } from "@/jmap/client";
|
import { CAP, client } from "@/jmap/client";
|
||||||
import { eventIdForScope, isOccurrence, useCalendar } from "@/store/calendar";
|
import { CalendarSetError, eventIdForScope, isOccurrence, isThisAndFutureRefusal, occurrencePatch, OccurrenceScopeError, useCalendar } from "@/store/calendar";
|
||||||
import type { CalendarEvent, JmapSession } from "@/jmap/types";
|
import type { CalendarEvent, JmapSession } from "@/jmap/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -161,3 +161,71 @@ describe("rsvp", () => {
|
|||||||
await expect(useCalendar.getState().rsvp(OCCURRENCE, "accepted")).rejects.toThrow(/not a participant/i);
|
await expect(useCalendar.getState().rsvp(OCCURRENCE, "accepted")).rejects.toThrow(/not a participant/i);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("occurrencePatch", () => {
|
||||||
|
it("lets through what one date will actually take", () => {
|
||||||
|
const { patch, dropped } = occurrencePatch({ title: "Just today", color: "#f00" });
|
||||||
|
expect(patch).toEqual({ title: "Just today", color: "#f00" });
|
||||||
|
expect(dropped).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on a property the server refuses outright", () => {
|
||||||
|
// Loud is correct here: moving one occurrence to another calendar is not
|
||||||
|
// something the user can be quietly given a different answer to.
|
||||||
|
expect(() => occurrencePatch({ calendarIds: { c2: true } })).toThrow(OccurrenceScopeError);
|
||||||
|
expect(() => occurrencePatch({ useDefaultAlerts: false })).toThrow(/whole series/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes an inherited property and reports it, rather than letting it vanish", () => {
|
||||||
|
// The server would take this patch, drop `privacy`, and answer "updated".
|
||||||
|
// Anything that believes the response believes the change landed.
|
||||||
|
const { patch, dropped } = occurrencePatch({ title: "x", privacy: "private", recurrenceRule: null });
|
||||||
|
expect(patch).toEqual({ title: "x" });
|
||||||
|
expect(dropped).toEqual(["privacy", "recurrenceRule"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("judges a pointer patch on its first token, as the server does", () => {
|
||||||
|
expect(occurrencePatch({ "participants/me/participationStatus": "accepted" }).patch)
|
||||||
|
.toEqual({ "participants/me/participationStatus": "accepted" });
|
||||||
|
expect(occurrencePatch({ "participants/me/calendarAddress": "mailto:x@y" }).dropped)
|
||||||
|
.toEqual(["participants/me/calendarAddress"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateEvent, per occurrence", () => {
|
||||||
|
it("narrows the patch before sending it and reports what it kept back", async () => {
|
||||||
|
const calls = server();
|
||||||
|
const dropped = await useCalendar.getState().updateEvent(OCCURRENCE, { title: "Just today", privacy: "private" }, false, "occurrence");
|
||||||
|
expect(calls[0]!.update).toEqual({ iaaaaas: { title: "Just today" } });
|
||||||
|
expect(dropped).toEqual(["privacy"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends nothing at all when a patch is entirely inherited", async () => {
|
||||||
|
// A request that could only be a no-op is worse than no request: the
|
||||||
|
// response would say "updated" and mean nothing by it.
|
||||||
|
const calls = server();
|
||||||
|
const dropped = await useCalendar.getState().updateEvent(OCCURRENCE, { privacy: "private" }, false, "occurrence");
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
expect(dropped).toEqual(["privacy"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a series patch exactly as the caller wrote it", async () => {
|
||||||
|
const calls = server();
|
||||||
|
await useCalendar.getState().updateEvent(OCCURRENCE, { privacy: "private", useDefaultAlerts: false }, false, "series");
|
||||||
|
expect(calls[0]!.update!.i).toEqual({ privacy: "private", useDefaultAlerts: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isThisAndFutureRefusal", () => {
|
||||||
|
it("recognises the refusal worth offering the series for", () => {
|
||||||
|
expect(isThisAndFutureRefusal(new CalendarSetError({
|
||||||
|
type: "invalidProperties",
|
||||||
|
description: "Occurrences of a this-and-future change cannot be modified individually.",
|
||||||
|
}))).toBe(true);
|
||||||
|
});
|
||||||
|
it("does not claim an unrelated refusal", () => {
|
||||||
|
expect(isThisAndFutureRefusal(new CalendarSetError({ type: "forbidden", description: "Nope." }))).toBe(false);
|
||||||
|
expect(isThisAndFutureRefusal(new Error("Occurrences of a this-and-future change"))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -83,6 +83,95 @@ export function isOccurrence(event: CalendarEvent): boolean {
|
|||||||
return event.baseEventId != null && event.baseEventId !== event.id;
|
return event.baseEventId != null && event.baseEventId !== event.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What `CalendarEvent/set` will not take on a single occurrence, and why the
|
||||||
|
* client has to know rather than letting the server sort it out.
|
||||||
|
*
|
||||||
|
* 0.16.20's per-occurrence validator sorts properties into three groups, and
|
||||||
|
* only one of them is honest about itself:
|
||||||
|
*
|
||||||
|
* - **Rejected** — `invalidProperties`, *"This property cannot be modified on a
|
||||||
|
* single occurrence."* Loud, and fine.
|
||||||
|
* - **Inherited** — dropped from the patch, and the response still says the
|
||||||
|
* update succeeded. Nothing anywhere reports it.
|
||||||
|
* - Everything else, which is applied to the override.
|
||||||
|
*
|
||||||
|
* The middle group is the whole problem. It is the same failure as [#26], where
|
||||||
|
* a participant map addressed the RFC 8984 way was discarded without an error
|
||||||
|
* and the client showed the guests as saved: a successful response is not
|
||||||
|
* evidence that anything was written. So a per-occurrence patch is checked here
|
||||||
|
* before it is sent — rejected properties throw, inherited ones are reported to
|
||||||
|
* the caller — rather than being posted hopefully and believed.
|
||||||
|
*
|
||||||
|
* [#26]: https://github.com/Coffey-Labs/ihasmail/issues/26
|
||||||
|
*/
|
||||||
|
const OCCURRENCE_REJECTED = new Set([
|
||||||
|
"baseEventId", "calendarIds", "isDraft", "isOrigin", "utcStart", "utcEnd",
|
||||||
|
"useDefaultAlerts", "mayInviteSelf", "mayInviteOthers", "hideAttendees",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Applied to the series and never to one date; dropped in silence if sent. */
|
||||||
|
const OCCURRENCE_INHERITED = new Set([
|
||||||
|
"@type", "method", "organizerCalendarAddress", "privacy", "prodId",
|
||||||
|
"recurrenceId", "recurrenceIdTimeZone", "sentBy", "uid",
|
||||||
|
"recurrenceOverrides", "recurrenceRule", "relatedTo",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `notUpdated`/`notDestroyed` entry, kept whole rather than flattened.
|
||||||
|
*
|
||||||
|
* Some refusals are worth acting on rather than only showing: 0.16.20 will not
|
||||||
|
* edit an occurrence that belongs to a this-and-future change, and the useful
|
||||||
|
* response to that is to offer the series, which needs the reason and not just
|
||||||
|
* its text.
|
||||||
|
*/
|
||||||
|
export class CalendarSetError extends Error {
|
||||||
|
constructor(readonly setError: { type: string; description?: string; properties?: string[] }) {
|
||||||
|
super(setErrorMessage(setError));
|
||||||
|
this.name = "CalendarSetError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a refusal was "this occurrence belongs to a this-and-future change". */
|
||||||
|
export function isThisAndFutureRefusal(err: unknown): boolean {
|
||||||
|
return err instanceof CalendarSetError && /this-and-future/i.test(err.setError.description ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OccurrenceScopeError extends Error {
|
||||||
|
constructor(readonly property: string) {
|
||||||
|
super(`"${property}" applies to the whole series and cannot be changed for one occurrence.`);
|
||||||
|
this.name = "OccurrenceScopeError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A patch narrowed to what one occurrence will actually accept.
|
||||||
|
*
|
||||||
|
* Throws `OccurrenceScopeError` on a property the server would refuse, and
|
||||||
|
* returns the inherited ones it removed so a caller can say what it could not
|
||||||
|
* do for this date alone instead of claiming it did.
|
||||||
|
*
|
||||||
|
* Patch *pointers* are judged on their first token, the way the server does:
|
||||||
|
* `participants/{key}/participationStatus` is allowed, and
|
||||||
|
* `participants/{key}/calendarAddress` is one of the silent drops.
|
||||||
|
*/
|
||||||
|
export function occurrencePatch(patch: Record<string, unknown>): { patch: Record<string, unknown>; dropped: string[] } {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
const dropped: string[] = [];
|
||||||
|
for (const [key, value] of Object.entries(patch)) {
|
||||||
|
const [head, , third] = key.split("/");
|
||||||
|
const root = head ?? key;
|
||||||
|
if (OCCURRENCE_REJECTED.has(root)) throw new OccurrenceScopeError(root);
|
||||||
|
if (OCCURRENCE_INHERITED.has(root)) { dropped.push(root); continue; }
|
||||||
|
if (root === "participants" && third === "calendarAddress") { dropped.push(key); continue; }
|
||||||
|
// `id` is immutable; the server errors on a value that is not the event's
|
||||||
|
// own, and ignores one that is. Neither is worth sending.
|
||||||
|
if (root === "id") { dropped.push(root); continue; }
|
||||||
|
out[key] = value;
|
||||||
|
}
|
||||||
|
return { patch: out, dropped };
|
||||||
|
}
|
||||||
|
|
||||||
/** A calendar somebody else shared, and the account it lives in. */
|
/** A calendar somebody else shared, and the account it lives in. */
|
||||||
export interface SharedCalendar {
|
export interface SharedCalendar {
|
||||||
accountId: Id;
|
accountId: Id;
|
||||||
@@ -122,7 +211,8 @@ interface CalendarState {
|
|||||||
instancesIn(start: Date, end: Date): EventInstance[];
|
instancesIn(start: Date, end: Date): EventInstance[];
|
||||||
getEvent(id: Id): Promise<CalendarEvent | null>;
|
getEvent(id: Id): Promise<CalendarEvent | null>;
|
||||||
createEvent(event: Partial<CalendarEvent>, calendarId: Id, sendInvites: boolean): Promise<Id>;
|
createEvent(event: Partial<CalendarEvent>, calendarId: Id, sendInvites: boolean): Promise<Id>;
|
||||||
updateEvent(event: CalendarEvent, patch: Record<string, unknown>, sendInvites: boolean, scope: EventScope): Promise<void>;
|
/** Returns the properties that had to be left to the series, if any. */
|
||||||
|
updateEvent(event: CalendarEvent, patch: Record<string, unknown>, sendInvites: boolean, scope: EventScope): Promise<string[]>;
|
||||||
destroyEvent(event: CalendarEvent, sendInvites: boolean, scope: EventScope): Promise<void>;
|
destroyEvent(event: CalendarEvent, sendInvites: boolean, scope: EventScope): Promise<void>;
|
||||||
rsvp(event: CalendarEvent, status: "accepted" | "tentative" | "declined", comment?: string): Promise<void>;
|
rsvp(event: CalendarEvent, status: "accepted" | "tentative" | "declined", comment?: string): Promise<void>;
|
||||||
createCalendar(data: Partial<Calendar>): Promise<Id>;
|
createCalendar(data: Partial<Calendar>): Promise<Id>;
|
||||||
@@ -399,10 +489,15 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
|||||||
async updateEvent(event, patch, sendInvites, scope) {
|
async updateEvent(event, patch, sendInvites, scope) {
|
||||||
const accountId = get().accountId!;
|
const accountId = get().accountId!;
|
||||||
const id = eventIdForScope(event, scope);
|
const id = eventIdForScope(event, scope);
|
||||||
const res = await client.call<SetResponse>("CalendarEvent/set", { accountId, update: { [id]: patch }, sendSchedulingMessages: sendInvites });
|
// An occurrence takes less than the series does, and says so about only
|
||||||
|
// half of it. Narrow the patch here rather than posting it hopefully.
|
||||||
|
const { patch: body, dropped } = scope === "occurrence" ? occurrencePatch(patch) : { patch, dropped: [] as string[] };
|
||||||
|
if (!Object.keys(body).length) return dropped;
|
||||||
|
const res = await client.call<SetResponse>("CalendarEvent/set", { accountId, update: { [id]: body }, sendSchedulingMessages: sendInvites });
|
||||||
const err = res.notUpdated?.[id];
|
const err = res.notUpdated?.[id];
|
||||||
if (err) throw new Error(setErrorMessage(err));
|
if (err) throw new CalendarSetError(err);
|
||||||
get().invalidate();
|
get().invalidate();
|
||||||
|
return dropped;
|
||||||
},
|
},
|
||||||
|
|
||||||
async destroyEvent(event, sendInvites, scope) {
|
async destroyEvent(event, sendInvites, scope) {
|
||||||
@@ -410,7 +505,7 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
|||||||
const id = eventIdForScope(event, scope);
|
const id = eventIdForScope(event, scope);
|
||||||
const res = await client.call<SetResponse>("CalendarEvent/set", { accountId, destroy: [id], sendSchedulingMessages: sendInvites });
|
const res = await client.call<SetResponse>("CalendarEvent/set", { accountId, destroy: [id], sendSchedulingMessages: sendInvites });
|
||||||
const err = res.notDestroyed?.[id];
|
const err = res.notDestroyed?.[id];
|
||||||
if (err) throw new Error(setErrorMessage(err));
|
if (err) throw new CalendarSetError(err);
|
||||||
set((s) => {
|
set((s) => {
|
||||||
const events = { ...s.events };
|
const events = { ...s.events };
|
||||||
// Drop both ids: the one that was sent, and the object as the caller
|
// Drop both ids: the one that was sent, and the object as the caller
|
||||||
|
|||||||
@@ -310,6 +310,11 @@ a.menu-item:hover { color: var(--fg); }
|
|||||||
.dialog-body { padding: 8px 20px 16px; overflow: auto; }
|
.dialog-body { padding: 8px 20px 16px; overflow: auto; }
|
||||||
.dialog-foot { display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding: 12px 20px 16px; border-top: 1px solid var(--border); }
|
.dialog-foot { display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding: 12px 20px 16px; border-top: 1px solid var(--border); }
|
||||||
.dialog-foot .left { margin-right: auto; }
|
.dialog-foot .left { margin-right: auto; }
|
||||||
|
/* "This occurrence or the whole series" — one button per answer, stacked, so
|
||||||
|
the destructive one is read rather than landed on by muscle memory. */
|
||||||
|
.dialog-choices { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.dialog-choice { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; width: 100%; text-align: left; padding: 10px 12px; height: auto; }
|
||||||
|
.dialog-choice small { font-weight: 400; opacity: 0.75; }
|
||||||
|
|
||||||
/* Toasts ----------------------------------------------------------------- */
|
/* Toasts ----------------------------------------------------------------- */
|
||||||
.toast-host { position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); z-index: 3000; display: flex; flex-direction: column; gap: 8px; align-items: center; pointer-events: none; padding: 0 12px; width: 100%; max-width: 520px; }
|
.toast-host { position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); z-index: 3000; display: flex; flex-direction: column; gap: 8px; align-items: center; pointer-events: none; padding: 0 12px; width: 100%; max-width: 520px; }
|
||||||
|
|||||||
+45
-8
@@ -86,9 +86,17 @@ export function Dialog({ open, onClose, title, children, footer, size = "md", cl
|
|||||||
|
|
||||||
/* ---------- Imperative confirm / prompt ---------- */
|
/* ---------- Imperative confirm / prompt ---------- */
|
||||||
|
|
||||||
|
export interface DialogChoice {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
/** Shown under the label, for the choice that needs the caveat. */
|
||||||
|
hint?: string;
|
||||||
|
danger?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface ConfirmRequest {
|
interface ConfirmRequest {
|
||||||
id: number;
|
id: number;
|
||||||
kind: "confirm" | "prompt";
|
kind: "confirm" | "prompt" | "choice";
|
||||||
title: string;
|
title: string;
|
||||||
message?: ReactNode;
|
message?: ReactNode;
|
||||||
confirmLabel?: string;
|
confirmLabel?: string;
|
||||||
@@ -96,6 +104,7 @@ interface ConfirmRequest {
|
|||||||
danger?: boolean;
|
danger?: boolean;
|
||||||
defaultValue?: string;
|
defaultValue?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
choices?: DialogChoice[];
|
||||||
resolve: (v: boolean | string | null) => void;
|
resolve: (v: boolean | string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +128,18 @@ export function promptDialog(opts: { title: string; message?: ReactNode; default
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A question with more than two answers, which "this one or all of them" is.
|
||||||
|
*
|
||||||
|
* Resolves to the chosen `value`, or `null` if the dialog is dismissed —
|
||||||
|
* dismissing is not one of the choices, so a caller cannot mistake it for one.
|
||||||
|
*/
|
||||||
|
export function choiceDialog(opts: { title: string; message?: ReactNode; choices: DialogChoice[]; cancelLabel?: string }): Promise<string | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
useConfirmStore.getState().push({ id: reqId++, kind: "choice", ...opts, resolve: (v) => resolve(typeof v === "string" ? v : null) });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function ConfirmHost() {
|
export function ConfirmHost() {
|
||||||
const req = useConfirmStore((s) => s.queue[0]);
|
const req = useConfirmStore((s) => s.queue[0]);
|
||||||
const pop = useConfirmStore((s) => s.pop);
|
const pop = useConfirmStore((s) => s.pop);
|
||||||
@@ -132,21 +153,37 @@ export function ConfirmHost() {
|
|||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open
|
open
|
||||||
onClose={() => done(req.kind === "prompt" ? null : false)}
|
onClose={() => done(req.kind === "confirm" ? false : null)}
|
||||||
title={req.title}
|
title={req.title}
|
||||||
size="sm"
|
size="sm"
|
||||||
footer={
|
footer={
|
||||||
<>
|
req.kind === "choice" ? (
|
||||||
<button className="btn" onClick={() => done(req.kind === "prompt" ? null : false)}>
|
<button className="btn" onClick={() => done(null)}>
|
||||||
{req.cancelLabel ?? "Cancel"}
|
{req.cancelLabel ?? "Cancel"}
|
||||||
</button>
|
</button>
|
||||||
<button className={`btn ${req.danger ? "btn-danger" : "btn-primary"}`} onClick={() => done(req.kind === "prompt" ? value : true)}>
|
) : (
|
||||||
{req.confirmLabel ?? (req.kind === "prompt" ? "OK" : "Confirm")}
|
<>
|
||||||
</button>
|
<button className="btn" onClick={() => done(req.kind === "prompt" ? null : false)}>
|
||||||
</>
|
{req.cancelLabel ?? "Cancel"}
|
||||||
|
</button>
|
||||||
|
<button className={`btn ${req.danger ? "btn-danger" : "btn-primary"}`} onClick={() => done(req.kind === "prompt" ? value : true)}>
|
||||||
|
{req.confirmLabel ?? (req.kind === "prompt" ? "OK" : "Confirm")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{req.message && <p style={{ marginTop: 0 }}>{req.message}</p>}
|
{req.message && <p style={{ marginTop: 0 }}>{req.message}</p>}
|
||||||
|
{req.kind === "choice" && (
|
||||||
|
<div className="dialog-choices">
|
||||||
|
{req.choices?.map((c) => (
|
||||||
|
<button key={c.value} className={`btn dialog-choice ${c.danger ? "btn-danger" : ""}`} onClick={() => done(c.value)}>
|
||||||
|
<span>{c.label}</span>
|
||||||
|
{c.hint && <small>{c.hint}</small>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{req.kind === "prompt" && (
|
{req.kind === "prompt" && (
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { Calendar as CalIcon, CalendarDays, Copy, ExternalLink, Palette, Pencil, Plus, Tag, Trash2, X } from "lucide-react";
|
import { Calendar as CalIcon, CalendarDays, Copy, ExternalLink, Palette, Pencil, Plus, Tag, Trash2, X } from "lucide-react";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
import type { CalendarEvent } from "@/jmap/types";
|
import type { CalendarEvent } from "@/jmap/types";
|
||||||
import { useCalendar, isRecurring, type EventInstance } from "@/store/calendar";
|
import { useCalendar, isRecurring, isOccurrence, type EventInstance, type EventScope } from "@/store/calendar";
|
||||||
import { useSettings } from "@/store/settings";
|
import { useSettings } from "@/store/settings";
|
||||||
import { formatDayMonth } from "@/lib/datetime";
|
import { formatDayMonth } from "@/lib/datetime";
|
||||||
import { MenuItem, MenuSep, MenuTitle, Popover, type Anchor } from "@/ui/popover";
|
|
||||||
import { CALENDAR_COLORS } from "@/ui/misc";
|
import { CALENDAR_COLORS } from "@/ui/misc";
|
||||||
|
import { MenuItem, MenuSep, MenuTitle, Popover, type Anchor } from "@/ui/popover";
|
||||||
import { confirmDialog } from "@/ui/dialog";
|
import { confirmDialog } from "@/ui/dialog";
|
||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
|
import { askDeleteScope, askEditScope, droppedMessage, runScoped } from "./scope";
|
||||||
import { toLocalDateOnly } from "@/lib/dates";
|
import { toLocalDateOnly } from "@/lib/dates";
|
||||||
import { formatTime } from "@/lib/format";
|
import { formatTime } from "@/lib/format";
|
||||||
|
|
||||||
@@ -65,9 +66,13 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
|
|||||||
const participants = Object.keys(ev.participants ?? {}).length;
|
const participants = Object.keys(ev.participants ?? {}).length;
|
||||||
|
|
||||||
const patch = async (p: Record<string, unknown>, msg: string) => {
|
const patch = async (p: Record<string, unknown>, msg: string) => {
|
||||||
|
const scope = await askEditScope(ev);
|
||||||
|
if (!scope) return;
|
||||||
try {
|
try {
|
||||||
await cal.updateEvent(ev, p, false, "series");
|
const dropped = await runScoped(scope, (s) => cal.updateEvent(ev, p, false, s));
|
||||||
toast.success(msg);
|
if (!dropped) return;
|
||||||
|
// A per-occurrence change can be accepted in part. Say which part.
|
||||||
|
toast.success(droppedMessage(dropped) ?? (scope === "occurrence" ? `${msg} for this date` : msg));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error((err as Error).message);
|
toast.error((err as Error).message);
|
||||||
}
|
}
|
||||||
@@ -88,11 +93,16 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
|
|||||||
};
|
};
|
||||||
const del = async () => {
|
const del = async () => {
|
||||||
onClose();
|
onClose();
|
||||||
const recurring = isRecurring(ev);
|
let scope: EventScope | null = "series";
|
||||||
if (!(await confirmDialog({ title: recurring ? "Delete all occurrences?" : "Delete this event?", confirmLabel: "Delete", danger: true }))) return;
|
if (isRecurring(ev) && isOccurrence(ev)) {
|
||||||
|
scope = await askDeleteScope(ev);
|
||||||
|
} else if (!(await confirmDialog({ title: "Delete this event?", confirmLabel: "Delete", danger: true }))) {
|
||||||
|
scope = null;
|
||||||
|
}
|
||||||
|
if (!scope) return;
|
||||||
try {
|
try {
|
||||||
await cal.destroyEvent(ev, participants > 1, "series");
|
await runScoped(scope, (s) => cal.destroyEvent(ev, participants > 1, s));
|
||||||
toast.success("Event deleted");
|
toast.success(scope === "occurrence" ? "Occurrence deleted" : "Event deleted");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error((err as Error).message);
|
toast.error((err as Error).message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Plus, Trash2, Users } from "lucide-react";
|
import { Plus, Trash2, Users } from "lucide-react";
|
||||||
import type { BusyPeriod, CalendarEvent, EmailAddress, JSCalendarAlert, JSCalendarParticipant, JSCalendarRecurrenceRule, JSCalendarNDay } from "@/jmap/types";
|
import type { BusyPeriod, CalendarEvent, EmailAddress, JSCalendarAlert, JSCalendarParticipant, JSCalendarRecurrenceRule, JSCalendarNDay } from "@/jmap/types";
|
||||||
import { useCalendar, myParticipantKeys, isRecurring, eventRule, makeParticipant, participantEmail } from "@/store/calendar";
|
import { useCalendar, myParticipantKeys, isRecurring, isOccurrence, eventRule, makeParticipant, participantEmail, type EventScope } from "@/store/calendar";
|
||||||
import { useSettings } from "@/store/settings";
|
import { useSettings } from "@/store/settings";
|
||||||
import { useSession } from "@/store/session";
|
import { useSession } from "@/store/session";
|
||||||
import { useContacts } from "@/store/contacts";
|
import { useContacts } from "@/store/contacts";
|
||||||
@@ -14,6 +14,7 @@ import { browserTimeZone, dateToZonedLocal, formatDuration, fromInputDateTime, l
|
|||||||
import { formatClock, formatNumericDate, formatWeekday } from "@/lib/datetime";
|
import { formatClock, formatNumericDate, formatWeekday } from "@/lib/datetime";
|
||||||
import { WEEKDAYS, describeRule, presetFor, ruleFromPreset, type RecurrencePreset } from "@/lib/recurrence";
|
import { WEEKDAYS, describeRule, presetFor, ruleFromPreset, type RecurrencePreset } from "@/lib/recurrence";
|
||||||
import { newKey } from "@/lib/contacts";
|
import { newKey } from "@/lib/contacts";
|
||||||
|
import { askEditScope, droppedMessage, runScoped } from "./scope";
|
||||||
|
|
||||||
export interface EditorInit {
|
export interface EditorInit {
|
||||||
event?: CalendarEvent;
|
event?: CalendarEvent;
|
||||||
@@ -24,25 +25,68 @@ export interface EditorInit {
|
|||||||
|
|
||||||
const ALERT_OPTIONS = [0, 5, 10, 15, 30, 60, 120, 1440, 2880, 10080];
|
const ALERT_OPTIONS = [0, 5, 10, 15, 30, 60, 120, 1440, 2880, 10080];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fields this form always sends that a single occurrence will not take.
|
||||||
|
*
|
||||||
|
* `useDefaultAlerts` and `calendarIds` are refused with `invalidProperties`;
|
||||||
|
* the rest are dropped from the patch while the response still reports
|
||||||
|
* success. Both halves are reasons not to send them — the second more so,
|
||||||
|
* because nothing would say it had happened.
|
||||||
|
*/
|
||||||
|
const OCCURRENCE_OMIT = new Set(["useDefaultAlerts", "calendarIds", "recurrenceRule", "privacy", "organizerCalendarAddress"]);
|
||||||
|
|
||||||
export function EventEditor({ init, onClose }: { init: EditorInit; onClose: () => void }) {
|
export function EventEditor({ init, onClose }: { init: EditorInit; onClose: () => void }) {
|
||||||
const cal = useCalendar();
|
const cal = useCalendar();
|
||||||
const settings = useSettings((s) => s.settings);
|
const settings = useSettings((s) => s.settings);
|
||||||
const session = useSession((s) => s.session);
|
const session = useSession((s) => s.session);
|
||||||
const [base, setBase] = useState<CalendarEvent | null | undefined>(init.event && !init.event.baseEventId ? init.event : undefined);
|
const [base, setBase] = useState<CalendarEvent | null | undefined>(init.event && !init.event.baseEventId ? init.event : undefined);
|
||||||
|
const [scope, setScope] = useState<EventScope | undefined>(init.event?.baseEventId ? undefined : "series");
|
||||||
const editing = Boolean(init.event);
|
const editing = Boolean(init.event);
|
||||||
|
|
||||||
// Load base event for recurring instances
|
/*
|
||||||
|
* Which event this form is even about has to be settled before it opens.
|
||||||
|
*
|
||||||
|
* A form populated from the master shows the series' start date, so editing
|
||||||
|
* Wednesday's standup would offer to move Monday's — right for the series and
|
||||||
|
* wrong for one date. So the scope is asked first, and the occurrence itself
|
||||||
|
* is what the form loads when the answer is "this occurrence".
|
||||||
|
*/
|
||||||
|
/*
|
||||||
|
* Asked once per event, and deliberately not tied to the effect's lifetime.
|
||||||
|
*
|
||||||
|
* Two things make the obvious version wrong. A dialog is queued in a store
|
||||||
|
* the moment it is requested, so it outlives the effect that asked for it: a
|
||||||
|
* re-run queues a second prompt the first answer cannot retract, and the
|
||||||
|
* reader is asked the same question twice. And gating the *answer* on a
|
||||||
|
* cleanup flag is worse — React's StrictMode runs mount, cleanup, mount, so
|
||||||
|
* the flag is already set by the time anyone clicks and the editor never
|
||||||
|
* opens at all. The ref is what makes this once; the answer is applied
|
||||||
|
* whenever it arrives.
|
||||||
|
*/
|
||||||
|
const asked = useRef<string | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (init.event?.baseEventId) void cal.getEvent(init.event.baseEventId).then((e) => setBase(e));
|
const ev = init.event;
|
||||||
else if (!init.event) setBase(null);
|
if (!ev) { setBase(null); setScope("series"); return; }
|
||||||
|
if (!ev.baseEventId) { setBase(ev); setScope("series"); return; }
|
||||||
|
if (asked.current === ev.id) return;
|
||||||
|
asked.current = ev.id;
|
||||||
|
void (async () => {
|
||||||
|
const chosen = isRecurring(ev) && isOccurrence(ev) ? await askEditScope(ev) : "series";
|
||||||
|
if (!chosen) { onClose(); return; }
|
||||||
|
setScope(chosen);
|
||||||
|
if (chosen === "occurrence") setBase(ev);
|
||||||
|
else void cal.getEvent(ev.baseEventId!).then(setBase);
|
||||||
|
})();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [init.event?.id]);
|
}, [init.event?.id]);
|
||||||
|
|
||||||
if (base === undefined) return null;
|
if (base === undefined || scope === undefined) return null;
|
||||||
return <EventForm key={base?.id ?? "new"} init={init} base={base} editing={editing} onClose={onClose} settingsTz={settings.timeZone ?? browserTimeZone} defaultAlert={settings.defaultAlertMinutes} myEmail={session?.username ?? ""} />;
|
return <EventForm key={base?.id ?? "new"} init={init} base={base} scope={scope} editing={editing} onClose={onClose} settingsTz={settings.timeZone ?? browserTimeZone} defaultAlert={settings.defaultAlertMinutes} myEmail={session?.username ?? ""} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myEmail }: { init: EditorInit; base: CalendarEvent | null; editing: boolean; onClose: () => void; settingsTz: string; defaultAlert: number; myEmail: string }) {
|
function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAlert, myEmail }: { init: EditorInit; base: CalendarEvent | null; scope: EventScope; editing: boolean; onClose: () => void; settingsTz: string; defaultAlert: number; myEmail: string }) {
|
||||||
|
/** This form is editing one date rather than the series behind it. */
|
||||||
|
const oneDate = scope === "occurrence";
|
||||||
const cal = useCalendar();
|
const cal = useCalendar();
|
||||||
const contacts = useContacts();
|
const contacts = useContacts();
|
||||||
const ev = base;
|
const ev = base;
|
||||||
@@ -175,13 +219,23 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
|
|||||||
};
|
};
|
||||||
const invites = sendInvites && attendees.length > 0;
|
const invites = sendInvites && attendees.length > 0;
|
||||||
if (ev) {
|
if (ev) {
|
||||||
|
/*
|
||||||
|
* A single occurrence takes less than the series does. Four of the
|
||||||
|
* fields this form always sends are among them — `useDefaultAlerts`
|
||||||
|
* and `calendarIds` are refused outright, `recurrenceRule`,
|
||||||
|
* `privacy` and `organizerCalendarAddress` are dropped in silence —
|
||||||
|
* so they are left out here rather than sent and believed. The store
|
||||||
|
* still checks; this is what stops it having to complain.
|
||||||
|
*/
|
||||||
|
const source = oneDate
|
||||||
|
? Object.fromEntries(Object.entries(obj).filter(([k]) => !OCCURRENCE_OMIT.has(k)))
|
||||||
|
: obj;
|
||||||
const patch: Record<string, unknown> = {};
|
const patch: Record<string, unknown> = {};
|
||||||
for (const [k, v] of Object.entries(obj)) patch[k] = v === undefined ? null : v;
|
for (const [k, v] of Object.entries(source)) patch[k] = v === undefined ? null : v;
|
||||||
if (Object.keys(ev.calendarIds)[0] !== calendarId) patch.calendarIds = { [calendarId]: true };
|
if (!oneDate && Object.keys(ev.calendarIds)[0] !== calendarId) patch.calendarIds = { [calendarId]: true };
|
||||||
// `ev` is the master: EventEditor resolves `baseEventId` when it opens
|
const dropped = await runScoped(scope, (s) => cal.updateEvent(ev, patch, invites, s));
|
||||||
// on an occurrence, so the whole series is what this form edits.
|
if (!dropped) { setBusy(false); return; }
|
||||||
await cal.updateEvent(ev, patch, invites, "series");
|
toast.success(droppedMessage(dropped) ?? (oneDate ? "This occurrence updated" : "Event updated"));
|
||||||
toast.success("Event updated");
|
|
||||||
} else {
|
} else {
|
||||||
const clean: Record<string, unknown> = {};
|
const clean: Record<string, unknown> = {};
|
||||||
for (const [k, v] of Object.entries(obj)) if (v !== undefined) clean[k] = v;
|
for (const [k, v] of Object.entries(obj)) if (v !== undefined) clean[k] = v;
|
||||||
@@ -206,7 +260,13 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
|
|||||||
return (
|
return (
|
||||||
<Dialog open onClose={onClose} title={editing ? "Edit event" : "New event"} size="lg" footer={<><button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{busy ? "Saving…" : editing ? "Save" : attendees.length && sendInvites ? "Send invites" : "Create"}</button></>}>
|
<Dialog open onClose={onClose} title={editing ? "Edit event" : "New event"} size="lg" footer={<><button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{busy ? "Saving…" : editing ? "Save" : attendees.length && sendInvites ? "Send invites" : "Create"}</button></>}>
|
||||||
<div className="event-form">
|
<div className="event-form">
|
||||||
{ev && isRecurring(ev) && <div className="info-box mb-16">This is a recurring event — changes apply to the whole series.</div>}
|
{ev && isRecurring(ev) && (
|
||||||
|
<div className="info-box mb-16">
|
||||||
|
{oneDate
|
||||||
|
? `Editing ${formatNumericDate(start)} only — the rest of the series is unchanged. Repeat, calendar and privacy belong to the series and are not shown.`
|
||||||
|
: "This is a recurring event — changes apply to the whole series."}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="field"><input className="input" style={{ fontSize: "1.1em", height: 44 }} placeholder="Add title" autoFocus value={title} onChange={(e) => setTitle(e.target.value)} /></div>
|
<div className="field"><input className="input" style={{ fontSize: "1.1em", height: 44 }} placeholder="Add title" autoFocus value={title} onChange={(e) => setTitle(e.target.value)} /></div>
|
||||||
<div className="time-row mb-8">
|
<div className="time-row mb-8">
|
||||||
{allDay ? (
|
{allDay ? (
|
||||||
@@ -231,6 +291,7 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
|
|||||||
{listTimeZones().map((t) => <option key={t} value={t}>{t}</option>)}
|
{listTimeZones().map((t) => <option key={t} value={t}>{t}</option>)}
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
|
{!oneDate && (
|
||||||
<select className="select" style={{ width: "auto", height: 32 }} value={preset} onChange={(e) => { const p = e.target.value as RecurrencePreset; setPreset(p); if (p === "custom") setRule(rule ?? { "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ "@type": "NDay", day: WEEKDAYS[(start.getDay() + 6) % 7]!.key }] }); else setRule(ruleFromPreset(p, start)); }}>
|
<select className="select" style={{ width: "auto", height: 32 }} value={preset} onChange={(e) => { const p = e.target.value as RecurrencePreset; setPreset(p); if (p === "custom") setRule(rule ?? { "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ "@type": "NDay", day: WEEKDAYS[(start.getDay() + 6) % 7]!.key }] }); else setRule(ruleFromPreset(p, start)); }}>
|
||||||
<option value="none">Does not repeat</option>
|
<option value="none">Does not repeat</option>
|
||||||
<option value="daily">Daily</option>
|
<option value="daily">Daily</option>
|
||||||
@@ -240,8 +301,9 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
|
|||||||
<option value="yearly">Yearly</option>
|
<option value="yearly">Yearly</option>
|
||||||
<option value="custom">Custom…</option>
|
<option value="custom">Custom…</option>
|
||||||
</select>
|
</select>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{preset === "custom" && (
|
{!oneDate && preset === "custom" && (
|
||||||
<div className="card" style={{ marginBottom: 12 }}>
|
<div className="card" style={{ marginBottom: 12 }}>
|
||||||
<div className="row wrap" style={{ gap: 8 }}>
|
<div className="row wrap" style={{ gap: 8 }}>
|
||||||
<span>Repeat every</span>
|
<span>Repeat every</span>
|
||||||
@@ -271,7 +333,7 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
|
|||||||
)}
|
)}
|
||||||
<div className="field-row">
|
<div className="field-row">
|
||||||
<div className="field"><label>Calendar</label>
|
<div className="field"><label>Calendar</label>
|
||||||
<select className="select" value={calendarId} onChange={(e) => setCalendarId(e.target.value)}>
|
<select className="select" value={calendarId} disabled={oneDate} title={oneDate ? "An occurrence cannot be moved to another calendar on its own" : undefined} onChange={(e) => setCalendarId(e.target.value)}>
|
||||||
{calendars.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
{calendars.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -329,7 +391,7 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
|
|||||||
<div className="field-row">
|
<div className="field-row">
|
||||||
<div className="field"><label>Status</label><select className="select" value={status} onChange={(e) => setStatus(e.target.value as typeof status)}><option value="confirmed">Confirmed</option><option value="tentative">Tentative</option><option value="cancelled">Cancelled</option></select></div>
|
<div className="field"><label>Status</label><select className="select" value={status} onChange={(e) => setStatus(e.target.value as typeof status)}><option value="confirmed">Confirmed</option><option value="tentative">Tentative</option><option value="cancelled">Cancelled</option></select></div>
|
||||||
<div className="field"><label>Show as</label><select className="select" value={freeBusy} onChange={(e) => setFreeBusy(e.target.value as typeof freeBusy)}><option value="busy">Busy</option><option value="free">Free</option></select></div>
|
<div className="field"><label>Show as</label><select className="select" value={freeBusy} onChange={(e) => setFreeBusy(e.target.value as typeof freeBusy)}><option value="busy">Busy</option><option value="free">Free</option></select></div>
|
||||||
<div className="field"><label>Visibility</label><select className="select" value={privacy} onChange={(e) => setPrivacy(e.target.value as typeof privacy)}><option value="public">Default</option><option value="private">Private</option><option value="secret">Secret</option></select></div>
|
{!oneDate && <div className="field"><label>Visibility</label><select className="select" value={privacy} onChange={(e) => setPrivacy(e.target.value as typeof privacy)}><option value="public">Default</option><option value="private">Private</option><option value="secret">Secret</option></select></div>}
|
||||||
</div>
|
</div>
|
||||||
<div className="field"><label>Category</label>
|
<div className="field"><label>Category</label>
|
||||||
<select className="select" value={category} onChange={(e) => setCategory(e.target.value)}>
|
<select className="select" value={category} onChange={(e) => setCategory(e.target.value)}>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { AlignLeft, Bell, Calendar as CalIcon, Check, Clock, HelpCircle, Link2, MapPin, Pencil, Repeat, Trash2, Users, X, Mail } from "lucide-react";
|
import { AlignLeft, Bell, Calendar as CalIcon, Check, Clock, HelpCircle, Link2, MapPin, Pencil, Repeat, Trash2, Users, X, Mail } from "lucide-react";
|
||||||
import { useCalendar, myParticipantKeys, isRecurring, eventRule, participantEmail, type EventInstance } from "@/store/calendar";
|
import { useCalendar, myParticipantKeys, isRecurring, isOccurrence, eventRule, participantEmail, type EventInstance, type EventScope } from "@/store/calendar";
|
||||||
import { Popover, type Anchor } from "@/ui/popover";
|
import { Popover, type Anchor } from "@/ui/popover";
|
||||||
import { confirmDialog } from "@/ui/dialog";
|
import { confirmDialog } from "@/ui/dialog";
|
||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
|
import { askDeleteScope, runScoped } from "./scope";
|
||||||
import { formatTimeRange, humanDuration, parseDuration } from "@/lib/dates";
|
import { formatTimeRange, humanDuration, parseDuration } from "@/lib/dates";
|
||||||
import { describeRule } from "@/lib/recurrence";
|
import { describeRule } from "@/lib/recurrence";
|
||||||
import { useCompose } from "@/store/compose";
|
import { useCompose } from "@/store/compose";
|
||||||
@@ -28,13 +29,20 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns
|
|||||||
const openCompose = useCompose((s) => s.open);
|
const openCompose = useCompose((s) => s.open);
|
||||||
|
|
||||||
const del = async () => {
|
const del = async () => {
|
||||||
const recurring = isRecurring(ev);
|
// A series asks which; anything else is a plain confirm. `askDeleteScope`
|
||||||
const ok = await confirmDialog({ title: recurring ? "Delete all occurrences?" : "Delete this event?", message: recurring ? "This will delete the entire series." : undefined, confirmLabel: "Delete", danger: true });
|
// returns null for a dismissed dialog, which is a cancel and not a series.
|
||||||
if (!ok) return;
|
let scope: EventScope | null = "series";
|
||||||
|
if (isRecurring(ev) && isOccurrence(ev)) {
|
||||||
|
scope = await askDeleteScope(ev);
|
||||||
|
} else {
|
||||||
|
const ok = await confirmDialog({ title: "Delete this event?", confirmLabel: "Delete", danger: true });
|
||||||
|
if (!ok) scope = null;
|
||||||
|
}
|
||||||
|
if (!scope) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await cal.destroyEvent(ev, participants.length > 1, "series");
|
await runScoped(scope, (s) => cal.destroyEvent(ev, participants.length > 1, s));
|
||||||
toast.success("Event deleted");
|
toast.success(scope === "occurrence" ? "Occurrence deleted" : "Event deleted");
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error((err as Error).message);
|
toast.error((err as Error).message);
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { choiceDialog, confirmDialog } from "@/ui/dialog";
|
||||||
|
import { isOccurrence, isRecurring, isThisAndFutureRefusal, type EventScope } from "@/store/calendar";
|
||||||
|
import type { CalendarEvent } from "@/jmap/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask which of a series a change is meant for, when there is a choice.
|
||||||
|
*
|
||||||
|
* There is only a choice when the object in hand is an occurrence of a real
|
||||||
|
* series: a one-off has a synthetic id too, but its only occurrence *is* the
|
||||||
|
* event, so asking would be a question with one true answer. `null` means the
|
||||||
|
* dialog was dismissed, which is not the same as "the whole series" — every
|
||||||
|
* caller has to treat it as a cancel.
|
||||||
|
*
|
||||||
|
* Until 0.16.20 there was nothing to ask: the server refused a write aimed at
|
||||||
|
* an occurrence, so "the whole series" was the only thing that could happen.
|
||||||
|
*/
|
||||||
|
export async function askScope(
|
||||||
|
event: CalendarEvent,
|
||||||
|
opts: { title: string; occurrenceLabel: string; seriesLabel: string; danger?: boolean; occurrenceHint?: string; seriesHint?: string },
|
||||||
|
): Promise<EventScope | null> {
|
||||||
|
if (!isRecurring(event) || !isOccurrence(event)) return "series";
|
||||||
|
const answer = await choiceDialog({
|
||||||
|
title: opts.title,
|
||||||
|
choices: [
|
||||||
|
{ value: "occurrence", label: opts.occurrenceLabel, hint: opts.occurrenceHint, danger: opts.danger },
|
||||||
|
{ value: "series", label: opts.seriesLabel, hint: opts.seriesHint, danger: opts.danger },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return answer === "occurrence" || answer === "series" ? answer : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The scope question for deleting. */
|
||||||
|
export const askDeleteScope = (event: CalendarEvent): Promise<EventScope | null> =>
|
||||||
|
askScope(event, {
|
||||||
|
title: "Delete this event?",
|
||||||
|
occurrenceLabel: "This occurrence",
|
||||||
|
occurrenceHint: "Removes this date and leaves the rest of the series.",
|
||||||
|
seriesLabel: "All occurrences",
|
||||||
|
seriesHint: "Deletes the whole series. This cannot be undone.",
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The scope question for editing. */
|
||||||
|
export const askEditScope = (event: CalendarEvent): Promise<EventScope | null> =>
|
||||||
|
askScope(event, {
|
||||||
|
title: "Change this event?",
|
||||||
|
occurrenceLabel: "This occurrence",
|
||||||
|
occurrenceHint: "Applies to this date only.",
|
||||||
|
seriesLabel: "All occurrences",
|
||||||
|
seriesHint: "Applies to every date in the series.",
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What to say when the server kept some of a per-occurrence change for the
|
||||||
|
* series. `dropped` comes back from `updateEvent`; an empty list says nothing.
|
||||||
|
*/
|
||||||
|
export function droppedMessage(dropped: string[]): string | null {
|
||||||
|
if (!dropped.length) return null;
|
||||||
|
const names = dropped.map((d) => d.replace(/^@/, "")).join(", ");
|
||||||
|
return `Saved for this date. ${names} ${dropped.length === 1 ? "applies" : "apply"} to the whole series and was left unchanged.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a scoped change, and offer the series if the server will not do one date.
|
||||||
|
*
|
||||||
|
* Stalwart refuses an occurrence that belongs to a this-and-future override —
|
||||||
|
* *"Occurrences of a this-and-future change cannot be modified individually."*
|
||||||
|
* Nothing ihasmail writes creates one, but an event synced from another client
|
||||||
|
* can carry one, so the refusal is reachable and a bare error toast would leave
|
||||||
|
* the reader with no way forward.
|
||||||
|
*
|
||||||
|
* The series is offered rather than silently substituted: they asked for one
|
||||||
|
* date, and doing the larger thing without saying so is the failure this whole
|
||||||
|
* area exists to avoid.
|
||||||
|
*/
|
||||||
|
export async function runScoped<T>(scope: EventScope, run: (scope: EventScope) => Promise<T>): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
return await run(scope);
|
||||||
|
} catch (err) {
|
||||||
|
if (scope !== "occurrence" || !isThisAndFutureRefusal(err)) throw err;
|
||||||
|
const ok = await confirmDialog({
|
||||||
|
title: "This date cannot be changed on its own",
|
||||||
|
message: "It belongs to a change that was applied to this and all later occurrences, which the server will only edit as a whole. Apply to the entire series instead?",
|
||||||
|
confirmLabel: "Apply to series",
|
||||||
|
});
|
||||||
|
return ok ? await run("series") : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user