From 7ab900a0f7918d771c32197f60ff84fa707d3e9d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:44:40 +0100 Subject: [PATCH] fix: save an edited HTML event description as plain text and keep notes in its format --- .../__tests__/event-description-html.test.tsx | 37 ++++++++++++++++++- components/calendar/calendar-app.tsx | 10 ++--- components/calendar/event-modal.tsx | 11 ++++-- lib/__tests__/calendar-description.test.ts | 24 ++++++++++++ .../plugin-host-calendar-tasks.test.ts | 10 +++++ lib/calendar-description.ts | 36 ++++++++++++++++++ lib/plugin-sandbox/host-api-calendar.ts | 3 ++ 7 files changed, 120 insertions(+), 11 deletions(-) create mode 100644 lib/__tests__/calendar-description.test.ts diff --git a/components/calendar/__tests__/event-description-html.test.tsx b/components/calendar/__tests__/event-description-html.test.tsx index 064030ee5..8c3becab0 100644 --- a/components/calendar/__tests__/event-description-html.test.tsx +++ b/components/calendar/__tests__/event-description-html.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { describe, it, expect, vi } from 'vitest'; import { EventDetailPopover } from '../event-detail-popover'; import { EventModal } from '../event-modal'; @@ -68,3 +68,38 @@ describe('HTML event descriptions', () => { expect(container.querySelector('.pr-text')?.textContent).toBe('a c'); }); }); + +describe('saving an HTML event description from the editor', () => { + async function save(event: CalendarEvent, newDescription?: string): Promise> { + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + fireEvent.click(screen.getByText('events.edit')); + if (newDescription !== undefined) { + fireEvent.change(screen.getByPlaceholderText('form.description'), { target: { value: newDescription } }); + } + fireEvent.click(screen.getByText('form.save')); + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + return onSave.mock.calls[0][0] as Partial; + } + + // Other clients would read the text as HTML and run its lines together. + it('labels an edited description as plain text', async () => { + const data = await save(htmlEvent(), `${TEXT}\nMore`); + expect(data.description).toBe(`${TEXT}\nMore`); + expect(data.descriptionContentType).toBe('text/plain'); + }); + + it('leaves an untouched HTML description as it was', async () => { + const data = await save(htmlEvent()); + expect(data).not.toHaveProperty('description'); + expect(data).not.toHaveProperty('descriptionContentType'); + }); + + // Stalwart writes a set type as STYLED-DESCRIPTION, which many clients skip. + it('sends no type for an event that had none', async () => { + const event = { ...htmlEvent(), description: 'old', descriptionContentType: undefined } as unknown as CalendarEvent; + const data = await save(event, 'new'); + expect(data.description).toBe('new'); + expect(data).not.toHaveProperty('descriptionContentType'); + }); +}); diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 621ba7347..6b6595315 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -60,6 +60,7 @@ import { useIsFocusedProTab, useIsPaneScoped } from "@/hooks/use-pane-context"; import { useProMultiAccountCalendars } from "@/hooks/use-pro-multi-account-calendars"; import { ResizeHandle } from "@/components/layout/resize-handle"; import { buildDuplicateEventData } from "@/lib/calendar-duplicate"; +import { descriptionWithNote } from "@/lib/calendar-description"; import { filterTasksByCalendars } from "@/lib/calendar-tasks"; import { baseEventStoreId, isServerRecurrenceInstance } from "@/lib/recurrence-instances"; import { deleteOccurrence, saveOccurrenceEdit, type EventEditorState } from "@/lib/recurrence-scope-edit"; @@ -1532,13 +1533,10 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = const handleSaveNoteFromDetail = useCallback(async (note: string) => { if (!detailEvent || !client) return; const timestamp = format(displayNow(), "yyyy-MM-dd HH:mm"); - const separator = `\n\n--- ${timestamp} ---\n`; - const newDescription = detailEvent.description - ? `${detailEvent.description}${separator}${note}` - : `--- ${timestamp} ---\n${note}`; + const fields = descriptionWithNote(detailEvent, `--- ${timestamp} ---`, note); try { - if (!(await updateEvent(client, detailEvent.id, { description: newDescription }))) return; - setDetailEvent({ ...detailEvent, description: newDescription }); + if (!(await updateEvent(client, detailEvent.id, fields))) return; + setDetailEvent({ ...detailEvent, ...fields }); toast.success(t("detail.note_saved")); } catch (error) { toast.error(describeError(error, t("notifications.event_error"))); diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 7e903ab0d..88db9168b 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -9,7 +9,7 @@ import { LinkifiedText } from "@/components/ui/linkified-text"; import { X, Trash2, Users, CalendarDays, CopyPlus, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, AlertTriangle, MoreVertical } from "@/components/icons"; import { IconButton } from "@/components/ui/icon-button"; import { findMeetingLink, locationAction, mapsUrl, primaryLocationName } from "@/lib/event-links"; -import { descriptionAsText } from "@/lib/calendar-description"; +import { descriptionAsText, isHtmlDescription, plainDescriptionFields } from "@/lib/calendar-description"; import { ParticipantStatusBadge } from "./rsvp-buttons"; import { format, parseISO, addHours, isSameDay } from "date-fns"; import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types"; @@ -791,8 +791,11 @@ export function EventModal({ // On an existing event the empty string is how a description gets cleared, but // sending it on creation writes a `DESCRIPTION:` with nothing after it — which // invitation e-mails then render as a "Description" heading over blank space. - if (trimmedDescription || event) { - data.description = trimmedDescription; + // An HTML description the user did not touch stays as it was; one they + // edited is saved as the plain text it now is. + const keepsHtml = !!event && isHtmlDescription(event) && trimmedDescription === eventDescription.trim(); + if ((trimmedDescription || event) && !keepsHtml) { + Object.assign(data, plainDescriptionFields(event, trimmedDescription)); } if (!event) { @@ -941,7 +944,7 @@ export function EventModal({ savingRef.current = false; setIsSaving(false); } - }, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, calendars, recurrence, customRule, useDefaultAlerts, defaultAlerts, alertRows, preservedAlerts, attendees, initialAttendees, sendInvitations, currentUserEmails, userIsOrganizer, resolveContactName, event, onSave, isSaving, t]); + }, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, calendars, recurrence, customRule, useDefaultAlerts, defaultAlerts, alertRows, preservedAlerts, attendees, initialAttendees, sendInvitations, currentUserEmails, userIsOrganizer, resolveContactName, event, eventDescription, onSave, isSaving, t]); // The user's own answer, and the owner's in a calendar shared with them // (lib/calendar-delegate.ts). diff --git a/lib/__tests__/calendar-description.test.ts b/lib/__tests__/calendar-description.test.ts new file mode 100644 index 000000000..76bba7ec0 --- /dev/null +++ b/lib/__tests__/calendar-description.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { descriptionWithNote } from '@/lib/calendar-description'; + +// The quick note in the event popover is added to the description. + +describe('descriptionWithNote', () => { + it('adds a note to an HTML description as an HTML paragraph', () => { + const fields = descriptionWithNote( + { description: '

Agenda

', descriptionContentType: 'text/html' }, + '--- 2026-11-02 10:00 ---', + 'Call & Ann\nabout it', + ); + expect(fields).toEqual({ + description: '

Agenda

--- 2026-11-02 10:00 ---
Call <Bob> & Ann
about it

', + }); + }); + + it('adds a note to a plain description as text', () => { + expect(descriptionWithNote({ description: 'Agenda', descriptionContentType: 'text/plain' }, '--- t ---', 'Call Bob')) + .toEqual({ description: 'Agenda\n\n--- t ---\nCall Bob' }); + expect(descriptionWithNote({ description: '' }, '--- t ---', 'Call Bob')) + .toEqual({ description: '--- t ---\nCall Bob' }); + }); +}); diff --git a/lib/__tests__/plugin-host-calendar-tasks.test.ts b/lib/__tests__/plugin-host-calendar-tasks.test.ts index 03d20d6c3..bf8e3aea0 100644 --- a/lib/__tests__/plugin-host-calendar-tasks.test.ts +++ b/lib/__tests__/plugin-host-calendar-tasks.test.ts @@ -204,6 +204,16 @@ describe('api.calendar', () => { await expect(call(WRITE, 'calendar.updateEvent', 'e1', {})).rejects.toThrow(/patch is empty/); }); + // A plugin writes plain text; left labelled text/html, other clients would + // run its lines together. + it('labels a description written over an HTML one as plain text', async () => { + client.getCalendarEvent.mockResolvedValue(event({ description: '

Old

', descriptionContentType: 'text/html' })); + await call(WRITE, 'calendar.updateEvent', 'e1', { description: 'New\nlines' }); + expect(client.updateCalendarEvent).toHaveBeenCalledWith('e1', expect.objectContaining({ + description: 'New\nlines', descriptionContentType: 'text/plain', + }), false, undefined); + }); + it('includes the virtual birthday calendar in listCalendars only when contacts:read is held and showBirthdayCalendar is enabled', async () => { useSettingsStore.setState({ showBirthdayCalendar: true, birthdayCalendarColor: '#f59e0b' }); // Plugin without contacts:read does not see the birthday calendar diff --git a/lib/calendar-description.ts b/lib/calendar-description.ts index 5df68544f..b54e2bbd6 100644 --- a/lib/calendar-description.ts +++ b/lib/calendar-description.ts @@ -1,4 +1,5 @@ import { htmlToPlainText } from '@/lib/html-to-text'; +import { escapeHtml } from '@/lib/email-sanitization'; interface DescribedEvent { description?: string | null; @@ -20,3 +21,38 @@ export function descriptionAsText(event: DescribedEvent): string { if (!description || !isHtmlDescription(event)) return description; return htmlToPlainText(description, { paragraphSpacing: true }); } + +/** + * The fields that write `text` as the description of `event`. An event whose + * description had another type (text/html) is relabelled text/plain, or other + * clients would read the text as HTML and run its lines together. Events + * without a type keep none: Stalwart writes a set type to ICS as + * STYLED-DESCRIPTION, which many CalDAV clients do not read. + */ +export function plainDescriptionFields( + event: DescribedEvent | null | undefined, + text: string, +): { description: string; descriptionContentType?: string } { + const type = event?.descriptionContentType?.trim(); + if (type && !/^text\/plain\b/i.test(type)) return { description: text, descriptionContentType: 'text/plain' }; + return { description: text }; +} + +/** + * The description of `event` with a note added under `heading`. An HTML + * description gets the note as an HTML paragraph, so it keeps its own + * formatting and stays text/html. + */ +export function descriptionWithNote( + event: DescribedEvent, + heading: string, + note: string, +): { description: string; descriptionContentType?: string } { + const description = event.description ?? ''; + if (description && isHtmlDescription(event)) { + const lines = [heading, ...note.split(/\r?\n/)].map(escapeHtml).join('
'); + return { description: `${description}

${lines}

` }; + } + const text = description ? `${description}\n\n${heading}\n${note}` : `${heading}\n${note}`; + return plainDescriptionFields(event, text); +} diff --git a/lib/plugin-sandbox/host-api-calendar.ts b/lib/plugin-sandbox/host-api-calendar.ts index ce6d16bf5..fb2cac800 100644 --- a/lib/plugin-sandbox/host-api-calendar.ts +++ b/lib/plugin-sandbox/host-api-calendar.ts @@ -34,6 +34,7 @@ import { expandRecurringEvents } from '../recurrence-expansion'; import { buildParticipantMap } from '../calendar-participants'; import { parseDurationSeconds } from '../calendar-event-normalization'; import { parseAlertOffset, withOwnAlertIds } from '../calendar-alerts'; +import { plainDescriptionFields } from '../calendar-description'; import { patchPath } from '../jmap/patch-object'; import { isValidEmail } from '../validation'; import { getEffectiveTimeZone } from '../timezone'; @@ -537,6 +538,8 @@ export async function doCalendarUpdateEvent(id: unknown, patch: unknown): Promis updates.calendarIds = { [await pickCalendar(client, method, data.calendarId, false)]: true }; } const existing = await eventForMutation(client, method, eventId); + // A plugin writes plain text, also over an HTML description. + if (updates.description !== undefined) Object.assign(updates, plainDescriptionFields(existing, updates.description)); const timeKeys = ['start', 'end', 'duration', 'timeZone', 'isAllDay'] as const; if (timeKeys.some((k) => data[k] !== undefined)) {