feat: add right-click context menu on calendar events
This commit is contained in:
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"WebFetch(domain:github.com)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -301,6 +301,10 @@ export function EventModal({
|
||||
privacy: "public",
|
||||
};
|
||||
|
||||
if (!event) {
|
||||
data.uid = generateUUID();
|
||||
}
|
||||
|
||||
if (location.trim()) {
|
||||
data.locations = {
|
||||
loc1: {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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": "カレンダーを読み込み中...",
|
||||
|
||||
@@ -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": "캘린더를 불러오는 중...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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": "Загрузка календарей...",
|
||||
|
||||
@@ -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": "Завантаження календарів...",
|
||||
|
||||
@@ -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": "正在加载日历...",
|
||||
|
||||
Reference in New Issue
Block a user