From 19a522a4a0c0f0cfd30b0056654863af4967c68b Mon Sep 17 00:00:00 2001 From: Tobias Genannt Date: Sat, 31 Oct 2026 19:06:20 +0100 Subject: [PATCH] feat: expose contact birthday calendar and events in plugin calendar api - Include virtual birthday calendar in api.calendar.listCalendars when showBirthdayCalendar is enabled and plugin holds contacts:read - Merge contact birthday events into api.calendar.getEvents and support single event retrieval in api.calendar.getEvent when plugin holds contacts:read - Use same start and end date range bounds as regular events in getEvents - Guard against mutating birthday calendar or events via createEvent, updateEvent, or deleteEvent - Emit onCalendarChange when showBirthdayCalendar setting changes or when contacts are modified - Add unit tests for birthday calendar listing, querying, filtering, permission checks, and mutation guards --- .../plugin-host-calendar-tasks.test.ts | 95 ++++++++++++++++++- lib/plugin-sandbox/host-api-calendar.ts | 76 +++++++++++++-- lib/plugin-sandbox/host-api.ts | 6 +- stores/contact-store.ts | 27 +++++- stores/settings-store.ts | 10 +- 5 files changed, 200 insertions(+), 14 deletions(-) diff --git a/lib/__tests__/plugin-host-calendar-tasks.test.ts b/lib/__tests__/plugin-host-calendar-tasks.test.ts index 2ae3d3d97..a050a9a40 100644 --- a/lib/__tests__/plugin-host-calendar-tasks.test.ts +++ b/lib/__tests__/plugin-host-calendar-tasks.test.ts @@ -7,6 +7,8 @@ import { useCalendarStore } from '@/stores/calendar-store'; import { useTaskStore } from '@/stores/task-store'; import { useIdentityStore } from '@/stores/identity-store'; import { useSettingsStore } from '@/stores/settings-store'; +import { useContactStore } from '@/stores/contact-store'; +import { BIRTHDAY_CALENDAR_ID, BIRTHDAY_CALENDAR_COLOR } from '@/lib/birthday-calendar'; import { calendarHooks, taskHooks } from '../plugin-hooks'; vi.mock('@/stores/toast-store', () => ({ @@ -24,7 +26,9 @@ function plugin(perms: string[]): InstalledPlugin { } const READ = plugin(['calendar:read', 'tasks:read']); +const READ_WITH_CONTACTS = plugin(['calendar:read', 'tasks:read', 'contacts:read']); const WRITE = plugin(['calendar:read', 'calendar:write', 'tasks:read', 'tasks:write']); +const WRITE_WITH_CONTACTS = plugin(['calendar:read', 'calendar:write', 'tasks:read', 'tasks:write', 'contacts:read']); const NONE = plugin([]); const work = { id: 'cal-work', name: 'Work', color: '#f00', isVisible: true, isDefault: true, isTasksOnly: false } as Calendar; @@ -73,7 +77,8 @@ beforeEach(() => { identities: [{ id: 'id1', name: 'Ann', email: 'ann@example.com' }] as never, preferredPrimaryId: null, }); - useSettingsStore.setState({ timeZone: 'Europe/Berlin' } as never); + useSettingsStore.setState({ timeZone: 'Europe/Berlin', showBirthdayCalendar: false, birthdayCalendarColor: BIRTHDAY_CALENDAR_COLOR } as never); + useContactStore.setState({ contacts: [], contactsLoaded: true }); }); afterEach(() => { @@ -81,6 +86,8 @@ afterEach(() => { useCalendarStore.setState({ calendars: [], events: [] }); useTaskStore.setState({ tasks: [] }); useIdentityStore.setState({ identities: [] }); + useSettingsStore.setState({ showBirthdayCalendar: false }); + useContactStore.setState({ contacts: [] }); calendarHooks.onBeforeEventCreate.removePlugin('veto'); taskHooks.onBeforeTaskDelete.removePlugin('veto'); }); @@ -196,6 +203,92 @@ describe('api.calendar', () => { await expect(call(WRITE, 'calendar.deleteEvent', 'missing')).rejects.toThrow(/no event with id "missing"/); await expect(call(WRITE, 'calendar.updateEvent', 'e1', {})).rejects.toThrow(/patch is empty/); }); + + it('includes the virtual birthday calendar in listCalendars only when contacts:read is held and showBirthdayCalendar is enabled', async () => { + useSettingsStore.setState({ showBirthdayCalendar: true, birthdayCalendarColor: '#f59e0b' }); + // Plugin without contacts:read does not see the birthday calendar + const calsWithoutContacts = await call(READ, 'calendar.listCalendars') as { id: string }[]; + expect(calsWithoutContacts.some((c) => c.id === BIRTHDAY_CALENDAR_ID)).toBe(false); + + // Plugin with contacts:read sees the birthday calendar + const cals = await call(READ_WITH_CONTACTS, 'calendar.listCalendars') as { id: string; name: string; color: string }[]; + expect(cals).toEqual([ + { id: 'cal-work', name: 'Work', color: '#f00', isVisible: true, isDefault: true, isTasksOnly: false }, + { id: 'cal-todo', name: 'To do', color: '', isVisible: true, isDefault: false, isTasksOnly: true }, + { id: BIRTHDAY_CALENDAR_ID, name: 'Birthdays', color: '#f59e0b', isVisible: true, isDefault: false, isTasksOnly: false }, + ]); + }); + + it('returns contact birthdays in getEvents only when contacts:read is held and showBirthdayCalendar is enabled', async () => { + useSettingsStore.setState({ showBirthdayCalendar: true }); + useContactStore.setState({ + contacts: [ + { + id: 'c1', + name: { full: 'Alice Smith' }, + anniversaries: { b1: { kind: 'birth', date: '1990-05-15' } }, + } as never, + ], + }); + + // Plugin without contacts:read does not see birthday events + const eventsWithoutContacts = await call(READ, 'calendar.getEvents', { from: '2026-05-01', to: '2026-06-01' }) as { id: string; calendarId: string }[]; + expect(eventsWithoutContacts.some((e) => e.calendarId === BIRTHDAY_CALENDAR_ID)).toBe(false); + + // Plugin with contacts:read sees birthday events + const events = await call(READ_WITH_CONTACTS, 'calendar.getEvents', { from: '2026-05-01', to: '2026-06-01' }) as { + id: string; title: string; calendarId: string; isAllDay: boolean; start: string; + }[]; + + const birthday = events.find((e) => e.calendarId === BIRTHDAY_CALENDAR_ID); + expect(birthday).toBeDefined(); + expect(birthday).toEqual(expect.objectContaining({ + id: 'birthday-c1-b1-2026', + title: '🎂 Alice Smith (36)', + calendarId: BIRTHDAY_CALENDAR_ID, + isAllDay: true, + start: '2026-05-15T00:00:00', + })); + + // Respects date range bounds using same start/end as regular events + const beforeBirthday = await call(READ_WITH_CONTACTS, 'calendar.getEvents', { + from: '2026-05-01', to: '2026-05-10', + }) as { calendarId: string }[]; + expect(beforeBirthday.some((e) => e.calendarId === BIRTHDAY_CALENDAR_ID)).toBe(false); + + const afterBirthday = await call(READ_WITH_CONTACTS, 'calendar.getEvents', { + from: '2026-05-20', to: '2026-05-31', + }) as { calendarId: string }[]; + expect(afterBirthday.some((e) => e.calendarId === BIRTHDAY_CALENDAR_ID)).toBe(false); + + // Respects calendarIds filtering + const onlyWork = await call(READ_WITH_CONTACTS, 'calendar.getEvents', { + from: '2026-05-01', to: '2026-06-01', calendarIds: ['cal-work'], + }) as { calendarId: string }[]; + expect(onlyWork.some((e) => e.calendarId === BIRTHDAY_CALENDAR_ID)).toBe(false); + + const onlyBirthdays = await call(READ_WITH_CONTACTS, 'calendar.getEvents', { + from: '2026-05-01', to: '2026-06-01', calendarIds: [BIRTHDAY_CALENDAR_ID], + }) as { calendarId: string }[]; + expect(onlyBirthdays.every((e) => e.calendarId === BIRTHDAY_CALENDAR_ID)).toBe(true); + + // Fetching a birthday event by ID requires contacts:read + expect(await call(READ, 'calendar.getEvent', 'birthday-c1-b1-2026')).toBeNull(); + const single = await call(READ_WITH_CONTACTS, 'calendar.getEvent', 'birthday-c1-b1-2026'); + expect(single).toEqual(expect.objectContaining({ + id: 'birthday-c1-b1-2026', + title: '🎂 Alice Smith (36)', + })); + + // Birthday events cannot be mutated + await expect(call(WRITE_WITH_CONTACTS, 'calendar.updateEvent', 'birthday-c1-b1-2026', { title: 'New' })) + .rejects.toThrow(/birthday events cannot be modified/); + await expect(call(WRITE_WITH_CONTACTS, 'calendar.deleteEvent', 'birthday-c1-b1-2026')) + .rejects.toThrow(/birthday events cannot be modified/); + await expect(call(WRITE_WITH_CONTACTS, 'calendar.createEvent', { + title: 'New', calendarId: BIRTHDAY_CALENDAR_ID, start: '2026-05-15T10:00', end: '2026-05-15T11:00', + })).rejects.toThrow(/no calendar with id "__birthday-calendar__"/); + }); }); describe('api.tasks', () => { diff --git a/lib/plugin-sandbox/host-api-calendar.ts b/lib/plugin-sandbox/host-api-calendar.ts index fcaf989b7..2b6733fba 100644 --- a/lib/plugin-sandbox/host-api-calendar.ts +++ b/lib/plugin-sandbox/host-api-calendar.ts @@ -8,16 +8,26 @@ import { createRecurrenceRule } from '@/lib/recurrence-rule'; import type { - CalendarEventInput, CalendarEventView, CalendarView, TaskInput, TaskView, + CalendarEventInput, CalendarEventView, CalendarView, InstalledPlugin, TaskInput, TaskView, } from '../plugin-types'; import type { Calendar, CalendarEvent, CalendarEventAlert, CalendarLink, CalendarParticipant, CalendarRecurrenceRule, - CalendarRelation, CalendarTask, + CalendarRelation, CalendarTask, ContactCard, } from '../jmap/types'; import type { IJMAPClient } from '../jmap/client-interface'; import { useCalendarStore } from '@/stores/calendar-store'; import { useTaskStore } from '@/stores/task-store'; import { useIdentityStore } from '@/stores/identity-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useContactStore } from '@/stores/contact-store'; +import { + BIRTHDAY_CALENDAR_ID, + createBirthdayCalendar, + generateBirthdayEvents, +} from '@/lib/birthday-calendar'; +import { getEffectiveLocale } from '@/i18n/detect-locale'; +import { messagesFor } from './host-api-app'; +import { pluginHasPermission } from './permissions'; import { eventToView, taskToView } from '../plugin-projection'; import { expandRecurringEvents } from '../recurrence-expansion'; import { buildParticipantMap } from '../calendar-participants'; @@ -221,9 +231,39 @@ async function pickCalendar( return choice.id; } -export async function doCalendarListCalendars(): Promise { +async function birthdayCalendarName(): Promise { + try { + const locale = getEffectiveLocale(); + const msgs = await messagesFor(locale); + if (typeof msgs?.birthday_calendar === 'string') return msgs.birthday_calendar; + } catch { + // fallback + } + return 'Birthdays'; +} + +async function ensureContacts(client: IJMAPClient): Promise { + const store = useContactStore.getState(); + if (!store.contactsLoaded && store.contacts.length === 0 && client?.supportsContacts?.()) { + try { + await store.fetchContacts(client); + } catch { + // Ignore network errors; proceed with current contacts + } + } + return useContactStore.getState().contacts; +} + +export async function doCalendarListCalendars(plugin?: InstalledPlugin): Promise { const client = sessionClient('calendar.listCalendars'); - return (await ensureCalendars(client)).map(calendarToView); + const calendars = (await ensureCalendars(client)).map(calendarToView); + const settings = useSettingsStore.getState(); + const canReadContacts = plugin ? pluginHasPermission(plugin, 'contacts:read') : false; + if (settings.showBirthdayCalendar && canReadContacts) { + const name = await birthdayCalendarName(); + calendars.push(calendarToView(createBirthdayCalendar(name, settings.birthdayCalendarColor))); + } + return calendars; } // ─── Reading events ─────────────────────────────────────────── @@ -233,7 +273,7 @@ function rangeBound(when: WhenParts): string { return `${when.date}T${when.time ?? '00:00:00'}${when.utc ? 'Z' : ''}`; } -export async function doCalendarGetEvents(options: unknown): Promise { +export async function doCalendarGetEvents(options: unknown, plugin?: InstalledPlugin): Promise { const method = 'calendar.getEvents'; const opts = readObject(options, method, 'options', ['from', 'to', 'calendarIds']); const from = readWhen(opts.from, method, 'from'); @@ -260,6 +300,15 @@ export async function doCalendarGetEvents(options: unknown): Promise typeof e.start === 'string' && e.start && !Number.isNaN(Date.parse(e.start))); const events = expandRecurringEvents(valid, start, end) .filter((e) => !calendarIds || Object.keys(e.calendarIds || {}).some((id) => e.calendarIds[id] && calendarIds.has(id))); + + const canReadContacts = plugin ? pluginHasPermission(plugin, 'contacts:read') : false; + const { showBirthdayCalendar } = useSettingsStore.getState(); + if (showBirthdayCalendar && canReadContacts && (!calendarIds || calendarIds.has(BIRTHDAY_CALENDAR_ID))) { + const contacts = await ensureContacts(client); + const birthdays = generateBirthdayEvents(contacts, start, end); + events.push(...birthdays); + } + const sortKey = (e: CalendarEvent) => e.utcStart ?? e.start; events.sort((a, b) => (sortKey(a) < sortKey(b) ? -1 : sortKey(a) > sortKey(b) ? 1 : 0)); return events.slice(0, MAX_PLUGIN_EVENTS).map(eventToView); @@ -294,9 +343,21 @@ async function loadEvent(client: IJMAPClient, id: string): Promise { +export async function doCalendarGetEvent(id: unknown, plugin?: InstalledPlugin): Promise { const method = 'calendar.getEvent'; const eventId = readId(id, method); + if (eventId.startsWith('birthday-')) { + const canReadContacts = plugin ? pluginHasPermission(plugin, 'contacts:read') : false; + const { showBirthdayCalendar } = useSettingsStore.getState(); + if (!showBirthdayCalendar || !canReadContacts) return null; + const client = sessionClient(method); + const contacts = await ensureContacts(client); + const match = eventId.match(/^birthday-(.+)-(\d{4})$/); + const year = match ? parseInt(match[2], 10) : new Date().getFullYear(); + const birthdays = generateBirthdayEvents(contacts, `${year}-01-01T00:00:00`, `${year}-12-31T23:59:59`); + const found = birthdays.find((b) => b.id === eventId); + return found ? eventToView(found) : null; + } const event = await loadEvent(sessionClient(method), eventId); return event ? eventToView(event) : null; } @@ -307,6 +368,9 @@ export async function doCalendarGetEvent(id: unknown): Promise { + if (id.startsWith('birthday-')) { + throw new Error(`${method}: birthday events cannot be modified`); + } const event = await loadEvent(client, id); if (!event) throw new Error(`${method}: no event with id "${id}"`); if (!useCalendarStore.getState().events.some((e) => e.id === id)) { diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 76e9a50e7..e482ce0d7 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -2307,9 +2307,9 @@ export async function dispatchApiCall( return undefined; } - case 'calendar.listCalendars': return doCalendarListCalendars(); - case 'calendar.getEvents': return doCalendarGetEvents(args[0]); - case 'calendar.getEvent': return doCalendarGetEvent(args[0]); + case 'calendar.listCalendars': return doCalendarListCalendars(plugin); + case 'calendar.getEvents': return doCalendarGetEvents(args[0], plugin); + case 'calendar.getEvent': return doCalendarGetEvent(args[0], plugin); case 'calendar.createEvent': return doCalendarCreateEvent(args[0], plugin.id); case 'calendar.updateEvent': return doCalendarUpdateEvent(args[0], args[1]); case 'calendar.deleteEvent': return doCalendarDeleteEvent(args[0]); diff --git a/stores/contact-store.ts b/stores/contact-store.ts index f6c8d84ab..cd10a91f7 100644 --- a/stores/contact-store.ts +++ b/stores/contact-store.ts @@ -5,11 +5,12 @@ import type { IJMAPClient } from '@/lib/jmap/client-interface'; import { loadPrincipalDirectory } from '@/lib/jmap/principal-directory'; import { generateUUID } from '@/lib/utils'; import { debug } from '@/lib/debug'; -import { settingsHooks } from '@/lib/plugin-hooks'; +import { calendarHooks, contactHooks, settingsHooks } from '@/lib/plugin-hooks'; +import { BIRTHDAY_CALENDAR_ID } from '@/lib/birthday-calendar'; +import { useSettingsStore } from './settings-store'; import { getClientByLocalAccountId } from './client-registry'; import { sanitizeDisplayName, splitMailbox } from '@/lib/rfc5322-mailbox'; import { inlineContactMedia, writeContactWithNewPhoto, type ContactSaveOptions } from '@/lib/contact-media'; -import { contactHooks } from '@/lib/plugin-hooks'; import { contactToView } from '@/lib/plugin-projection'; import { PrincipalsRequestError } from '@/lib/jmap/principals-error'; import type { Principal } from '@/lib/jmap/types'; @@ -20,6 +21,16 @@ function draftContactView(contact: Partial) { return contactToView({ addressBookIds: {}, ...contact, id: contact.id ?? '' } as ContactCard); } +function notifyBirthdayCalendarIfEnabled() { + try { + if (useSettingsStore.getState().showBirthdayCalendar) { + void calendarHooks.onCalendarChange.emit({ calendarId: BIRTHDAY_CALENDAR_ID, change: 'updated' }); + } + } catch { + // Ignore when stores not initialized in unit tests + } +} + function sameIdSet(a: Set, b: Set): boolean { if (a.size !== b.size) return false; for (const id of a) if (!b.has(id)) return false; @@ -749,6 +760,7 @@ export const useContactStore = create()( isLoading: false, })); void contactHooks.onAfterContactCreate.emit(contactToView(stored)); + notifyBirthdayCalendarIfEnabled(); return true; } catch (error) { const msg = error instanceof Error ? error.message : 'Failed to create contact'; @@ -788,6 +800,7 @@ export const useContactStore = create()( })); const updated = get().contacts.find(c => c.id === id); if (updated) void contactHooks.onAfterContactUpdate.emit(contactToView(updated)); + notifyBirthdayCalendarIfEnabled(); return true; } catch (error) { const msg = error instanceof Error ? error.message : 'Failed to update contact'; @@ -821,6 +834,7 @@ export const useContactStore = create()( }; }); void contactHooks.onAfterContactDelete.emit({ contactIds: [id] }); + notifyBirthdayCalendarIfEnabled(); return true; } catch (error) { const msg = error instanceof Error ? error.message : 'Failed to delete contact'; @@ -833,6 +847,7 @@ export const useContactStore = create()( if (!(await contactHooks.onBeforeContactCreate.intercept(draftContactView(contact)))) return false; set((state) => ({ contacts: [...state.contacts, contact] })); void contactHooks.onAfterContactCreate.emit(contactToView(contact)); + notifyBirthdayCalendarIfEnabled(); return true; }, @@ -845,6 +860,7 @@ export const useContactStore = create()( })); const updated = get().contacts.find(c => c.id === id); if (updated) void contactHooks.onAfterContactUpdate.emit(contactToView(updated)); + notifyBirthdayCalendarIfEnabled(); return true; }, @@ -859,6 +875,7 @@ export const useContactStore = create()( }; }); void contactHooks.onAfterContactDelete.emit({ contactIds: [id] }); + notifyBirthdayCalendarIfEnabled(); return true; }, @@ -1202,6 +1219,7 @@ export const useContactStore = create()( }); if (deletedIds.size > 0) { void contactHooks.onAfterContactDelete.emit({ contactIds: ids.filter(id => deletedIds.has(id)) }); + notifyBirthdayCalendarIfEnabled(); } return true; }, @@ -1616,7 +1634,10 @@ export const useContactStore = create()( } } - if (imported > 0) void contactHooks.onContactsImport.emit({ addressBookId, imported }); + if (imported > 0) { + void contactHooks.onContactsImport.emit({ addressBookId, imported }); + notifyBirthdayCalendarIfEnabled(); + } return imported; }, }); diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 2427a5965..ba8a512c3 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -9,7 +9,8 @@ import type { NotificationSoundChoice } from '@/lib/notification-sound'; import { EMPTY_NOTIFICATION_MUTES, normalizeNotificationMutes, type NotificationMutes } from '@/lib/notification-mutes'; import { apiFetch } from '@/lib/browser-navigation'; import { writePushPreferences } from '@/lib/push-preferences'; -import { settingsHooks } from '@/lib/plugin-hooks'; +import { calendarHooks, settingsHooks } from '@/lib/plugin-hooks'; +import { BIRTHDAY_CALENDAR_ID } from '@/lib/birthday-calendar'; import { generateAccountId } from '@/lib/account-utils'; import { subscriptionOwnerFor } from '@/lib/calendar-subscription-sync'; import type { FolderCountMode } from '@/lib/folder-counts'; @@ -1310,6 +1311,13 @@ export const useSettingsStore = create()( applyInterfaceLayout(value as InterfaceLayout, get().density); } + // Notify calendar plugins (e.g. calendar-agenda) when the virtual birthday calendar is toggled or recolored + if (key === 'showBirthdayCalendar' || key === 'birthdayCalendarColor') { + void calendarHooks.onCalendarChange.emit({ + calendarId: BIRTHDAY_CALENDAR_ID, + change: key === 'showBirthdayCalendar' && !value ? 'removed' : 'updated', + }); + } }; const buildExport = (opts?: { forSync?: boolean; subscriptionOwner?: string }): string => {