diff --git a/server/src/mock/index.ts b/server/src/mock/index.ts index 721b603..a164e1b 100644 --- a/server/src/mock/index.ts +++ b/server/src/mock/index.ts @@ -5,6 +5,7 @@ */ import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { randomUUID } from "node:crypto"; +import { expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, splitOccurrencePatch, syntheticId, type Occurrence } from "./recurrence.js"; import { parseOtpauthUrl, verifyTotp } from "../totp.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)) : [] }; }; } +/** + * 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. */ class SetError extends Error { 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 | 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(); + + /* 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(); + 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(); + 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 | 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 ---------- */ /** * Held messages, the way Stalwart models them: `sendAt` is derived from the @@ -774,18 +912,43 @@ const handlers: Record = { "SieveScript/validate": () => ({ accountId: ACCOUNT, error: null }), "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), - "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 // 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. - "CalendarEvent/set": genericSet(events, "ev", (o) => { - if (o.recurrenceRules) throw new SetError("invalidProperties", "Invalid property.", ["recurrenceRules"]); - const parts = o.participants as Record | 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/set": (a) => calendarEventSet(a), "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:ada@example.org", 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), "Principal/query": () => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: principals.map((p) => p.id) }), diff --git a/server/src/mock/recurrence.test.ts b/server/src/mock/recurrence.test.ts new file mode 100644 index 0000000..b1bfcdf --- /dev/null +++ b/server/src/mock/recurrence.test.ts @@ -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); +const oneOff = () => ({ id: "ev2", "@type": "Event", uid: "u2", title: "Lunch", start: "2026-09-08T12:00:00", duration: "PT1H" } as Record); + +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, {}); + }); +}); diff --git a/server/src/mock/recurrence.ts b/server/src/mock/recurrence.ts new file mode 100644 index 0000000..1d5fc57 --- /dev/null +++ b/server/src/mock/recurrence.ts @@ -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; + +/** 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 | 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; +} diff --git a/web/src/store/__tests__/event-scope.test.ts b/web/src/store/__tests__/event-scope.test.ts index 70f5ae8..27126f4 100644 --- a/web/src/store/__tests__/event-scope.test.ts +++ b/web/src/store/__tests__/event-scope.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 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"; /** @@ -161,3 +161,71 @@ describe("rsvp", () => { 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); + }); +}); diff --git a/web/src/store/calendar.ts b/web/src/store/calendar.ts index 2ad1f5b..c5ec5c6 100644 --- a/web/src/store/calendar.ts +++ b/web/src/store/calendar.ts @@ -83,6 +83,95 @@ export function isOccurrence(event: CalendarEvent): boolean { 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): { patch: Record; dropped: string[] } { + const out: Record = {}; + 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. */ export interface SharedCalendar { accountId: Id; @@ -122,7 +211,8 @@ interface CalendarState { instancesIn(start: Date, end: Date): EventInstance[]; getEvent(id: Id): Promise; createEvent(event: Partial, calendarId: Id, sendInvites: boolean): Promise; - updateEvent(event: CalendarEvent, patch: Record, sendInvites: boolean, scope: EventScope): Promise; + /** Returns the properties that had to be left to the series, if any. */ + updateEvent(event: CalendarEvent, patch: Record, sendInvites: boolean, scope: EventScope): Promise; destroyEvent(event: CalendarEvent, sendInvites: boolean, scope: EventScope): Promise; rsvp(event: CalendarEvent, status: "accepted" | "tentative" | "declined", comment?: string): Promise; createCalendar(data: Partial): Promise; @@ -399,10 +489,15 @@ export const useCalendar = create((set, get) => ({ async updateEvent(event, patch, sendInvites, scope) { const accountId = get().accountId!; const id = eventIdForScope(event, scope); - const res = await client.call("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("CalendarEvent/set", { accountId, update: { [id]: body }, sendSchedulingMessages: sendInvites }); const err = res.notUpdated?.[id]; - if (err) throw new Error(setErrorMessage(err)); + if (err) throw new CalendarSetError(err); get().invalidate(); + return dropped; }, async destroyEvent(event, sendInvites, scope) { @@ -410,7 +505,7 @@ export const useCalendar = create((set, get) => ({ const id = eventIdForScope(event, scope); const res = await client.call("CalendarEvent/set", { accountId, destroy: [id], sendSchedulingMessages: sendInvites }); const err = res.notDestroyed?.[id]; - if (err) throw new Error(setErrorMessage(err)); + if (err) throw new CalendarSetError(err); set((s) => { const events = { ...s.events }; // Drop both ids: the one that was sent, and the object as the caller diff --git a/web/src/styles/app.css b/web/src/styles/app.css index f3cbd4d..e61907e 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -310,6 +310,11 @@ a.menu-item:hover { color: var(--fg); } .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 .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 ----------------------------------------------------------------- */ .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; } diff --git a/web/src/ui/dialog.tsx b/web/src/ui/dialog.tsx index b746d77..c323ca6 100644 --- a/web/src/ui/dialog.tsx +++ b/web/src/ui/dialog.tsx @@ -86,9 +86,17 @@ export function Dialog({ open, onClose, title, children, footer, size = "md", cl /* ---------- 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 { id: number; - kind: "confirm" | "prompt"; + kind: "confirm" | "prompt" | "choice"; title: string; message?: ReactNode; confirmLabel?: string; @@ -96,6 +104,7 @@ interface ConfirmRequest { danger?: boolean; defaultValue?: string; placeholder?: string; + choices?: DialogChoice[]; 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 { + return new Promise((resolve) => { + useConfirmStore.getState().push({ id: reqId++, kind: "choice", ...opts, resolve: (v) => resolve(typeof v === "string" ? v : null) }); + }); +} + export function ConfirmHost() { const req = useConfirmStore((s) => s.queue[0]); const pop = useConfirmStore((s) => s.pop); @@ -132,21 +153,37 @@ export function ConfirmHost() { return ( done(req.kind === "prompt" ? null : false)} + onClose={() => done(req.kind === "confirm" ? false : null)} title={req.title} size="sm" footer={ - <> - - - + ) : ( + <> + + + + ) } > {req.message &&

{req.message}

} + {req.kind === "choice" && ( +
+ {req.choices?.map((c) => ( + + ))} +
+ )} {req.kind === "prompt" && (
{ diff --git a/web/src/views/calendar/CalendarContextMenu.tsx b/web/src/views/calendar/CalendarContextMenu.tsx index d9c8b08..02a6898 100644 --- a/web/src/views/calendar/CalendarContextMenu.tsx +++ b/web/src/views/calendar/CalendarContextMenu.tsx @@ -1,13 +1,14 @@ import { Calendar as CalIcon, CalendarDays, Copy, ExternalLink, Palette, Pencil, Plus, Tag, Trash2, X } from "lucide-react"; import { useLocation } from "wouter"; 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 { formatDayMonth } from "@/lib/datetime"; -import { MenuItem, MenuSep, MenuTitle, Popover, type Anchor } from "@/ui/popover"; import { CALENDAR_COLORS } from "@/ui/misc"; +import { MenuItem, MenuSep, MenuTitle, Popover, type Anchor } from "@/ui/popover"; import { confirmDialog } from "@/ui/dialog"; import { toast } from "@/ui/toast"; +import { askDeleteScope, askEditScope, droppedMessage, runScoped } from "./scope"; import { toLocalDateOnly } from "@/lib/dates"; 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 patch = async (p: Record, msg: string) => { + const scope = await askEditScope(ev); + if (!scope) return; try { - await cal.updateEvent(ev, p, false, "series"); - toast.success(msg); + const dropped = await runScoped(scope, (s) => cal.updateEvent(ev, p, false, s)); + 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) { toast.error((err as Error).message); } @@ -88,11 +93,16 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }: }; const del = async () => { onClose(); - const recurring = isRecurring(ev); - if (!(await confirmDialog({ title: recurring ? "Delete all occurrences?" : "Delete this event?", confirmLabel: "Delete", danger: true }))) return; + let scope: EventScope | null = "series"; + 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 { - await cal.destroyEvent(ev, participants > 1, "series"); - toast.success("Event deleted"); + await runScoped(scope, (s) => cal.destroyEvent(ev, participants > 1, s)); + toast.success(scope === "occurrence" ? "Occurrence deleted" : "Event deleted"); } catch (err) { toast.error((err as Error).message); } diff --git a/web/src/views/calendar/EventEditor.tsx b/web/src/views/calendar/EventEditor.tsx index 91133bb..2e0d333 100644 --- a/web/src/views/calendar/EventEditor.tsx +++ b/web/src/views/calendar/EventEditor.tsx @@ -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 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 { useSession } from "@/store/session"; import { useContacts } from "@/store/contacts"; @@ -14,6 +14,7 @@ import { browserTimeZone, dateToZonedLocal, formatDuration, fromInputDateTime, l import { formatClock, formatNumericDate, formatWeekday } from "@/lib/datetime"; import { WEEKDAYS, describeRule, presetFor, ruleFromPreset, type RecurrencePreset } from "@/lib/recurrence"; import { newKey } from "@/lib/contacts"; +import { askEditScope, droppedMessage, runScoped } from "./scope"; export interface EditorInit { event?: CalendarEvent; @@ -24,25 +25,68 @@ export interface EditorInit { 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 }) { const cal = useCalendar(); const settings = useSettings((s) => s.settings); const session = useSession((s) => s.session); const [base, setBase] = useState(init.event && !init.event.baseEventId ? init.event : undefined); + const [scope, setScope] = useState(init.event?.baseEventId ? undefined : "series"); 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(null); useEffect(() => { - if (init.event?.baseEventId) void cal.getEvent(init.event.baseEventId).then((e) => setBase(e)); - else if (!init.event) setBase(null); + const ev = init.event; + 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 }, [init.event?.id]); - if (base === undefined) return null; - return ; + if (base === undefined || scope === undefined) return null; + return ; } -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 contacts = useContacts(); const ev = base; @@ -175,13 +219,23 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE }; const invites = sendInvites && attendees.length > 0; 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 = {}; - for (const [k, v] of Object.entries(obj)) patch[k] = v === undefined ? null : v; - if (Object.keys(ev.calendarIds)[0] !== calendarId) patch.calendarIds = { [calendarId]: true }; - // `ev` is the master: EventEditor resolves `baseEventId` when it opens - // on an occurrence, so the whole series is what this form edits. - await cal.updateEvent(ev, patch, invites, "series"); - toast.success("Event updated"); + for (const [k, v] of Object.entries(source)) patch[k] = v === undefined ? null : v; + if (!oneDate && Object.keys(ev.calendarIds)[0] !== calendarId) patch.calendarIds = { [calendarId]: true }; + const dropped = await runScoped(scope, (s) => cal.updateEvent(ev, patch, invites, s)); + if (!dropped) { setBusy(false); return; } + toast.success(droppedMessage(dropped) ?? (oneDate ? "This occurrence updated" : "Event updated")); } else { const clean: Record = {}; 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 ( }>
- {ev && isRecurring(ev) &&
This is a recurring event — changes apply to the whole series.
} + {ev && isRecurring(ev) && ( +
+ {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."} +
+ )}
setTitle(e.target.value)} />
{allDay ? ( @@ -231,6 +291,7 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE {listTimeZones().map((t) => )} )} + {!oneDate && ( + )}
- {preset === "custom" && ( + {!oneDate && preset === "custom" && (
Repeat every @@ -271,7 +333,7 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE )}
- setCalendarId(e.target.value)}> {calendars.map((c) => )}
@@ -329,7 +391,7 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
-
+ {!oneDate &&
}