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.
|
- 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.
|
- 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 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.
|
- 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
|
### 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 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.
|
- 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`.
|
- 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_all_day_event": "New all-day event",
|
||||||
"new_task": "New task",
|
"new_task": "New task",
|
||||||
"go_to_today": "Go to today",
|
"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": {
|
"detail": {
|
||||||
"add_note": "Add a note...",
|
"add_note": "Add a note...",
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
MapPin,
|
MapPin,
|
||||||
|
Link2,
|
||||||
Pencil,
|
Pencil,
|
||||||
Repeat,
|
Repeat,
|
||||||
|
Share2,
|
||||||
Trash2,
|
Trash2,
|
||||||
Users,
|
Users,
|
||||||
Video,
|
Video,
|
||||||
@@ -64,6 +66,9 @@ interface EventDetailSheetProps {
|
|||||||
onEdit?: (event: CalendarEvent) => void;
|
onEdit?: (event: CalendarEvent) => void;
|
||||||
onDelete?: (event: CalendarEvent) => void;
|
onDelete?: (event: CalendarEvent) => void;
|
||||||
onDuplicate?: (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>;
|
onRsvp?: (event: CalendarEvent, participantId: string, status: RsvpStatus) => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +135,8 @@ export function EventDetailSheet({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
|
onExport,
|
||||||
|
onCopyLink,
|
||||||
onRsvp,
|
onRsvp,
|
||||||
}: EventDetailSheetProps) {
|
}: EventDetailSheetProps) {
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
@@ -367,7 +374,7 @@ export function EventDetailSheet({
|
|||||||
)}
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
{(canEdit || onDuplicate) && (
|
{(canEdit || onDuplicate || onExport || (onCopyLink && videoUri)) && (
|
||||||
<View style={styles.actions}>
|
<View style={styles.actions}>
|
||||||
{onEdit && canEdit && (
|
{onEdit && canEdit && (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
@@ -383,6 +390,20 @@ export function EventDetailSheet({
|
|||||||
onPress={() => onDuplicate(event)}
|
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 && (
|
{onDelete && canEdit && (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon={<Trash2 size={18} color={c.error} />}
|
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,
|
applySharedCalendarColors,
|
||||||
buildEventDayIndex,
|
buildEventDayIndex,
|
||||||
eventsOnDayFromIndex,
|
eventsOnDayFromIndex,
|
||||||
|
getEventStartDate,
|
||||||
getPrimaryCalendarId,
|
getPrimaryCalendarId,
|
||||||
pickUnusedCalendarColor,
|
pickUnusedCalendarColor,
|
||||||
sharedCalendarColorKey,
|
sharedCalendarColorKey,
|
||||||
@@ -81,6 +82,8 @@ import { useLocaleStore } from '../stores/locale-store';
|
|||||||
import { useUserCalendarAddresses } from '../lib/calendar-user-addresses';
|
import { useUserCalendarAddresses } from '../lib/calendar-user-addresses';
|
||||||
import { useCalendarSubscriptionsStore } from '../stores/calendar-subscriptions-store';
|
import { useCalendarSubscriptionsStore } from '../stores/calendar-subscriptions-store';
|
||||||
import { startCalendarNotificationSync } from '../lib/calendar-notifications';
|
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';
|
import type { Calendar, CalendarEvent, RecurrenceRule } from '../api/types';
|
||||||
|
|
||||||
type ViewMode = 'month' | 'week' | 'agenda';
|
type ViewMode = 'month' | 'week' | 'agenda';
|
||||||
@@ -594,6 +597,54 @@ export default function CalendarScreen() {
|
|||||||
[t],
|
[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(
|
const handleCalendarEditSave = React.useCallback(
|
||||||
async (values: CalendarEditValues) => {
|
async (values: CalendarEditValues) => {
|
||||||
if (!calendarEditTarget) return;
|
if (!calendarEditTarget) return;
|
||||||
@@ -809,6 +860,9 @@ export default function CalendarScreen() {
|
|||||||
onClose={() => setDetailEvent(null)}
|
onClose={() => setDetailEvent(null)}
|
||||||
onEdit={handleEditFromDetail}
|
onEdit={handleEditFromDetail}
|
||||||
onDelete={handleDeleteFromDetail}
|
onDelete={handleDeleteFromDetail}
|
||||||
|
onDuplicate={(ev) => { if (!isReadOnlyEvent(ev)) void handleDuplicateFromDetail(ev); }}
|
||||||
|
onExport={handleExportFromDetail}
|
||||||
|
onCopyLink={handleCopyLink}
|
||||||
onRsvp={async (ev, participantId, status) => {
|
onRsvp={async (ev, participantId, status) => {
|
||||||
await rsvpEvent(ev.id, participantId, status, buildReplyTo(ev));
|
await rsvpEvent(ev.id, participantId, status, buildReplyTo(ev));
|
||||||
setDetailEvent((cur) =>
|
setDetailEvent((cur) =>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ vi.mock('../../api/calendar', () => ({
|
|||||||
createEvent: vi.fn(),
|
createEvent: vi.fn(),
|
||||||
updateEvent: vi.fn(),
|
updateEvent: vi.fn(),
|
||||||
deleteEvents: vi.fn(),
|
deleteEvents: vi.fn(),
|
||||||
|
batchCreateEvents: vi.fn(),
|
||||||
setDefaultCalendar: 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', () => {
|
describe('recurring series mutations', () => {
|
||||||
it('updateEvent on an occurrence patches the master and refetches the range', async () => {
|
it('updateEvent on an occurrence patches the master and refetches the range', async () => {
|
||||||
useCalendarStore.setState({
|
useCalendarStore.setState({
|
||||||
|
|||||||
+105
-10
@@ -1,7 +1,8 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
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 {
|
import {
|
||||||
type CalendarUpdates,
|
type CalendarUpdates,
|
||||||
updateCalendar as apiUpdateCalendar,
|
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 {
|
export interface LoadedRange {
|
||||||
after: string;
|
after: string;
|
||||||
before: string;
|
before: string;
|
||||||
@@ -499,22 +567,49 @@ export const useCalendarStore = create<CalendarState>()(
|
|||||||
const cal = get().calendars.find((c) => c.id === calendarId);
|
const cal = get().calendars.find((c) => c.id === calendarId);
|
||||||
const accountId = cal?.accountId;
|
const accountId = cal?.accountId;
|
||||||
const serverCalendarId = cal?.originalId || calendarId;
|
const serverCalendarId = cal?.originalId || calendarId;
|
||||||
// Stalwart enforces UID uniqueness across calendars. Skip events whose UID
|
// Stalwart enforces UID uniqueness across calendars (#113):
|
||||||
// already exists; create the rest. (We don't attempt cross-calendar linking
|
// - UID already in the target calendar -> skip (true duplicate)
|
||||||
// on mobile — a duplicate is simply skipped.)
|
// - UID in another calendar -> link it to the target via calendarIds
|
||||||
|
// - new UID -> create
|
||||||
let toCreate = events;
|
let toCreate = events;
|
||||||
|
let linked = 0;
|
||||||
try {
|
try {
|
||||||
const existingIds = await queryEvents([], '', '', accountId);
|
const existingIds = await queryEvents([], '', '', accountId);
|
||||||
const existing = existingIds.length > 0 ? await fetchEvents(existingIds, accountId) : [];
|
const existing = existingIds.length > 0 ? await fetchEvents(existingIds, accountId) : [];
|
||||||
const seenUids = new Set(existing.map((e) => e.uid).filter(Boolean) as string[]);
|
const byUid = new Map<string, CalendarEvent>();
|
||||||
toCreate = events.filter((e) => !e.uid || !seenUids.has(e.uid));
|
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 {
|
} catch {
|
||||||
// Couldn't dedupe — proceed and let the server reject genuine dupes.
|
// Couldn't dedupe — proceed and let the server reject genuine dupes.
|
||||||
}
|
}
|
||||||
if (toCreate.length === 0) return 0;
|
const prepared = toCreate.map(prepareImportedEvent);
|
||||||
const count = await apiBatchCreateEvents(toCreate, serverCalendarId, accountId);
|
let count = 0;
|
||||||
await get().refresh();
|
// Batch in chunks of 50 to avoid oversized requests.
|
||||||
return count;
|
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) => {
|
createCalendar: async (name, color, description) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user