fix: save an edited HTML event description as plain text and keep notes in its format

This commit is contained in:
Linus Rath
2026-11-02 22:52:04 +01:00
parent 798f0ca0c8
commit 7ab900a0f7
7 changed files with 120 additions and 11 deletions
@@ -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 <b> c');
});
});
describe('saving an HTML event description from the editor', () => {
async function save(event: CalendarEvent, newDescription?: string): Promise<Partial<CalendarEvent>> {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<EventModal event={event} calendars={[calendar]} onSave={onSave} onClose={vi.fn()} currentUserEmails={['me@example.com']} />);
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<CalendarEvent>;
}
// 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');
});
});
+4 -6
View File
@@ -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")));
+7 -4
View File
@@ -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).
@@ -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: '<p><b>Agenda</b></p>', descriptionContentType: 'text/html' },
'--- 2026-11-02 10:00 ---',
'Call <Bob> & Ann\nabout it',
);
expect(fields).toEqual({
description: '<p><b>Agenda</b></p><p>--- 2026-11-02 10:00 ---<br>Call &lt;Bob&gt; &amp; Ann<br>about it</p>',
});
});
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' });
});
});
@@ -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: '<p>Old</p>', 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
+36
View File
@@ -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('<br>');
return { description: `${description}<p>${lines}</p>` };
}
const text = description ? `${description}\n\n${heading}\n${note}` : `${heading}\n${note}`;
return plainDescriptionFields(event, text);
}
+3
View File
@@ -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)) {