mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
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:
@@ -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`.
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
}
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user