feat: duplicate, share as .ics and copy the meeting link of an event; link imported UIDs instead of skipping them (#113)

This commit is contained in:
Linus Rath
2026-08-29 18:49:08 +02:00
parent 0159d6acc8
commit 5fe8e407c7
8 changed files with 502 additions and 14 deletions
+2 -2
View File
@@ -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`.
+3 -1
View File
@@ -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...",
+22 -1
View File
@@ -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<void>;
}
@@ -130,6 +135,8 @@ export function EventDetailSheet({
onEdit,
onDelete,
onDuplicate,
onExport,
onCopyLink,
onRsvp,
}: EventDetailSheetProps) {
const c = useColors();
@@ -367,7 +374,7 @@ export function EventDetailSheet({
)}
</ScrollView>
{(canEdit || onDuplicate) && (
{(canEdit || onDuplicate || onExport || (onCopyLink && videoUri)) && (
<View style={styles.actions}>
{onEdit && canEdit && (
<ActionButton
@@ -383,6 +390,20 @@ export function EventDetailSheet({
onPress={() => onDuplicate(event)}
/>
)}
{onExport && (
<ActionButton
icon={<Share2 size={18} color={c.text} />}
label={t('calendar.events.export_ics_short', '.ics')}
onPress={() => onExport(event)}
/>
)}
{onCopyLink && videoUri && (
<ActionButton
icon={<Link2 size={18} color={c.text} />}
label={t('calendar.events.copy_link_short', 'Link')}
onPress={() => onCopyLink(event, videoUri)}
/>
)}
{onDelete && canEdit && (
<ActionButton
icon={<Trash2 size={18} color={c.error} />}
@@ -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();
});
});
+210
View File
@@ -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, string> }): 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<CalendarEvent, 'virtualLocations'>): 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<void> {
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' });
}
+54
View File
@@ -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<CalendarEvent> = {
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) =>
@@ -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<typeof vi.fn>;
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({
+105 -10
View File
@@ -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<CalendarEvent>): Partial<CalendarEvent> {
const src = event as Record<string, unknown> & Partial<CalendarEvent>;
let participants: Record<string, Participant> | undefined;
if (src.participants) {
participants = {};
for (const [key, p] of Object.entries(src.participants)) {
const cleaned: Record<string, unknown> = {
'@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<string, unknown> = {
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<CalendarEvent>;
}
export interface LoadedRange {
after: string;
before: string;
@@ -499,22 +567,49 @@ export const useCalendarStore = create<CalendarState>()(
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<string, CalendarEvent>();
for (const e of existing) if (e.uid) byUid.set(e.uid, e);
const fresh: Partial<CalendarEvent>[] = [];
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) => {