mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 08:15:38 +00:00
fix: save an edited HTML event description as plain text and keep notes in its format
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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")));
|
||||
|
||||
@@ -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 <Bob> & 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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user