563 lines
24 KiB
TypeScript
563 lines
24 KiB
TypeScript
import {
|
|
useMemo,
|
|
useState,
|
|
type FormEvent,
|
|
} from "react";
|
|
import { Trash2 } from "lucide-react";
|
|
import {
|
|
Button,
|
|
DateField,
|
|
Dialog,
|
|
TimeField,
|
|
ToggleSwitch,
|
|
useUnsavedDraftGuard,
|
|
} from "@govoplan/core-webui";
|
|
import type {
|
|
CalendarCollection,
|
|
CalendarEvent,
|
|
CalendarEventCreatePayload,
|
|
} from "../../api/calendar";
|
|
import {
|
|
addDays,
|
|
addHours,
|
|
addMinutesToTime,
|
|
calendarDraftKey,
|
|
errorText,
|
|
localDateTime,
|
|
startOfDay,
|
|
toInputDate,
|
|
toInputTime,
|
|
} from "./calendarViewModel";
|
|
|
|
type CalendarEventEndMode = "end" | "duration";
|
|
type CalendarEventAdvancedPayload = Pick<
|
|
CalendarEventCreatePayload,
|
|
| "organizer"
|
|
| "attendees"
|
|
| "categories"
|
|
| "rrule"
|
|
| "rdate"
|
|
| "exdate"
|
|
| "reminders"
|
|
| "attachments"
|
|
| "related_to"
|
|
| "icalendar"
|
|
| "metadata"
|
|
>;
|
|
|
|
export function CalendarEventDialog({
|
|
calendars,
|
|
defaultCalendarId,
|
|
event,
|
|
focusDate,
|
|
saving,
|
|
canWrite,
|
|
canDelete,
|
|
onCancel,
|
|
onSave,
|
|
onDelete
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
}: {calendars: CalendarCollection[];defaultCalendarId: string;event: CalendarEvent | null;focusDate: Date;saving: boolean;canWrite: boolean;canDelete: boolean;onCancel: () => void;onSave: (payload: CalendarEventCreatePayload, event: CalendarEvent | null) => Promise<boolean>;onDelete: (event: CalendarEvent) => Promise<void>;}) {
|
|
const initialStart = event ? new Date(event.start_at) : focusDate;
|
|
const initialEnd = event?.end_at ? new Date(event.end_at) : addHours(initialStart, 1);
|
|
const initialAllDayEnd = event?.all_day && event.end_at ? addDays(startOfDay(initialEnd), -1) : initialEnd;
|
|
const initialDurationSeconds = event?.duration_seconds ?? Math.max(3600, Math.round((initialEnd.getTime() - initialStart.getTime()) / 1000));
|
|
const [summary, setSummary] = useState(event?.summary ?? "");
|
|
const [calendarId, setCalendarId] = useState(event?.calendar_id ?? defaultCalendarId);
|
|
const [description, setDescription] = useState(event?.description ?? "");
|
|
const [location, setLocation] = useState(event?.location ?? "");
|
|
const [startDate, setStartDate] = useState(toInputDate(initialStart));
|
|
const [endDate, setEndDate] = useState(toInputDate(initialAllDayEnd < initialStart ? initialStart : initialAllDayEnd));
|
|
const [startTime, setStartTime] = useState(toInputTime(initialStart));
|
|
const [endTime, setEndTime] = useState(toInputTime(initialEnd));
|
|
const [allDay, setAllDay] = useState(event?.all_day ?? false);
|
|
const [endMode, setEndMode] = useState<CalendarEventEndMode>(event && eventUsesDuration(event) ? "duration" : "end");
|
|
const [durationSeconds, setDurationSeconds] = useState(String(initialDurationSeconds));
|
|
const [uid, setUid] = useState(event?.uid ?? "");
|
|
const [recurrenceId, setRecurrenceId] = useState(event?.recurrence_id ?? "");
|
|
const [sequence, setSequence] = useState(String(event?.sequence ?? 0));
|
|
const [status, setStatus] = useState(event?.status ?? "CONFIRMED");
|
|
const [transparency, setTransparency] = useState(event?.transparency ?? "OPAQUE");
|
|
const [classification, setClassification] = useState(event?.classification ?? "PUBLIC");
|
|
const [timezone, setTimezone] = useState(event?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC");
|
|
const [categoriesText, setCategoriesText] = useState((event?.categories ?? []).join(", "));
|
|
const [organizerJson, setOrganizerJson] = useState(jsonTextareaValue(event?.organizer ?? null));
|
|
const [attendeesJson, setAttendeesJson] = useState(jsonTextareaValue(event?.attendees ?? []));
|
|
const [rruleText, setRruleText] = useState(event?.rrule ? serializeRRuleText(event.rrule) : "");
|
|
const [rdateJson, setRdateJson] = useState(jsonTextareaValue(event?.rdate ?? []));
|
|
const [exdateJson, setExdateJson] = useState(jsonTextareaValue(event?.exdate ?? []));
|
|
const [remindersJson, setRemindersJson] = useState(jsonTextareaValue(event?.reminders ?? []));
|
|
const [attachmentsJson, setAttachmentsJson] = useState(jsonTextareaValue(event?.attachments ?? []));
|
|
const [relatedToJson, setRelatedToJson] = useState(jsonTextareaValue(event?.related_to ?? []));
|
|
const [sourceKind, setSourceKind] = useState(event?.source_kind ?? "local");
|
|
const [sourceHref, setSourceHref] = useState(event?.source_href ?? "");
|
|
const [etag, setEtag] = useState(event?.etag ?? "");
|
|
const [icalendarJson, setICalendarJson] = useState(jsonTextareaValue(event?.icalendar ?? {}));
|
|
const [metadataJson, setMetadataJson] = useState(jsonTextareaValue(event?.metadata ?? {}));
|
|
const [formError, setFormError] = useState("");
|
|
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
|
const formId = "calendar-event-form";
|
|
const eventDraft = {
|
|
summary,
|
|
calendarId,
|
|
description,
|
|
location,
|
|
startDate,
|
|
endDate,
|
|
startTime,
|
|
endTime,
|
|
allDay,
|
|
endMode,
|
|
durationSeconds,
|
|
uid,
|
|
recurrenceId,
|
|
sequence,
|
|
status,
|
|
transparency,
|
|
classification,
|
|
timezone,
|
|
categoriesText,
|
|
organizerJson,
|
|
attendeesJson,
|
|
rruleText,
|
|
rdateJson,
|
|
exdateJson,
|
|
remindersJson,
|
|
attachmentsJson,
|
|
relatedToJson,
|
|
sourceKind,
|
|
sourceHref,
|
|
etag,
|
|
icalendarJson,
|
|
metadataJson
|
|
};
|
|
const initialEventDraftKey = useMemo(() => calendarDraftKey(eventDraft), []);
|
|
const eventDirty = calendarDraftKey(eventDraft) !== initialEventDraftKey;
|
|
|
|
useUnsavedDraftGuard({
|
|
dirty: eventDirty,
|
|
onSave: saveCurrent,
|
|
onDiscard: onCancel
|
|
});
|
|
|
|
function handleAllDayChange(next: boolean) {
|
|
setAllDay(next);
|
|
if (next) {
|
|
setStartTime("00:00");
|
|
setEndTime("00:00");
|
|
} else if (startTime === "00:00" && endTime === "00:00") {
|
|
setStartTime("09:00");
|
|
setEndTime("10:00");
|
|
}
|
|
}
|
|
|
|
function handleStartDateChange(next: string) {
|
|
setStartDate(next);
|
|
if (endDate < next) setEndDate(next);
|
|
}
|
|
|
|
function handleStartTimeChange(next: string) {
|
|
setStartTime(next);
|
|
if (startDate === endDate && endTime <= next) setEndTime(addMinutesToTime(next, 60));
|
|
}
|
|
|
|
async function saveCurrent(): Promise<boolean> {
|
|
setFormError("");
|
|
const startAt = allDay ? localDateTime(startDate, "00:00") : localDateTime(startDate, startTime);
|
|
const parsedDurationSeconds = Math.max(0, Number(durationSeconds) || 0);
|
|
const usesDuration = !allDay && endMode === "duration";
|
|
const endAt = allDay ?
|
|
addDays(localDateTime(endDate, "00:00"), 1) :
|
|
usesDuration ?
|
|
new Date(startAt.getTime() + parsedDurationSeconds * 1000) :
|
|
localDateTime(endDate, endTime);
|
|
if (usesDuration && parsedDurationSeconds <= 0) {
|
|
setFormError("i18n:govoplan-calendar.duration_must_be_greater_than_zero.519292f7");
|
|
return false;
|
|
}
|
|
if (endAt <= startAt) {
|
|
setFormError(allDay ? "i18n:govoplan-calendar.end_date_must_be_on_or_after_start_date.a2518865" : "i18n:govoplan-calendar.end_date_and_time_must_be_after_start_date_and_t.daf31831");
|
|
return false;
|
|
}
|
|
let advanced: CalendarEventAdvancedPayload;
|
|
try {
|
|
advanced = {
|
|
organizer: parseJsonObjectOrNull(organizerJson, "i18n:govoplan-calendar.organizer.debd1720"),
|
|
attendees: parseJsonArray(attendeesJson, "i18n:govoplan-calendar.attendees.a45a0962"),
|
|
categories: commaSeparatedValues(categoriesText),
|
|
rrule: parseRRuleInput(rruleText),
|
|
rdate: parseJsonArray(rdateJson, "RDATE"),
|
|
exdate: parseJsonArray(exdateJson, "EXDATE"),
|
|
reminders: parseJsonArray(remindersJson, "i18n:govoplan-calendar.reminders.ae8c3939"),
|
|
attachments: parseJsonArray(attachmentsJson, "i18n:govoplan-calendar.attachments.6771ade6"),
|
|
related_to: parseJsonArray(relatedToJson, "i18n:govoplan-calendar.related_to.0e7989ff"),
|
|
icalendar: withDurationMode(parseJsonObject(icalendarJson, "i18n:govoplan-calendar.icalendar.f388476d"), usesDuration),
|
|
metadata: parseJsonObject(metadataJson, "i18n:govoplan-calendar.metadata.251edc0e")
|
|
};
|
|
} catch (err) {
|
|
setFormError(errorText(err));
|
|
return false;
|
|
}
|
|
const payload: CalendarEventCreatePayload = {
|
|
calendar_id: calendarId,
|
|
summary,
|
|
description: description || null,
|
|
location: location || null,
|
|
sequence: Math.max(0, Number(sequence) || 0),
|
|
status,
|
|
transparency,
|
|
classification,
|
|
start_at: startAt.toISOString(),
|
|
end_at: endAt.toISOString(),
|
|
duration_seconds: usesDuration ? parsedDurationSeconds : null,
|
|
all_day: allDay,
|
|
timezone: timezone.trim() || null,
|
|
source_kind: sourceKind.trim() || "local",
|
|
source_href: sourceHref.trim() || null,
|
|
etag: etag.trim() || null,
|
|
...advanced
|
|
};
|
|
if (!event) {
|
|
if (uid.trim()) payload.uid = uid.trim();
|
|
if (recurrenceId.trim()) payload.recurrence_id = recurrenceId.trim();
|
|
}
|
|
return onSave(payload, event);
|
|
}
|
|
|
|
function submit(formEvent: FormEvent<HTMLFormElement>) {
|
|
formEvent.preventDefault();
|
|
void saveCurrent();
|
|
}
|
|
|
|
function requestDelete() {
|
|
if (!event) return;
|
|
if (!confirmingDelete) {
|
|
setConfirmingDelete(true);
|
|
return;
|
|
}
|
|
void onDelete(event);
|
|
}
|
|
|
|
return (
|
|
<Dialog
|
|
open
|
|
title={event ? "i18n:govoplan-calendar.edit_event.a7028454" : "i18n:govoplan-calendar.new_event.2ef3795c"}
|
|
className="calendar-event-dialog calendar-vevent-dialog"
|
|
footerClassName="calendar-event-dialog-footer"
|
|
closeDisabled={saving}
|
|
onClose={onCancel}
|
|
footer={
|
|
<>
|
|
<div>
|
|
{event && canDelete &&
|
|
<Button type="button" variant="danger" onClick={requestDelete} disabled={saving}>
|
|
<Trash2 size={16} /> {confirmingDelete ? "i18n:govoplan-calendar.confirm_delete.c9f2829e" : "i18n:govoplan-calendar.delete.f6fdbe48"}
|
|
</Button>
|
|
}
|
|
</div>
|
|
<div className="calendar-dialog-actions">
|
|
<Button type="button" onClick={onCancel} disabled={saving}>i18n:govoplan-calendar.cancel.77dfd213</Button>
|
|
<Button type="submit" form={formId} variant="primary" disabled={saving || !canWrite}>{saving ? "i18n:govoplan-calendar.saving.ae7e8875" : "i18n:govoplan-calendar.save.efc007a3"}</Button>
|
|
</div>
|
|
</>
|
|
}>
|
|
|
|
<form id={formId} className="calendar-dialog-form" onSubmit={submit}>
|
|
{formError && <p className="calendar-form-error">{formError}</p>}
|
|
<label>
|
|
<span>i18n:govoplan-calendar.title.768e0c1c</span>
|
|
<input value={summary} onChange={(item) => setSummary(item.target.value)} required maxLength={500} autoFocus disabled={saving || !canWrite} />
|
|
</label>
|
|
<label>
|
|
<span>i18n:govoplan-calendar.calendar.adab5090</span>
|
|
<select value={calendarId} onChange={(item) => setCalendarId(item.target.value)} required disabled={saving || !canWrite}>
|
|
{calendars.map((calendar) =>
|
|
<option key={calendar.id} value={calendar.id}>{calendar.name}</option>
|
|
)}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
<span>i18n:govoplan-calendar.description.55f8ebc8</span>
|
|
<textarea value={description} onChange={(item) => setDescription(item.target.value)} rows={4} disabled={saving || !canWrite} />
|
|
</label>
|
|
<label>
|
|
<span>i18n:govoplan-calendar.location.d219c681</span>
|
|
<input value={location} onChange={(item) => setLocation(item.target.value)} maxLength={500} disabled={saving || !canWrite} />
|
|
</label>
|
|
<ToggleSwitch label="i18n:govoplan-calendar.whole_day.951c82d1" checked={allDay} disabled={saving || !canWrite} onChange={handleAllDayChange} />
|
|
<div className="calendar-dialog-date-row">
|
|
<label>
|
|
<span>i18n:govoplan-calendar.start_date.ff99f5b5</span>
|
|
<DateField value={startDate} onChange={handleStartDateChange} required disabled={saving || !canWrite} />
|
|
</label>
|
|
<label>
|
|
<span>i18n:govoplan-calendar.start_time.88d8206d</span>
|
|
<TimeField value={startTime} onChange={handleStartTimeChange} disabled={saving || !canWrite || allDay} />
|
|
</label>
|
|
</div>
|
|
{!allDay &&
|
|
<div className="calendar-dialog-date-row">
|
|
<label>
|
|
<span>i18n:govoplan-calendar.end_mode.5a06de37</span>
|
|
<select value={endMode} onChange={(item) => setEndMode(item.target.value as CalendarEventEndMode)} disabled={saving || !canWrite}>
|
|
<option value="end">i18n:govoplan-calendar.end_time.cd7800da</option>
|
|
<option value="duration">i18n:govoplan-calendar.duration.1370004d</option>
|
|
</select>
|
|
</label>
|
|
{endMode === "duration" &&
|
|
<label>
|
|
<span>i18n:govoplan-calendar.duration_seconds.19d42eeb</span>
|
|
<input type="number" min={1} step={60} value={durationSeconds} onChange={(item) => setDurationSeconds(item.target.value)} required disabled={saving || !canWrite} />
|
|
</label>
|
|
}
|
|
</div>
|
|
}
|
|
{(allDay || endMode === "end") &&
|
|
<div className="calendar-dialog-date-row">
|
|
<label>
|
|
<span>i18n:govoplan-calendar.end_date.89d10cd6</span>
|
|
<DateField value={endDate} min={startDate} onChange={setEndDate} required disabled={saving || !canWrite} />
|
|
</label>
|
|
<label>
|
|
<span>i18n:govoplan-calendar.end_time.cd7800da</span>
|
|
<TimeField value={endTime} min={!allDay && startDate === endDate ? startTime : undefined} onChange={setEndTime} disabled={saving || !canWrite || allDay} />
|
|
</label>
|
|
</div>
|
|
}
|
|
<details className="calendar-advanced-settings calendar-vevent-details">
|
|
<summary>i18n:govoplan-calendar.vevent.9cf5be75</summary>
|
|
<section className="calendar-vevent-section">
|
|
<h3>i18n:govoplan-calendar.identity.7e5a975b</h3>
|
|
<div className="calendar-dialog-grid-three">
|
|
<label>
|
|
<span>i18n:govoplan-calendar.uid.d946adf5</span>
|
|
<input value={uid} onChange={(item) => setUid(item.target.value)} maxLength={255} disabled={saving || !canWrite || Boolean(event)} />
|
|
</label>
|
|
<label>
|
|
<span>i18n:govoplan-calendar.recurrence_id.e0b780ba</span>
|
|
<input value={recurrenceId} onChange={(item) => setRecurrenceId(item.target.value)} maxLength={255} disabled={saving || !canWrite || Boolean(event)} />
|
|
</label>
|
|
<label>
|
|
<span>i18n:govoplan-calendar.sequence.5c8f4e0e</span>
|
|
<input type="number" min={0} step={1} value={sequence} onChange={(item) => setSequence(item.target.value)} disabled={saving || !canWrite} />
|
|
</label>
|
|
</div>
|
|
</section>
|
|
<section className="calendar-vevent-section">
|
|
<h3>i18n:govoplan-calendar.state.a7250206</h3>
|
|
<div className="calendar-dialog-grid-three">
|
|
<label>
|
|
<span>i18n:govoplan-calendar.status.bae7d5be</span>
|
|
<select value={status} onChange={(item) => setStatus(item.target.value)} disabled={saving || !canWrite}>
|
|
<option value="CONFIRMED">i18n:govoplan-calendar.confirmed.0542404a</option>
|
|
<option value="TENTATIVE">i18n:govoplan-calendar.tentative.d19f9022</option>
|
|
<option value="CANCELLED">i18n:govoplan-calendar.cancelled.5587b0af</option>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
<span>i18n:govoplan-calendar.transparency.7cb338a9</span>
|
|
<select value={transparency} onChange={(item) => setTransparency(item.target.value)} disabled={saving || !canWrite}>
|
|
<option value="OPAQUE">i18n:govoplan-calendar.opaque.3e1d0194</option>
|
|
<option value="TRANSPARENT">i18n:govoplan-calendar.transparent.ea4efcae</option>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
<span>i18n:govoplan-calendar.class.41ff354b</span>
|
|
<select value={classification} onChange={(item) => setClassification(item.target.value)} disabled={saving || !canWrite}>
|
|
<option value="PUBLIC">i18n:govoplan-calendar.public.d1785ca2</option>
|
|
<option value="PRIVATE">i18n:govoplan-calendar.private.b0b7ba46</option>
|
|
<option value="CONFIDENTIAL">i18n:govoplan-calendar.confidential.84c9cc88</option>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
<span>i18n:govoplan-calendar.timezone.d1f7dc89</span>
|
|
<input value={timezone} onChange={(item) => setTimezone(item.target.value)} maxLength={100} disabled={saving || !canWrite} />
|
|
</label>
|
|
<label className="calendar-dialog-wide">
|
|
<span>i18n:govoplan-calendar.categories.6ccb6007</span>
|
|
<input value={categoriesText} onChange={(item) => setCategoriesText(item.target.value)} disabled={saving || !canWrite} />
|
|
</label>
|
|
</div>
|
|
</section>
|
|
<section className="calendar-vevent-section">
|
|
<h3>i18n:govoplan-calendar.participants.cd56e083</h3>
|
|
<div className="calendar-dialog-grid-two">
|
|
<label>
|
|
<span>i18n:govoplan-calendar.organizer_json.3add6f9f</span>
|
|
<textarea value={organizerJson} onChange={(item) => setOrganizerJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
|
|
</label>
|
|
<label>
|
|
<span>i18n:govoplan-calendar.attendees_json.aeb487bb</span>
|
|
<textarea value={attendeesJson} onChange={(item) => setAttendeesJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
|
|
</label>
|
|
</div>
|
|
</section>
|
|
<section className="calendar-vevent-section">
|
|
<h3>i18n:govoplan-calendar.recurrence.f7ad40f5</h3>
|
|
<label>
|
|
<span>i18n:govoplan-calendar.rrule.c7b2f8a3</span>
|
|
<input value={rruleText} onChange={(item) => setRruleText(item.target.value)} disabled={saving || !canWrite} />
|
|
</label>
|
|
<div className="calendar-dialog-grid-two">
|
|
<label>
|
|
<span>i18n:govoplan-calendar.rdate_json.5b51fca4</span>
|
|
<textarea value={rdateJson} onChange={(item) => setRdateJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
|
|
</label>
|
|
<label>
|
|
<span>i18n:govoplan-calendar.exdate_json.7d0c538d</span>
|
|
<textarea value={exdateJson} onChange={(item) => setExdateJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
|
|
</label>
|
|
</div>
|
|
</section>
|
|
<section className="calendar-vevent-section">
|
|
<h3>i18n:govoplan-calendar.related.917df91e</h3>
|
|
<div className="calendar-dialog-grid-three">
|
|
<label>
|
|
<span>i18n:govoplan-calendar.reminders_json.ca25e08f</span>
|
|
<textarea value={remindersJson} onChange={(item) => setRemindersJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
|
|
</label>
|
|
<label>
|
|
<span>i18n:govoplan-calendar.attachments_json.d91abf3c</span>
|
|
<textarea value={attachmentsJson} onChange={(item) => setAttachmentsJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
|
|
</label>
|
|
<label>
|
|
<span>i18n:govoplan-calendar.related_to_json.2d4e8f59</span>
|
|
<textarea value={relatedToJson} onChange={(item) => setRelatedToJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
|
|
</label>
|
|
</div>
|
|
</section>
|
|
<section className="calendar-vevent-section">
|
|
<h3>i18n:govoplan-calendar.source.6da13add</h3>
|
|
<div className="calendar-dialog-grid-three">
|
|
<label>
|
|
<span>i18n:govoplan-calendar.source_kind.7eda9bc4</span>
|
|
<input value={sourceKind} onChange={(item) => setSourceKind(item.target.value)} maxLength={30} disabled={saving || !canWrite} />
|
|
</label>
|
|
<label>
|
|
<span>i18n:govoplan-calendar.source_href.819fa147</span>
|
|
<input value={sourceHref} onChange={(item) => setSourceHref(item.target.value)} maxLength={1000} disabled={saving || !canWrite} />
|
|
</label>
|
|
<label>
|
|
<span>i18n:govoplan-calendar.etag.11d00f6e</span>
|
|
<input value={etag} onChange={(item) => setEtag(item.target.value)} maxLength={255} disabled={saving || !canWrite} />
|
|
</label>
|
|
</div>
|
|
</section>
|
|
<section className="calendar-vevent-section">
|
|
<h3>i18n:govoplan-calendar.raw.da433cd4</h3>
|
|
<div className="calendar-dialog-grid-two">
|
|
<label>
|
|
<span>i18n:govoplan-calendar.icalendar_json.fb6cc33e</span>
|
|
<textarea value={icalendarJson} onChange={(item) => setICalendarJson(item.target.value)} rows={8} disabled={saving || !canWrite} />
|
|
</label>
|
|
<label>
|
|
<span>i18n:govoplan-calendar.metadata_json.b0e4c283</span>
|
|
<textarea value={metadataJson} onChange={(item) => setMetadataJson(item.target.value)} rows={8} disabled={saving || !canWrite} />
|
|
</label>
|
|
</div>
|
|
</section>
|
|
</details>
|
|
</form>
|
|
</Dialog>);
|
|
|
|
}
|
|
|
|
function jsonTextareaValue(value: unknown): string {
|
|
return JSON.stringify(value, null, 2);
|
|
}
|
|
|
|
function parseJsonObject(text: string, label: string): Record<string, unknown> {
|
|
const value = parseJsonText(text, label, {});
|
|
if (!isPlainObject(value)) throw new Error(`${label} must be a JSON object.`);
|
|
return value;
|
|
}
|
|
|
|
function parseJsonObjectOrNull(text: string, label: string): Record<string, unknown> | null {
|
|
if (!text.trim()) return null;
|
|
const value = parseJsonText(text, label, null);
|
|
if (value === null) return null;
|
|
if (!isPlainObject(value)) throw new Error(`${label} must be a JSON object or null.`);
|
|
return value;
|
|
}
|
|
|
|
function parseJsonArray(text: string, label: string): Record<string, unknown>[] {
|
|
const value = parseJsonText(text, label, []);
|
|
if (!Array.isArray(value)) throw new Error(`${label} must be a JSON array.`);
|
|
return value.map((item, index) => {
|
|
if (!isPlainObject(item)) throw new Error(`${label} item ${index + 1} must be a JSON object.`);
|
|
return item;
|
|
});
|
|
}
|
|
|
|
function parseJsonText(text: string, label: string, fallback: unknown): unknown {
|
|
const trimmed = text.trim();
|
|
if (!trimmed) return fallback;
|
|
try {
|
|
return JSON.parse(trimmed) as unknown;
|
|
} catch (err) {
|
|
throw new Error(`${label} contains invalid JSON: ${errorText(err)}`);
|
|
}
|
|
}
|
|
|
|
function parseRRuleInput(text: string): Record<string, unknown> | null {
|
|
const trimmed = text.trim();
|
|
if (!trimmed) return null;
|
|
if (trimmed.startsWith("{")) return parseJsonObject(trimmed, "RRULE");
|
|
const result: Record<string, unknown> = {};
|
|
for (const item of trimmed.split(";")) {
|
|
const [rawKey, ...rawValueParts] = item.split("=");
|
|
const key = rawKey.trim().toUpperCase();
|
|
const rawValue = rawValueParts.join("=").trim();
|
|
if (!key || !rawValue) continue;
|
|
const values = rawValue.split(",").map((value) => value.trim()).filter(Boolean);
|
|
result[key] = values.length > 1 ? values : values[0] ?? rawValue;
|
|
}
|
|
return Object.keys(result).length ? result : null;
|
|
}
|
|
|
|
function serializeRRuleText(value: Record<string, unknown>): string {
|
|
return Object.entries(value).
|
|
map(([key, item]) => `${key.toUpperCase()}=${Array.isArray(item) ? item.join(",") : String(item)}`).
|
|
join(";");
|
|
}
|
|
|
|
function commaSeparatedValues(text: string): string[] {
|
|
return text.split(",").map((item) => item.trim()).filter(Boolean);
|
|
}
|
|
|
|
function withDurationMode(value: Record<string, unknown>, usesDuration: boolean): Record<string, unknown> {
|
|
const next = { ...value };
|
|
const standard = isPlainObject(next.standard) ? { ...next.standard } : {};
|
|
if (usesDuration) {
|
|
standard.uses_duration = true;
|
|
} else {
|
|
delete standard.uses_duration;
|
|
}
|
|
if (Object.keys(standard).length > 0) {
|
|
next.standard = standard;
|
|
} else {
|
|
delete next.standard;
|
|
}
|
|
return next;
|
|
}
|
|
|
|
function eventUsesDuration(event: CalendarEvent): boolean {
|
|
const standard = isPlainObject(event.icalendar?.standard) ? event.icalendar.standard : null;
|
|
return standard?.uses_duration === true;
|
|
}
|
|
|
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|