mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 16:16:59 +00:00
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
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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<CalendarView[]> {
|
||||
async function birthdayCalendarName(): Promise<string> {
|
||||
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<ContactCard[]> {
|
||||
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<CalendarView[]> {
|
||||
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<CalendarEventView[]> {
|
||||
export async function doCalendarGetEvents(options: unknown, plugin?: InstalledPlugin): Promise<CalendarEventView[]> {
|
||||
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<CalendarEve
|
||||
const valid = raw.filter((e) => 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<CalendarEvent
|
||||
};
|
||||
}
|
||||
|
||||
export async function doCalendarGetEvent(id: unknown): Promise<CalendarEventView | null> {
|
||||
export async function doCalendarGetEvent(id: unknown, plugin?: InstalledPlugin): Promise<CalendarEventView | null> {
|
||||
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<CalendarEventView
|
||||
* range the calendar has loaded is added to that list first.
|
||||
*/
|
||||
async function eventForMutation(client: IJMAPClient, method: string, id: string): Promise<CalendarEvent> {
|
||||
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)) {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
+24
-3
@@ -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<ContactCard>) {
|
||||
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<string>, b: Set<string>): 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<ContactStore>()(
|
||||
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<ContactStore>()(
|
||||
}));
|
||||
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<ContactStore>()(
|
||||
};
|
||||
});
|
||||
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<ContactStore>()(
|
||||
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<ContactStore>()(
|
||||
}));
|
||||
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<ContactStore>()(
|
||||
};
|
||||
});
|
||||
void contactHooks.onAfterContactDelete.emit({ contactIds: [id] });
|
||||
notifyBirthdayCalendarIfEnabled();
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -1202,6 +1219,7 @@ export const useContactStore = create<ContactStore>()(
|
||||
});
|
||||
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<ContactStore>()(
|
||||
}
|
||||
}
|
||||
|
||||
if (imported > 0) void contactHooks.onContactsImport.emit({ addressBookId, imported });
|
||||
if (imported > 0) {
|
||||
void contactHooks.onContactsImport.emit({ addressBookId, imported });
|
||||
notifyBirthdayCalendarIfEnabled();
|
||||
}
|
||||
return imported;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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<SettingsState>()(
|
||||
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 => {
|
||||
|
||||
Reference in New Issue
Block a user