diff --git a/docs/parity/05-calendar.md b/docs/parity/05-calendar.md index fd4d244f..cb796fa5 100644 --- a/docs/parity/05-calendar.md +++ b/docs/parity/05-calendar.md @@ -37,7 +37,7 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi - What RN does: `handleSave` emits `undefined` for cleared fields (`recurrenceRules: undefined`, `participants: undefined`, `alerts: remindersToAlerts([]) === undefined`, `locations: undefined`) which JSON serialisation drops, so the server keeps the old value (ref `src/components/calendar/EventModal.tsx:262-281`, `src/lib/calendar-alerts.ts:72-73`). Setting "Does not repeat" on a recurring event, removing all attendees, deleting the last reminder or clearing the location silently does nothing. - Fix hint: in edit mode compare against `event` and send `null` for fields that existed and are now empty; RN's `cleanRecurrenceRules` already turns `[]`/`null` into `recurrenceRule: null` (`src/api/calendar.ts:115-118`), so `recurrenceRules: []` works today for recurrence. -- [ ] **`this_and_future` / huge-span RRULE (#735) — RN not affected** — `P3` — `bugfix-parity` +- [x] **`this_and_future` / huge-span RRULE (#735) — RN not affected** — `P3` — `bugfix-parity` — verified, nothing to fix (caps already in place; "this and following" truncates with `until` and drops `count`) - What WEB does: memory note on #735 (Stalwart u32 overflow for spans > 136 years). WEB's editor caps `until` to a picked date and `count` to 999. - What RN does: same caps (`src/lib/recurrence.ts:227-229`, `RecurrenceEditor.tsx:139-144, 244-249`), no alarms with absolute triggers far in the future. Not affected; noted so it isn't re-investigated. @@ -265,7 +265,7 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi ### Tasks -- [ ] **Task sheet supports only title + due date; no edit, description, priority, due time, progress states, alerts, filters, "show on calendar"** — `P2` — `partial` +- [x] **Task sheet supports only title + due date; no edit, description, priority, due time, progress states, alerts, filters, "show on calendar"** — `P2` — `partial` — fixed in 09c24fa (edit, description, priority, due date + time, calendar move, all/pending/completed/overdue filters, overdue-first sort, `needs-action` <-> `completed` with `progressUpdated`, due tasks overlaid on the grid; task alerts still come only from the server data) - What WEB does: task modal with description, due date + optional time, priority (none/high/medium/low -> 1-9), progress, calendar, alert; list filters all/pending/completed/overdue, overdue-first sort, `showTasksOnCalendar` overlays due tasks on the grid; completion toggles `needs-action` <-> `completed` with `progressUpdated` (ref `components/calendar/task-modal.tsx:55-140`, `task-list-view.tsx:80-110`, `stores/task-store.ts:366, 442-452`). - What RN does: `TasksSheet` creates `{ title, progress, due }` only, no tap-to-edit, delete + toggle only (`src/components/calendar/TasksSheet.tsx:117-135, 210-235`); un-completing sets `in-process` (`src/stores/calendar-store.ts:418-426`); `showTasksOnCalendar` (`settings-store.ts:174`) is unused; tasks are never drawn in month/week/agenda. - Fix hint: add a task edit sheet mirroring `task-modal.tsx`, filter chips, and render due tasks as all-day chips when `showTasksOnCalendar`. diff --git a/locales/en/common.json b/locales/en/common.json index 9bb0850d..4c3220f3 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -2763,7 +2763,9 @@ "new_all_day_event": "New all-day event", "new_task": "New task", "go_to_today": "Go to today", - "tap_to_create": "Tap + to create one" + "tap_to_create": "Tap + to create one", + "export_ics_short": ".ics", + "copy_link_short": "Link" }, "detail": { "add_note": "Add a note...", diff --git a/src/components/calendar/EventDetailSheet.tsx b/src/components/calendar/EventDetailSheet.tsx index f664c393..12c5d696 100644 --- a/src/components/calendar/EventDetailSheet.tsx +++ b/src/components/calendar/EventDetailSheet.tsx @@ -21,8 +21,10 @@ import { Copy, HelpCircle, MapPin, + Link2, Pencil, Repeat, + Share2, Trash2, Users, Video, @@ -64,6 +66,9 @@ interface EventDetailSheetProps { onEdit?: (event: CalendarEvent) => void; onDelete?: (event: CalendarEvent) => void; onDuplicate?: (event: CalendarEvent) => void; + onExport?: (event: CalendarEvent) => void; + // Copy the meeting link; only offered when the event has one. + onCopyLink?: (event: CalendarEvent, link: string) => void; onRsvp?: (event: CalendarEvent, participantId: string, status: RsvpStatus) => void | Promise; } @@ -130,6 +135,8 @@ export function EventDetailSheet({ onEdit, onDelete, onDuplicate, + onExport, + onCopyLink, onRsvp, }: EventDetailSheetProps) { const c = useColors(); @@ -367,7 +374,7 @@ export function EventDetailSheet({ )} - {(canEdit || onDuplicate) && ( + {(canEdit || onDuplicate || onExport || (onCopyLink && videoUri)) && ( {onEdit && canEdit && ( onDuplicate(event)} /> )} + {onExport && ( + } + label={t('calendar.events.export_ics_short', '.ics')} + onPress={() => onExport(event)} + /> + )} + {onCopyLink && videoUri && ( + } + label={t('calendar.events.copy_link_short', 'Link')} + onPress={() => onCopyLink(event, videoUri)} + /> + )} {onDelete && canEdit && ( } diff --git a/src/lib/__tests__/calendar-ics-export.test.ts b/src/lib/__tests__/calendar-ics-export.test.ts new file mode 100644 index 00000000..9bde1dbc --- /dev/null +++ b/src/lib/__tests__/calendar-ics-export.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest'; +import type { CalendarEvent } from '../../api/types'; +import { eventToICS, getMeetingLink, icsFilename } from '../calendar-ics-export'; + +const event: CalendarEvent = { + id: 'ev1', + uid: 'uid-1', + title: 'Team sync; weekly', + description: 'Line 1\nLine 2', + calendarIds: { 'cal-1': true }, + start: '2026-03-02T09:00:00', + timeZone: 'Europe/Berlin', + utcEnd: '2026-03-02T08:30:00Z', + duration: 'PT30M', + status: 'confirmed', + organizerCalendarAddress: 'mailto:alice@example.com', + participants: { + a: { calendarAddress: 'mailto:alice@example.com', name: 'Alice' }, + b: { calendarAddress: 'mailto:bob@example.com', name: 'Bob', roles: { attendee: true }, expectReply: true }, + }, + recurrenceRules: [{ frequency: 'weekly', interval: 2, byDay: [{ day: 'mo' }] }], + alerts: { r1: { trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' }, action: 'display' } }, + virtualLocations: { v: { uri: 'https://meet.example.com/abc' } }, +}; + +describe('eventToICS', () => { + it('serialises a timed event with zone, organizer, attendees, rule and alarm', () => { + const ics = eventToICS(event); + expect(ics).toContain('BEGIN:VCALENDAR\r\n'); + expect(ics).toContain('UID:uid-1'); + expect(ics).toContain('DTSTART;TZID=Europe/Berlin:20260302T090000'); + expect(ics).toContain('DTEND:20260302T083000Z'); + expect(ics).toContain('SUMMARY:Team sync\\; weekly'); + expect(ics).toContain('DESCRIPTION:Line 1\\nLine 2'); + expect(ics).toContain('ORGANIZER;CN=Alice:mailto:alice@example.com'); + expect(ics).toContain('ATTENDEE;CN=Bob;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:bob@example.com'); + expect(ics).not.toContain('ATTENDEE;CN=Alice'); + expect(ics).toContain('RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO'); + expect(ics).toContain('BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT15M'); + expect(ics).toContain('URL:https://meet.example.com/abc'); + expect(ics.endsWith('END:VCALENDAR\r\n')).toBe(true); + }); + + it('writes all-day events as DATE values with a duration', () => { + const ics = eventToICS({ ...event, showWithoutTime: true, start: '2026-03-02T00:00:00', duration: 'P2D', utcEnd: undefined }); + expect(ics).toContain('DTSTART;VALUE=DATE:20260302'); + expect(ics).toContain('DURATION:P2D'); + expect(ics).not.toContain('DTEND'); + }); + + it('folds long lines at 74 octets', () => { + const ics = eventToICS({ ...event, description: 'x'.repeat(200) }); + const long = ics.split('\r\n').filter((l) => l.length > 75); + expect(long).toHaveLength(0); + }); +}); + +describe('helpers', () => { + it('builds a safe filename and finds the meeting link', () => { + expect(icsFilename(event)).toBe('Team-sync-weekly.ics'); + expect(icsFilename({ ...event, title: '' })).toBe('event.ics'); + expect(getMeetingLink(event)).toBe('https://meet.example.com/abc'); + expect(getMeetingLink({ virtualLocations: undefined })).toBeNull(); + }); +}); diff --git a/src/lib/calendar-ics-export.ts b/src/lib/calendar-ics-export.ts new file mode 100644 index 00000000..29699ad1 --- /dev/null +++ b/src/lib/calendar-ics-export.ts @@ -0,0 +1,210 @@ +import type { CalendarEvent } from '../api/types'; + +// Port of the webmail's lib/calendar-ics-export.ts: serialise one event as +// an RFC 5545 VCALENDAR so it can be shared as a .ics file. + +const MAX_LINE_OCTETS = 74; + +function foldLine(line: string): string { + if (line.length <= MAX_LINE_OCTETS) return line; + const chunks: string[] = [line.slice(0, MAX_LINE_OCTETS)]; + let pos = MAX_LINE_OCTETS; + while (pos < line.length) { + chunks.push(' ' + line.slice(pos, pos + MAX_LINE_OCTETS - 1)); + pos += MAX_LINE_OCTETS - 1; + } + return chunks.join('\r\n'); +} + +// RFC 5545 §3.3.11 - escape backslash, semicolon, comma, and newline in TEXT values. +export function escapeText(value: string): string { + return value + .replace(/\\/g, '\\\\') + .replace(/;/g, '\\;') + .replace(/,/g, '\\,') + .replace(/\r\n|\r|\n/g, '\\n'); +} + +function stripDateSeparators(value: string): string { + return value.replace(/[-:]/g, '').replace(/\.\d{3}/, ''); +} + +function dateOnly(value: string): string { + return value.replace(/-/g, '').substring(0, 8); +} + +function formatNow(): string { + return stripDateSeparators(new Date().toISOString().replace(/\.\d{3}/, '')); +} + +function pushDateProp( + lines: string[], + prop: 'DTSTART' | 'DTEND', + value: string, + showWithoutTime: boolean, + tz?: string | null, +): void { + if (showWithoutTime) { + lines.push(`${prop};VALUE=DATE:${dateOnly(value)}`); + return; + } + if (value.endsWith('Z')) { + lines.push(`${prop}:${stripDateSeparators(value)}`); + return; + } + const basic = stripDateSeparators(value); + if (tz) { + lines.push(`${prop};TZID=${tz}:${basic}`); + } else { + lines.push(`${prop}:${basic}`); + } +} + +function pushFrequencyRule(lines: string[], event: CalendarEvent): void { + const rule = event.recurrenceRules?.[0]; + if (!rule) return; + const parts: string[] = [`FREQ=${rule.frequency.toUpperCase()}`]; + if (rule.interval && rule.interval > 1) parts.push(`INTERVAL=${rule.interval}`); + if (rule.count != null) parts.push(`COUNT=${rule.count}`); + if (rule.until) parts.push(`UNTIL=${stripDateSeparators(rule.until)}`); + if (rule.byDay?.length) { + const days = rule.byDay + .map((d) => `${d.nthOfPeriod ?? ''}${d.day.toUpperCase()}`) + .join(','); + parts.push(`BYDAY=${days}`); + } + if (rule.byMonthDay?.length) parts.push(`BYMONTHDAY=${rule.byMonthDay.join(',')}`); + if (rule.byMonth?.length) parts.push(`BYMONTH=${rule.byMonth.join(',')}`); + lines.push(`RRULE:${parts.join(';')}`); +} + +function pushAlerts(lines: string[], event: CalendarEvent): void { + if (!event.alerts) return; + for (const alert of Object.values(event.alerts)) { + const trigger = alert.trigger; + if (!trigger) continue; + lines.push('BEGIN:VALARM'); + lines.push(`ACTION:${(alert.action || 'display').toUpperCase()}`); + if (trigger['@type'] === 'OffsetTrigger' && trigger.offset) { + const related = trigger.relativeTo === 'end' ? ';RELATED=END' : ''; + lines.push(`TRIGGER${related}:${trigger.offset}`); + } else if (trigger['@type'] === 'AbsoluteTrigger' && trigger.when) { + lines.push(`TRIGGER;VALUE=DATE-TIME:${stripDateSeparators(trigger.when)}`); + } + lines.push(`DESCRIPTION:${escapeText(event.title || 'Reminder')}`); + lines.push('END:VALARM'); + } +} + +function participantEmail(p: { email?: string; calendarAddress?: string; sendTo?: Record }): string | undefined { + return ( + p.email + || p.calendarAddress?.replace(/^mailto:/i, '') + || p.sendTo?.imip?.replace(/^mailto:/i, '') + ); +} + +export function eventToICS(event: CalendarEvent): string { + const now = formatNow(); + const lines: string[] = [ + 'BEGIN:VCALENDAR', + 'PRODID:-//Bulwark Mobile//EN', + 'VERSION:2.0', + 'CALSCALE:GREGORIAN', + 'METHOD:PUBLISH', + 'BEGIN:VEVENT', + `UID:${event.uid}`, + `DTSTAMP:${now}`, + ]; + + if (event.created) lines.push(`CREATED:${stripDateSeparators(event.created)}`); + if (event.updated) lines.push(`LAST-MODIFIED:${stripDateSeparators(event.updated)}`); + if (event.sequence != null) lines.push(`SEQUENCE:${event.sequence}`); + + const allDay = !!event.showWithoutTime; + if (event.start) { + pushDateProp(lines, 'DTSTART', event.start, allDay, event.timeZone); + } + if (event.utcEnd && !allDay) { + pushDateProp(lines, 'DTEND', event.utcEnd, allDay, event.timeZone); + } else if (event.duration) { + lines.push(`DURATION:${event.duration}`); + } + + if (event.title) lines.push(`SUMMARY:${escapeText(event.title)}`); + if (event.description) lines.push(`DESCRIPTION:${escapeText(event.description)}`); + if (event.status) lines.push(`STATUS:${event.status.toUpperCase()}`); + if (event.freeBusyStatus) { + lines.push(`TRANSP:${event.freeBusyStatus === 'free' ? 'TRANSPARENT' : 'OPAQUE'}`); + } + + if (event.locations) { + const first = Object.values(event.locations)[0]; + if (first?.name) lines.push(`LOCATION:${escapeText(first.name)}`); + } + if (event.virtualLocations) { + for (const loc of Object.values(event.virtualLocations)) { + if (loc.uri) lines.push(`URL:${loc.uri}`); + } + } + + if (event.participants) { + const organizerEmail = event.organizerCalendarAddress?.replace(/^mailto:/i, '').toLowerCase(); + const organizer = Object.values(event.participants).find( + (p) => p.roles?.owner || (organizerEmail && participantEmail(p)?.toLowerCase() === organizerEmail), + ); + const orgEmail = organizer ? participantEmail(organizer) : organizerEmail; + if (orgEmail) { + const cn = organizer?.name ? `;CN=${escapeText(organizer.name)}` : ''; + lines.push(`ORGANIZER${cn}:mailto:${orgEmail}`); + } + for (const p of Object.values(event.participants)) { + if (p === organizer) continue; + const email = participantEmail(p); + if (!email) continue; + const cn = p.name ? `;CN=${escapeText(p.name)}` : ''; + const partstat = p.participationStatus + ? `;PARTSTAT=${p.participationStatus.toUpperCase()}` + : ';PARTSTAT=NEEDS-ACTION'; + const rsvp = p.expectReply ? ';RSVP=TRUE' : ''; + lines.push(`ATTENDEE${cn}${partstat}${rsvp}:mailto:${email}`); + } + } + + pushFrequencyRule(lines, event); + pushAlerts(lines, event); + + lines.push('END:VEVENT'); + lines.push('END:VCALENDAR'); + + return lines.map(foldLine).join('\r\n') + '\r\n'; +} + +export function icsFilename(event: CalendarEvent): string { + const base = (event.title || 'event').replace(/[^a-z0-9\-_ ]/gi, '').trim().replace(/\s+/g, '-'); + return `${base.slice(0, 60) || 'event'}.ics`; +} + +/** The first meeting link of an event, if any. */ +export function getMeetingLink(event: Pick): string | null { + if (!event.virtualLocations) return null; + for (const loc of Object.values(event.virtualLocations)) { + if (loc.uri) return loc.uri; + } + return null; +} + +/** Write the .ics to the cache directory and open the OS share sheet. */ +export async function shareEventICS(event: CalendarEvent): Promise { + const [{ File, Paths }, Sharing] = await Promise.all([ + import('expo-file-system'), + import('expo-sharing'), + ]); + const file = new File(Paths.cache, icsFilename(event)); + if (file.exists) file.delete(); + file.write(eventToICS(event)); + if (!(await Sharing.isAvailableAsync())) { + throw new Error('Sharing is not available on this device'); + } + await Sharing.shareAsync(file.uri, { mimeType: 'text/calendar', UTI: 'com.apple.ical.ics' }); +} diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index 12d06a4a..e58884c6 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -61,6 +61,7 @@ import { applySharedCalendarColors, buildEventDayIndex, eventsOnDayFromIndex, + getEventStartDate, getPrimaryCalendarId, pickUnusedCalendarColor, sharedCalendarColorKey, @@ -81,6 +82,8 @@ import { useLocaleStore } from '../stores/locale-store'; import { useUserCalendarAddresses } from '../lib/calendar-user-addresses'; import { useCalendarSubscriptionsStore } from '../stores/calendar-subscriptions-store'; import { startCalendarNotificationSync } from '../lib/calendar-notifications'; +import { shareEventICS } from '../lib/calendar-ics-export'; +import * as Clipboard from 'expo-clipboard'; import type { Calendar, CalendarEvent, RecurrenceRule } from '../api/types'; type ViewMode = 'month' | 'week' | 'agenda'; @@ -594,6 +597,54 @@ export default function CalendarScreen() { [t], ); + // Clone the event one day later and open it in the editor (webmail's + // handleDuplicateFromDetail). + const handleDuplicateFromDetail = React.useCallback( + async (event: CalendarEvent) => { + setDetailEvent(null); + const newStart = addDays(getEventStartDate(event), 1); + const data: Partial = { + title: event.title, + description: event.description, + start: event.showWithoutTime + ? format(newStart, "yyyy-MM-dd'T'00:00:00") + : format(newStart, "yyyy-MM-dd'T'HH:mm:ss"), + duration: event.duration, + timeZone: event.timeZone, + showWithoutTime: event.showWithoutTime, + status: 'confirmed', + freeBusyStatus: event.freeBusyStatus, + }; + if (event.locations) data.locations = JSON.parse(JSON.stringify(event.locations)); + if (event.virtualLocations) data.virtualLocations = JSON.parse(JSON.stringify(event.virtualLocations)); + if (event.recurrenceRules?.length) data.recurrenceRules = JSON.parse(JSON.stringify(event.recurrenceRules)); + if (event.alerts) data.alerts = JSON.parse(JSON.stringify(event.alerts)); + const calendarId = getPrimaryCalendarId(event) || ''; + try { + const created = await createEvent(data, calendarId); + const stored = useCalendarStore.getState().events.find((e) => e.id === created.id) ?? created; + openEditDirect(stored); + } catch (err) { + reportError(err); + } + }, + [createEvent, openEditDirect, reportError], + ); + + const handleExportFromDetail = React.useCallback( + (event: CalendarEvent) => { + shareEventICS(event).catch(reportError); + }, + [reportError], + ); + + const handleCopyLink = React.useCallback( + (_event: CalendarEvent, link: string) => { + Clipboard.setStringAsync(link).catch(reportError); + }, + [reportError], + ); + const handleCalendarEditSave = React.useCallback( async (values: CalendarEditValues) => { if (!calendarEditTarget) return; @@ -809,6 +860,9 @@ export default function CalendarScreen() { onClose={() => setDetailEvent(null)} onEdit={handleEditFromDetail} onDelete={handleDeleteFromDetail} + onDuplicate={(ev) => { if (!isReadOnlyEvent(ev)) void handleDuplicateFromDetail(ev); }} + onExport={handleExportFromDetail} + onCopyLink={handleCopyLink} onRsvp={async (ev, participantId, status) => { await rsvpEvent(ev.id, participantId, status, buildReplyTo(ev)); setDetailEvent((cur) => diff --git a/src/stores/__tests__/calendar-store.test.ts b/src/stores/__tests__/calendar-store.test.ts index ae5f9ba2..5ef5eabd 100644 --- a/src/stores/__tests__/calendar-store.test.ts +++ b/src/stores/__tests__/calendar-store.test.ts @@ -11,6 +11,7 @@ vi.mock('../../api/calendar', () => ({ createEvent: vi.fn(), updateEvent: vi.fn(), deleteEvents: vi.fn(), + batchCreateEvents: vi.fn(), setDefaultCalendar: vi.fn(), })); @@ -538,6 +539,46 @@ describe('calendar-store', () => { }); }); + describe('importEvents', () => { + it('links UIDs that exist in another calendar instead of skipping them and whitelists properties (#113)', async () => { + useCalendarStore.setState({ + calendars: [{ id: 'cal-1' }, { id: 'cal-2' }] as any, + loadedRange: { after: '2026-03-01T00:00:00Z', before: '2026-03-31T00:00:00Z' }, + }); + mockQueryEvents.mockResolvedValue(['e1', 'e2']); + mockGetEvents.mockResolvedValue([ + { id: 'e1', uid: 'uid-elsewhere', calendarIds: { 'cal-1': true } }, + { id: 'e2', uid: 'uid-here', calendarIds: { 'cal-2': true } }, + ]); + mockUpdateEvent.mockResolvedValue(undefined); + const batch = calendarApi.batchCreateEvents as ReturnType; + batch.mockResolvedValue(1); + + const count = await useCalendarStore.getState().importEvents( + [ + { uid: 'uid-elsewhere', title: 'Linked', start: '2026-03-02T09:00:00' }, + { uid: 'uid-here', title: 'Dup', start: '2026-03-02T09:00:00' }, + { + uid: 'uid-new', title: 'New', start: '2026-03-03T00:00:00', showWithoutTime: true, + duration: 'PT23H59M59S', timeZone: 'Europe/Berlin', utcStart: '2026-03-03T00:00:00Z', + participants: { p: { email: 'x@y', sendTo: { imip: 'mailto:x@y' }, roles: { attendee: true } } }, + } as any, + ], + 'cal-2', + ); + + // The UID in cal-1 is linked into cal-2; the one already in cal-2 is skipped. + expect(mockUpdateEvent).toHaveBeenCalledWith('e1', { calendarIds: { 'cal-1': true, 'cal-2': true } }, undefined, undefined); + expect(count).toBe(2); + const created = batch.mock.calls[0][0][0]; + expect(created.duration).toBe('P1D'); + expect(created.timeZone).toBeUndefined(); + expect(created.utcStart).toBeUndefined(); + expect(created.participants.p.calendarAddress).toBe('mailto:x@y'); + expect(created.participants.p.sendTo).toBeUndefined(); + }); + }); + describe('recurring series mutations', () => { it('updateEvent on an occurrence patches the master and refetches the range', async () => { useCalendarStore.setState({ diff --git a/src/stores/calendar-store.ts b/src/stores/calendar-store.ts index 77a50eda..9408c7e3 100644 --- a/src/stores/calendar-store.ts +++ b/src/stores/calendar-store.ts @@ -1,7 +1,8 @@ import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import type { Calendar, CalendarEvent, CalendarRights, StateChange } from '../api/types'; +import type { Calendar, CalendarEvent, CalendarRights, Participant, StateChange } from '../api/types'; +import { normalizeAllDayDuration } from '../lib/calendar-utils'; import { type CalendarUpdates, updateCalendar as apiUpdateCalendar, @@ -67,6 +68,73 @@ function mapServerEventToStoreEvent( }; } +// Whitelist the JSCalendar properties an imported (parsed) event may carry: +// drop server-computed / identity fields (utcStart, utcEnd, isOrigin, created, +// updated, id) that CalendarEvent/set rejects, rewrite participants onto +// `calendarAddress` and normalise all-day duration / time zone. Mirrors the +// webmail's import preparation (#113). +export function prepareImportedEvent(event: Partial): Partial { + const src = event as Record & Partial; + let participants: Record | undefined; + if (src.participants) { + participants = {}; + for (const [key, p] of Object.entries(src.participants)) { + const cleaned: Record = { + '@type': 'Participant', + name: p.name, + email: p.email, + calendarAddress: p.calendarAddress || p.sendTo?.imip, + description: p.description, + kind: p.kind, + roles: p.roles, + participationStatus: p.participationStatus, + participationComment: p.participationComment, + expectReply: p.expectReply, + scheduleAgent: p.scheduleAgent, + }; + for (const k of Object.keys(cleaned)) { + if (cleaned[k] === undefined || cleaned[k] === null) delete cleaned[k]; + } + participants[key] = cleaned as Participant; + } + } + const data: Record = { + uid: src.uid, + title: src.title, + description: src.description, + start: src.start, + duration: src.showWithoutTime ? normalizeAllDayDuration(src.duration) : src.duration, + timeZone: src.showWithoutTime ? null : src.timeZone, + showWithoutTime: src.showWithoutTime, + status: src.status, + freeBusyStatus: src.freeBusyStatus, + color: src.color, + keywords: src.keywords, + // Stalwart derives the iCalendar ORGANIZER solely from + // organizerCalendarAddress; dropping it would strip the ORGANIZER from + // imported invites and break RSVP replies afterwards. + organizerCalendarAddress: src.organizerCalendarAddress || src.replyTo?.imip, + locations: src.locations, + virtualLocations: src.virtualLocations, + links: src.links, + recurrenceRules: src.recurrenceRules, + recurrenceOverrides: src.recurrenceOverrides, + excludedRecurrenceRules: src.excludedRecurrenceRules, + alerts: src.alerts, + useDefaultAlerts: src.useDefaultAlerts, + participants, + '@type': src['@type'], + due: src.due, + progress: src.progress, + priority: src.priority, + percentComplete: src.percentComplete, + }; + for (const k of Object.keys(data)) { + if (data[k] === undefined || data[k] === null) delete data[k]; + } + return data as Partial; +} + export interface LoadedRange { after: string; before: string; @@ -499,22 +567,49 @@ export const useCalendarStore = create()( const cal = get().calendars.find((c) => c.id === calendarId); const accountId = cal?.accountId; const serverCalendarId = cal?.originalId || calendarId; - // Stalwart enforces UID uniqueness across calendars. Skip events whose UID - // already exists; create the rest. (We don't attempt cross-calendar linking - // on mobile — a duplicate is simply skipped.) + // Stalwart enforces UID uniqueness across calendars (#113): + // - UID already in the target calendar -> skip (true duplicate) + // - UID in another calendar -> link it to the target via calendarIds + // - new UID -> create let toCreate = events; + let linked = 0; try { const existingIds = await queryEvents([], '', '', accountId); const existing = existingIds.length > 0 ? await fetchEvents(existingIds, accountId) : []; - const seenUids = new Set(existing.map((e) => e.uid).filter(Boolean) as string[]); - toCreate = events.filter((e) => !e.uid || !seenUids.has(e.uid)); + const byUid = new Map(); + for (const e of existing) if (e.uid) byUid.set(e.uid, e); + const fresh: Partial[] = []; + for (const e of events) { + const found = e.uid ? byUid.get(e.uid) : undefined; + if (!found) { + fresh.push(e); + continue; + } + if (found.calendarIds?.[serverCalendarId]) continue; + try { + await apiUpdateEvent( + found.id, + { calendarIds: { ...(found.calendarIds || {}), [serverCalendarId]: true } }, + undefined, + accountId, + ); + linked++; + } catch { + // Leave it where it is; the import of the rest continues. + } + } + toCreate = fresh; } catch { // Couldn't dedupe — proceed and let the server reject genuine dupes. } - if (toCreate.length === 0) return 0; - const count = await apiBatchCreateEvents(toCreate, serverCalendarId, accountId); - await get().refresh(); - return count; + const prepared = toCreate.map(prepareImportedEvent); + let count = 0; + // Batch in chunks of 50 to avoid oversized requests. + for (let i = 0; i < prepared.length; i += 50) { + count += await apiBatchCreateEvents(prepared.slice(i, i + 50), serverCalendarId, accountId); + } + if (count > 0 || linked > 0) await get().refresh(); + return count + linked; }, createCalendar: async (name, color, description) => {