feat: add right-click context menu on calendar events

This commit is contained in:
Linus Rath
2026-04-18 13:46:49 +02:00
parent 028e78a0c9
commit 6fa0029d0b
25 changed files with 531 additions and 36 deletions
-7
View File
@@ -1,7 +0,0 @@
{
"permissions": {
"allow": [
"WebFetch(domain:github.com)"
]
}
}
+102
View File
@@ -30,6 +30,9 @@ import { MiniCalendar } from "@/components/calendar/mini-calendar";
import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel";
import { EventModal, type PendingEventPreview } from "@/components/calendar/event-modal";
import { EventDetailPopover } from "@/components/calendar/event-detail-popover";
import { EventContextMenu } from "@/components/calendar/event-context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
import { downloadEventICS } from "@/lib/calendar-ics-export";
import { ICalImportModal } from "@/components/calendar/ical-import-modal";
import { ICalSubscriptionModal } from "@/components/calendar/ical-subscription-modal";
import { RecurrenceScopeDialog, type RecurrenceEditScope } from "@/components/calendar/recurrence-scope-dialog";
@@ -357,6 +360,18 @@ export default function CalendarPage() {
openEditModal(event);
}, [closeDetail, openEditModal]);
const {
contextMenu: eventContextMenu,
openContextMenu: openEventContextMenu,
closeContextMenu: closeEventContextMenu,
menuRef: eventContextMenuRef,
} = useContextMenu<CalendarEvent>();
const handleContextMenuEvent = useCallback((e: React.MouseEvent, event: CalendarEvent) => {
closeDetail();
openEventContextMenu(e, event);
}, [closeDetail, openEventContextMenu]);
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
if (isMobile) return;
if (calendarHoverPreview === 'off') return;
@@ -732,6 +747,73 @@ export default function CalendarPage() {
}
}, [detailEvent, client, updateEvent, t]);
const handleDuplicateContextMenu = useCallback(async (event: CalendarEvent) => {
if (!client) { toast.error(t("notifications.event_error")); return; }
const start = parseISO(event.start);
const newStart = addDays(start, 1);
const data = sanitizeOutgoingCalendarEventData<Partial<CalendarEvent>>({
title: event.title,
description: event.description,
start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"),
duration: event.duration,
timeZone: event.timeZone,
showWithoutTime: event.showWithoutTime,
calendarIds: { ...event.calendarIds },
status: "confirmed",
freeBusyStatus: event.freeBusyStatus,
privacy: event.privacy,
});
if (event.locations) data.locations = structuredClone(event.locations);
if (event.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules);
if (event.alerts) data.alerts = structuredClone(event.alerts);
if (event.participants) data.participants = structuredClone(event.participants);
try {
const created = await createEvent(client, data);
if (created) {
toast.success(t("notifications.event_duplicated"));
openEditModal(created);
}
} catch {
toast.error(t("notifications.event_error"));
}
}, [client, createEvent, openEditModal, t]);
const handleExportICS = useCallback((event: CalendarEvent) => {
try {
downloadEventICS(event);
toast.success(t("notifications.event_exported"));
} catch {
toast.error(t("notifications.event_error"));
}
}, [t]);
const handleCopyTitle = useCallback(async (event: CalendarEvent) => {
try {
await navigator.clipboard.writeText(event.title || "");
toast.success(t("notifications.title_copied"));
} catch {
toast.error(t("notifications.event_error"));
}
}, [t]);
const handleCopyMeetingLink = useCallback(async (event: CalendarEvent) => {
const uri = event.virtualLocations
? Object.values(event.virtualLocations).find((v) => v.uri)?.uri
: undefined;
if (!uri) return;
try {
await navigator.clipboard.writeText(uri);
toast.success(t("notifications.link_copied"));
} catch {
toast.error(t("notifications.event_error"));
}
}, [t]);
const handleDeleteContextMenu = useCallback((event: CalendarEvent) => {
const hasParticipants = event.participants && Object.keys(event.participants).length > 0;
handleDeleteEvent(event.id, hasParticipants || undefined);
}, [handleDeleteEvent]);
const handleRsvpFromDetail = useCallback(async (status: CalendarParticipant['participationStatus']) => {
if (!detailEvent || !client) return;
const participantId = getUserParticipantId(detailEvent, currentUserEmails);
@@ -841,6 +923,7 @@ export default function CalendarPage() {
onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave}
onContextMenuEvent={handleContextMenuEvent}
onCreateAtTime={openCreateModal}
firstDayOfWeek={firstDayOfWeek}
isMobile={isMobile}
@@ -857,6 +940,7 @@ export default function CalendarPage() {
onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave}
onContextMenuEvent={handleContextMenuEvent}
onCreateAtTime={openCreateModal}
firstDayOfWeek={firstDayOfWeek}
timeFormat={timeFormat}
@@ -875,6 +959,7 @@ export default function CalendarPage() {
onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave}
onContextMenuEvent={handleContextMenuEvent}
onCreateAtTime={openCreateModal}
timeFormat={timeFormat}
isMobile={isMobile}
@@ -892,6 +977,7 @@ export default function CalendarPage() {
onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave}
onContextMenuEvent={handleContextMenuEvent}
timeFormat={timeFormat}
/>
);
@@ -1106,6 +1192,22 @@ export default function CalendarPage() {
</div>
)}
{eventContextMenu.data && (
<EventContextMenu
event={eventContextMenu.data}
position={eventContextMenu.position}
isOpen={eventContextMenu.isOpen}
onClose={closeEventContextMenu}
menuRef={eventContextMenuRef}
onEdit={() => openEditModal(eventContextMenu.data!)}
onDuplicate={() => handleDuplicateContextMenu(eventContextMenu.data!)}
onExportICS={() => handleExportICS(eventContextMenu.data!)}
onCopyTitle={() => handleCopyTitle(eventContextMenu.data!)}
onCopyMeetingLink={() => handleCopyMeetingLink(eventContextMenu.data!)}
onDelete={() => handleDeleteContextMenu(eventContextMenu.data!)}
/>
)}
{detailEvent && detailAnchorRect && (
<EventDetailPopover
event={detailEvent}
@@ -17,6 +17,7 @@ interface CalendarAgendaViewProps {
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverLeave?: () => void;
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
timeFormat?: "12h" | "24h";
}
@@ -33,6 +34,7 @@ export function CalendarAgendaView({
onSelectEvent,
onHoverEvent,
onHoverLeave,
onContextMenuEvent,
timeFormat = "24h",
}: CalendarAgendaViewProps) {
const t = useTranslations("calendar");
@@ -157,6 +159,7 @@ export function CalendarAgendaView({
onClick={(e) => onSelectEvent(ev, e.currentTarget.getBoundingClientRect())}
onMouseEnter={(e) => onHoverEvent?.(ev, e.currentTarget.getBoundingClientRect())}
onMouseLeave={() => onHoverLeave?.()}
onContextMenu={onContextMenuEvent ? (e) => onContextMenuEvent(e, ev) : undefined}
className="w-full flex items-start px-4 hover:bg-muted/50 transition-colors text-left"
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
>
@@ -19,6 +19,7 @@ interface CalendarDayViewProps {
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverLeave?: () => void;
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
onCreateAtTime: (date: Date, endDate?: Date) => void;
timeFormat?: "12h" | "24h";
isMobile?: boolean;
@@ -37,6 +38,7 @@ export function CalendarDayView({
onSelectEvent,
onHoverEvent,
onHoverLeave,
onContextMenuEvent,
onCreateAtTime,
timeFormat = "24h",
isMobile,
@@ -157,6 +159,7 @@ export function CalendarDayView({
onClick={(rect) => onSelectEvent(ev, rect)}
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
onMouseLeave={onHoverLeave}
onContextMenu={onContextMenuEvent}
/>
);
})}
@@ -264,6 +267,7 @@ export function CalendarDayView({
onClick={(rect) => onSelectEvent(ev, rect)}
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
onMouseLeave={onHoverLeave}
onContextMenu={onContextMenuEvent}
draggable
/>
<div
@@ -23,6 +23,7 @@ interface CalendarMonthViewProps {
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverLeave?: () => void;
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
onCreateAtTime?: (date: Date) => void;
firstDayOfWeek?: number;
isMobile?: boolean;
@@ -37,6 +38,7 @@ export function CalendarMonthView({
onSelectEvent,
onHoverEvent,
onHoverLeave,
onContextMenuEvent,
onCreateAtTime,
firstDayOfWeek = 1,
isMobile,
@@ -279,6 +281,7 @@ export function CalendarMonthView({
onClick={(rect) => onSelectEvent(segment.event, rect)}
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
onMouseLeave={onHoverLeave}
onContextMenu={onContextMenuEvent}
draggable
/>
</div>
@@ -22,6 +22,7 @@ interface CalendarWeekViewProps {
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverLeave?: () => void;
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
onCreateAtTime: (date: Date, endDate?: Date) => void;
firstDayOfWeek?: number;
timeFormat?: "12h" | "24h";
@@ -42,6 +43,7 @@ export function CalendarWeekView({
onSelectEvent,
onHoverEvent,
onHoverLeave,
onContextMenuEvent,
onCreateAtTime,
firstDayOfWeek = 1,
timeFormat = "24h",
@@ -242,6 +244,7 @@ export function CalendarWeekView({
onClick={(rect) => onSelectEvent(segment.event, rect)}
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
onMouseLeave={onHoverLeave}
onContextMenu={onContextMenuEvent}
/>
</div>
);
@@ -402,6 +405,7 @@ export function CalendarWeekView({
onClick={(rect) => onSelectEvent(ev, rect)}
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
onMouseLeave={onHoverLeave}
onContextMenu={onContextMenuEvent}
draggable
/>
<div
+7 -1
View File
@@ -17,6 +17,7 @@ interface EventCardProps {
onClick?: (anchorRect: DOMRect) => void;
onMouseEnter?: (anchorRect: DOMRect) => void;
onMouseLeave?: () => void;
onContextMenu?: (e: React.MouseEvent, event: CalendarEvent) => void;
isSelected?: boolean;
draggable?: boolean;
continuesBefore?: boolean;
@@ -65,7 +66,7 @@ function createEventDragPreview(title: string, timeRange: string, color: string)
return el;
}
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, isSelected, draggable: isDraggable, continuesBefore = false, continuesAfter = false, className, style }: EventCardProps) {
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, onContextMenu, isSelected, draggable: isDraggable, continuesBefore = false, continuesAfter = false, className, style }: EventCardProps) {
const t = useTranslations("calendar");
const [isBeingDragged, setIsBeingDragged] = useState(false);
const color = getEventColor(event, calendar);
@@ -109,12 +110,15 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
"aria-roledescription": "draggable event",
} : {};
const handleContextMenu = onContextMenu ? (e: React.MouseEvent) => onContextMenu(e, event) : undefined;
if (variant === "chip") {
return (
<button
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
onMouseLeave={() => onMouseLeave?.()}
onContextMenu={handleContextMenu}
aria-label={ariaLabel}
{...dragProps}
className={cn(
@@ -142,6 +146,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
onMouseLeave={() => onMouseLeave?.()}
onContextMenu={handleContextMenu}
aria-label={ariaLabel}
{...dragProps}
className={cn(
@@ -172,6 +177,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
onMouseLeave={() => onMouseLeave?.()}
onContextMenu={handleContextMenu}
aria-label={ariaLabel}
{...dragProps}
data-calendar-event
@@ -0,0 +1,93 @@
"use client";
import { useTranslations } from "next-intl";
import {
ContextMenu,
ContextMenuItem,
ContextMenuSeparator,
} from "@/components/ui/context-menu";
import {
Pencil,
Copy,
Download,
ClipboardCopy,
Link as LinkIcon,
Trash2,
} from "lucide-react";
import type { CalendarEvent } from "@/lib/jmap/types";
interface Position {
x: number;
y: number;
}
interface EventContextMenuProps {
event: CalendarEvent;
position: Position;
isOpen: boolean;
onClose: () => void;
menuRef: React.RefObject<HTMLDivElement | null>;
onEdit: () => void;
onDuplicate: () => void;
onExportICS: () => void;
onCopyTitle: () => void;
onCopyMeetingLink?: () => void;
onDelete: () => void;
}
export function EventContextMenu({
event,
position,
isOpen,
onClose,
menuRef,
onEdit,
onDuplicate,
onExportICS,
onCopyTitle,
onCopyMeetingLink,
onDelete,
}: EventContextMenuProps) {
const t = useTranslations("calendar");
const handle = (fn: () => void) => () => {
fn();
onClose();
};
const hasMeetingLink = !!(
event.virtualLocations && Object.values(event.virtualLocations).some((v) => v.uri)
);
return (
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
<ContextMenuItem icon={Pencil} label={t("events.edit")} onClick={handle(onEdit)} />
<ContextMenuItem icon={Copy} label={t("events.duplicate")} onClick={handle(onDuplicate)} />
<ContextMenuSeparator />
<ContextMenuItem
icon={Download}
label={t("events.export_ics")}
onClick={handle(onExportICS)}
/>
<ContextMenuItem
icon={ClipboardCopy}
label={t("events.copy_title")}
onClick={handle(onCopyTitle)}
/>
{hasMeetingLink && onCopyMeetingLink && (
<ContextMenuItem
icon={LinkIcon}
label={t("events.copy_link")}
onClick={handle(onCopyMeetingLink)}
/>
)}
<ContextMenuSeparator />
<ContextMenuItem
icon={Trash2}
label={t("events.delete")}
onClick={handle(onDelete)}
destructive
/>
</ContextMenu>
);
}
+4
View File
@@ -301,6 +301,10 @@ export function EventModal({
privacy: "public",
};
if (!event) {
data.uid = generateUUID();
}
if (location.trim()) {
data.locations = {
loc1: {
+187
View File
@@ -0,0 +1,187 @@
import type { CalendarEvent } from "@/lib/jmap/types";
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.
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") {
const related = trigger.relativeTo === "end" ? ";RELATED=END" : "";
lines.push(`TRIGGER${related}:${trigger.offset}`);
} else if (trigger["@type"] === "AbsoluteTrigger") {
lines.push(`TRIGGER;VALUE=DATE-TIME:${stripDateSeparators(trigger.when)}`);
}
lines.push(`DESCRIPTION:${escapeText(event.title || "Reminder")}`);
lines.push("END:VALARM");
}
}
export function eventToICS(event: CalendarEvent): string {
const now = formatNow();
const lines: string[] = [
"BEGIN:VCALENDAR",
"PRODID:-//JMAP-Webmail//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}`);
if (event.start) {
pushDateProp(lines, "DTSTART", event.start, event.showWithoutTime, event.timeZone);
}
if (event.utcEnd) {
pushDateProp(lines, "DTEND", event.utcEnd, event.showWithoutTime, 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.privacy) lines.push(`CLASS:${event.privacy.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 organizer = Object.values(event.participants).find((p) => p.roles?.owner);
if (organizer) {
const email = organizer.email || organizer.sendTo?.imip?.replace("mailto:", "");
if (email) {
const cn = organizer.name ? `;CN=${escapeText(organizer.name)}` : "";
lines.push(`ORGANIZER${cn}:mailto:${email}`);
}
}
for (const p of Object.values(event.participants)) {
if (p.roles?.owner) continue;
const email = p.email || p.sendTo?.imip?.replace("mailto:", "");
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";
}
function sanitizeFilename(name: string): string {
const cleaned = name.replace(/[\\/:*?"<>|]/g, "_").trim();
return cleaned || "event";
}
export function downloadEventICS(event: CalendarEvent): void {
const ics = eventToICS(event);
const blob = new Blob([ics], { type: "text/calendar;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${sanitizeFilename(event.title || "event")}.ics`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 0);
}
+12
View File
@@ -1949,6 +1949,10 @@ export class JMAPClient implements IJMAPClient {
status: 'ACCEPTED' | 'TENTATIVE' | 'DECLINED';
identityId?: string;
}): Promise<void> {
if (!opts.uid) {
debug.warn('calendar', '[iMIP] sendImipReply aborted: missing UID');
return;
}
const mailboxes = await this.getMailboxes();
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
if (!sentMailbox) {
@@ -2126,6 +2130,10 @@ export class JMAPClient implements IJMAPClient {
*/
async sendImipInvitation(event: CalendarEvent): Promise<void> {
if (!event.participants) return;
if (!event.uid) {
debug.warn('calendar', '[iMIP] sendImipInvitation aborted: event has no UID', { eventId: event.id });
return;
}
const mailboxes = await this.getMailboxes();
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
@@ -2292,6 +2300,10 @@ export class JMAPClient implements IJMAPClient {
*/
async sendImipCancellation(event: CalendarEvent): Promise<void> {
if (!event.participants) return;
if (!event.uid) {
debug.warn('calendar', '[iMIP] sendImipCancellation aborted: event has no UID', { eventId: event.id });
return;
}
if (event.status && event.status !== 'cancelled') {
debug.warn('calendar', 'sendImipCancellation called on non-cancelled event, status:', event.status);
}
+8 -2
View File
@@ -1972,7 +1972,10 @@
"resize": "Termingröße ändern",
"duplicate": "Duplizieren",
"today_header": "Heute",
"tomorrow_header": "Morgen"
"tomorrow_header": "Morgen",
"export_ics": "Als .ics exportieren",
"copy_title": "Titel kopieren",
"copy_link": "Meeting-Link kopieren"
},
"detail": {
"add_note": "Notiz hinzufügen...",
@@ -2119,7 +2122,10 @@
"rsvp_error": "Antwort konnte nicht aktualisiert werden",
"event_duplicated": "Termin dupliziert",
"event_error": "Termin konnte nicht gespeichert werden",
"task_due": "Aufgabe fällig"
"task_due": "Aufgabe fällig",
"event_exported": "Termin exportiert",
"title_copied": "Titel kopiert",
"link_copied": "Link kopiert"
},
"status": {
"loading_calendars": "Kalender werden geladen...",
+8 -2
View File
@@ -1979,7 +1979,10 @@
"resize": "Resize event",
"duplicate": "Duplicate",
"today_header": "Today",
"tomorrow_header": "Tomorrow"
"tomorrow_header": "Tomorrow",
"export_ics": "Export as .ics",
"copy_title": "Copy title",
"copy_link": "Copy meeting link"
},
"detail": {
"add_note": "Add a note...",
@@ -2126,7 +2129,10 @@
"rsvp_error": "Failed to update response",
"event_duplicated": "Event duplicated",
"event_error": "Failed to save event",
"task_due": "Task due"
"task_due": "Task due",
"event_exported": "Event exported",
"title_copied": "Title copied",
"link_copied": "Link copied"
},
"status": {
"loading_calendars": "Loading calendars...",
+8 -2
View File
@@ -1972,7 +1972,10 @@
"resize": "Redimensionar evento",
"duplicate": "Duplicar",
"today_header": "Hoy",
"tomorrow_header": "Mañana"
"tomorrow_header": "Mañana",
"export_ics": "Exportar como .ics",
"copy_title": "Copiar título",
"copy_link": "Copiar enlace de reunión"
},
"detail": {
"add_note": "Añadir una nota...",
@@ -2119,7 +2122,10 @@
"rsvp_error": "Error al actualizar la respuesta",
"event_duplicated": "Evento duplicado",
"event_error": "Error al guardar el evento",
"task_due": "Tarea vencida"
"task_due": "Tarea vencida",
"event_exported": "Evento exportado",
"title_copied": "Título copiado",
"link_copied": "Enlace copiado"
},
"status": {
"loading_calendars": "Cargando calendarios...",
+8 -2
View File
@@ -1972,7 +1972,10 @@
"resize": "Redimensionner l'événement",
"duplicate": "Dupliquer",
"today_header": "Aujourd'hui",
"tomorrow_header": "Demain"
"tomorrow_header": "Demain",
"export_ics": "Exporter en .ics",
"copy_title": "Copier le titre",
"copy_link": "Copier le lien de réunion"
},
"detail": {
"add_note": "Ajouter une note...",
@@ -2119,7 +2122,10 @@
"rsvp_error": "Échec de la mise à jour de la réponse",
"event_duplicated": "Événement dupliqué",
"event_error": "Échec de l'enregistrement de l'événement",
"task_due": "Tâche à échéance"
"task_due": "Tâche à échéance",
"event_exported": "Événement exporté",
"title_copied": "Titre copié",
"link_copied": "Lien copié"
},
"status": {
"loading_calendars": "Chargement des calendriers...",
+8 -2
View File
@@ -1972,7 +1972,10 @@
"resize": "Ridimensiona evento",
"duplicate": "Duplica",
"today_header": "Oggi",
"tomorrow_header": "Domani"
"tomorrow_header": "Domani",
"export_ics": "Esporta come .ics",
"copy_title": "Copia titolo",
"copy_link": "Copia link riunione"
},
"detail": {
"add_note": "Aggiungi una nota...",
@@ -2119,7 +2122,10 @@
"rsvp_error": "Impossibile aggiornare la risposta",
"event_duplicated": "Evento duplicato",
"event_error": "Salvataggio dell'evento non riuscito",
"task_due": "Attività in scadenza"
"task_due": "Attività in scadenza",
"event_exported": "Evento esportato",
"title_copied": "Titolo copiato",
"link_copied": "Link copiato"
},
"status": {
"loading_calendars": "Caricamento calendari...",
+8 -2
View File
@@ -1972,7 +1972,10 @@
"resize": "イベントのサイズ変更",
"duplicate": "複製",
"today_header": "今日",
"tomorrow_header": "明日"
"tomorrow_header": "明日",
"export_ics": ".icsとしてエクスポート",
"copy_title": "タイトルをコピー",
"copy_link": "会議リンクをコピー"
},
"detail": {
"add_note": "メモを追加...",
@@ -2119,7 +2122,10 @@
"rsvp_error": "回答の更新に失敗しました",
"event_duplicated": "予定を複製しました",
"event_error": "予定の保存に失敗しました",
"task_due": "タスクの期限です"
"task_due": "タスクの期限です",
"event_exported": "イベントをエクスポートしました",
"title_copied": "タイトルをコピーしました",
"link_copied": "リンクをコピーしました"
},
"status": {
"loading_calendars": "カレンダーを読み込み中...",
+8 -2
View File
@@ -1972,7 +1972,10 @@
"resize": "일정 크기 조절",
"duplicate": "복제",
"today_header": "오늘",
"tomorrow_header": "내일"
"tomorrow_header": "내일",
"export_ics": ".ics로 내보내기",
"copy_title": "제목 복사",
"copy_link": "회의 링크 복사"
},
"detail": {
"add_note": "메모 추가...",
@@ -2119,7 +2122,10 @@
"rsvp_error": "응답을 업데이트하지 못했어요",
"event_duplicated": "일정이 복제되었어요",
"event_error": "일정을 저장하지 못했어요",
"task_due": "할 일 기한이 다가와요"
"task_due": "할 일 기한이 다가와요",
"event_exported": "이벤트 내보내기 완료",
"title_copied": "제목이 복사되었습니다",
"link_copied": "링크가 복사되었습니다"
},
"status": {
"loading_calendars": "캘린더를 불러오는 중...",
+8 -2
View File
@@ -1971,7 +1971,10 @@
"resize": "Mainīt pasākuma laiku",
"duplicate": "Dublēt",
"today_header": "Šodien",
"tomorrow_header": "Rīt"
"tomorrow_header": "Rīt",
"export_ics": "Eksportēt kā .ics",
"copy_title": "Kopēt nosaukumu",
"copy_link": "Kopēt sapulces saiti"
},
"detail": {
"add_note": "Pievienot piezīmi...",
@@ -2118,7 +2121,10 @@
"rsvp_error": "Neizdevās atjaunināt atbildi",
"event_duplicated": "Pasākums dublēts",
"event_error": "Neizdevās saglabāt pasākumu",
"task_due": "Uzdevuma termiņš"
"task_due": "Uzdevuma termiņš",
"event_exported": "Notikums eksportēts",
"title_copied": "Nosaukums nokopēts",
"link_copied": "Saite nokopēta"
},
"status": {
"loading_calendars": "Ielādē kalendārus...",
+8 -2
View File
@@ -1972,7 +1972,10 @@
"resize": "Evenement formaat wijzigen",
"duplicate": "Dupliceren",
"today_header": "Vandaag",
"tomorrow_header": "Morgen"
"tomorrow_header": "Morgen",
"export_ics": "Exporteren als .ics",
"copy_title": "Titel kopiëren",
"copy_link": "Vergaderlink kopiëren"
},
"detail": {
"add_note": "Notitie toevoegen...",
@@ -2119,7 +2122,10 @@
"rsvp_error": "Reactie kon niet worden bijgewerkt",
"event_duplicated": "Evenement gedupliceerd",
"event_error": "Evenement opslaan mislukt",
"task_due": "Taak vervalt"
"task_due": "Taak vervalt",
"event_exported": "Afspraak geëxporteerd",
"title_copied": "Titel gekopieerd",
"link_copied": "Link gekopieerd"
},
"status": {
"loading_calendars": "Agenda's laden...",
+8 -2
View File
@@ -1972,7 +1972,10 @@
"resize": "Zmień rozmiar wydarzenia",
"duplicate": "Duplikuj",
"today_header": "Dzisiaj",
"tomorrow_header": "Jutro"
"tomorrow_header": "Jutro",
"export_ics": "Eksportuj jako .ics",
"copy_title": "Kopiuj tytuł",
"copy_link": "Kopiuj link do spotkania"
},
"detail": {
"add_note": "Dodaj notatkę...",
@@ -2119,7 +2122,10 @@
"rsvp_error": "Nie udało się zaktualizować odpowiedzi",
"event_duplicated": "Wydarzenie zduplikowano",
"event_error": "Nie udało się zapisać wydarzenia",
"task_due": "Termin zadania"
"task_due": "Termin zadania",
"event_exported": "Wydarzenie wyeksportowane",
"title_copied": "Tytuł skopiowany",
"link_copied": "Link skopiowany"
},
"status": {
"loading_calendars": "Ładowanie kalendarzy...",
+8 -2
View File
@@ -1972,7 +1972,10 @@
"resize": "Redimensionar evento",
"duplicate": "Duplicar",
"today_header": "Hoje",
"tomorrow_header": "Amanhã"
"tomorrow_header": "Amanhã",
"export_ics": "Exportar como .ics",
"copy_title": "Copiar título",
"copy_link": "Copiar link da reunião"
},
"detail": {
"add_note": "Adicionar uma nota...",
@@ -2119,7 +2122,10 @@
"rsvp_error": "Falha ao atualizar resposta",
"event_duplicated": "Evento duplicado",
"event_error": "Falha ao salvar o evento",
"task_due": "Tarefa vencendo"
"task_due": "Tarefa vencendo",
"event_exported": "Evento exportado",
"title_copied": "Título copiado",
"link_copied": "Link copiado"
},
"status": {
"loading_calendars": "Carregando calendários...",
+8 -2
View File
@@ -1972,7 +1972,10 @@
"resize": "Изменить размер события",
"duplicate": "Дублировать",
"today_header": "Сегодня",
"tomorrow_header": "Завтра"
"tomorrow_header": "Завтра",
"export_ics": "Экспорт в .ics",
"copy_title": "Скопировать название",
"copy_link": "Скопировать ссылку встречи"
},
"detail": {
"add_note": "Добавить заметку...",
@@ -2119,7 +2122,10 @@
"rsvp_error": "Не удалось обновить ответ",
"event_duplicated": "Событие дублировано",
"event_error": "Не удалось сохранить событие",
"task_due": "Срок задачи"
"task_due": "Срок задачи",
"event_exported": "Событие экспортировано",
"title_copied": "Название скопировано",
"link_copied": "Ссылка скопирована"
},
"status": {
"loading_calendars": "Загрузка календарей...",
+8 -2
View File
@@ -1972,7 +1972,10 @@
"resize": "Змінити розмір події",
"duplicate": "дублікат",
"today_header": "Сьогодні",
"tomorrow_header": "завтра"
"tomorrow_header": "завтра",
"export_ics": "Експортувати як .ics",
"copy_title": "Скопіювати назву",
"copy_link": "Скопіювати посилання зустрічі"
},
"detail": {
"add_note": "Додати примітку...",
@@ -2119,7 +2122,10 @@
"rsvp_error": "Не вдалося оновити відповідь",
"event_duplicated": "Подія дублюється",
"event_error": "Не вдалося зберегти подію",
"task_due": "Термін виконання завдання"
"task_due": "Термін виконання завдання",
"event_exported": "Подію експортовано",
"title_copied": "Назву скопійовано",
"link_copied": "Посилання скопійовано"
},
"status": {
"loading_calendars": "Завантаження календарів...",
+8 -2
View File
@@ -1972,7 +1972,10 @@
"resize": "调整事件大小",
"duplicate": "复制",
"today_header": "今天",
"tomorrow_header": "明天"
"tomorrow_header": "明天",
"export_ics": "导出为 .ics",
"copy_title": "复制标题",
"copy_link": "复制会议链接"
},
"detail": {
"add_note": "添加注释...",
@@ -2119,7 +2122,10 @@
"rsvp_error": "无法更新回复",
"event_duplicated": "活动已复制",
"event_error": "无法保存活动",
"task_due": "任务到期"
"task_due": "任务到期",
"event_exported": "日程已导出",
"title_copied": "标题已复制",
"link_copied": "链接已复制"
},
"status": {
"loading_calendars": "正在加载日历...",