fix(caldav): handle numeric event shape in extractSyncValues (#8564) (#8573)

The "+ add to schedule" flow passes the PluginSearchResult shape, where
`start`/`duration` are epoch-ms numbers, but extractSyncValues only handled
the iCal-string shape returned by getById. parseIcalDateTime then ran
`.slice()` on a number, throwing `n.slice is not a function`, so the task
was silently never created for every CalDAV event.

Branch extractSyncValues on the value type: numeric start -> start_date
(all-day) or start_dateTime (timed) with numeric duration_ms; string start
keeps the existing iCal-parsing path. Guard parseIcalDateTime against
non-string input, and guard the numeric branch with Number.isFinite so a
NaN/Infinity start can't throw in toISOString or seed a corrupt write-back
baseline. Factor the YYYYMMDD->YYYY-MM-DD conversion into ymdToIsoDate,
shared by both branches.

Add regression specs for the numeric timed, numeric all-day, and
non-finite-start shapes.
This commit is contained in:
Johannes Millan 2026-06-24 13:46:40 +02:00 committed by GitHub
parent 0f47c6385e
commit 05690dee63
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 92 additions and 16 deletions

View file

@ -190,6 +190,59 @@ describe('CalDAV Calendar Plugin', () => {
expect(result.start_dateTime).toBe('2026-03-20T10:00:00.000Z');
expect(result.duration_ms).toBe(2 * 60 * 60 * 1000 + 30 * 60 * 1000);
});
// Regression for #8564: the "+ add to schedule" flow passes the
// PluginSearchResult shape (epoch-ms `start`/`duration`, `isAllDay` flag),
// not the iCal-string shape getById returns. This used to throw
// `n.slice is not a function` inside parseIcalDateTime.
it('should handle numeric (PluginSearchResult) timed event without throwing', () => {
const startMs = Date.UTC(2026, 2, 20, 12, 0, 0);
const issue = {
id: 'e1',
title: 'Meeting',
body: 'notes',
start: startMs,
dueWithTime: startMs,
duration: 30 * 60 * 1000,
isAllDay: false,
};
const result = definition.extractSyncValues!(issue as any);
expect(result.start_dateTime).toBe('2026-03-20T12:00:00.000Z');
expect(result.start_date).toBeUndefined();
expect(result.duration_ms).toBe(30 * 60 * 1000);
expect(result.summary).toBe('Meeting');
});
it('should handle numeric (PluginSearchResult) all-day event', () => {
// All-day occurrences are stamped at local midnight.
const startMs = new Date(2026, 2, 20, 0, 0, 0).getTime();
const issue = {
id: 'e1',
title: 'Holiday',
body: '',
start: startMs,
duration: 0,
isAllDay: true,
};
const result = definition.extractSyncValues!(issue as any);
expect(result.start_date).toBe('2026-03-20');
expect(result.start_dateTime).toBeUndefined();
expect(result.duration_ms).toBe(0);
});
it('should not throw or seed a corrupt baseline on a non-finite numeric start', () => {
const issue = { id: 'e1', title: 'Broken', body: '', start: NaN, isAllDay: false };
const result = definition.extractSyncValues!(issue as any);
expect(result.start_dateTime).toBeUndefined();
expect(result.start_date).toBeUndefined();
expect(result.duration_ms).toBe(0);
});
});
describe('updateIssue', () => {

View file

@ -198,7 +198,9 @@ const getIcalPropParams = (lines: string[], name: string): string => {
* 20260320 (date-only, VALUE=DATE).
*/
const parseIcalDateTime = (value: string, params: string): Date | null => {
if (!value) return null;
// Defensive: callers occasionally pass epoch-ms numbers (PluginSearchResult
// shape) instead of iCal strings; never call .slice() on a non-string. See #8564.
if (typeof value !== 'string' || !value) return null;
// Date-only: YYYYMMDD
if (value.length === 8) {
const y = parseInt(value.slice(0, 4), 10);
@ -298,6 +300,10 @@ const toIcalDate = (date: Date): string => {
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}`;
};
/** Convert a compact iCal date (YYYYMMDD) to an ISO calendar date (YYYY-MM-DD) */
const ymdToIsoDate = (ymd: string): string =>
`${ymd.slice(0, 4)}-${ymd.slice(4, 6)}-${ymd.slice(6, 8)}`;
/** Format a timestamp as UTC ISO 8601 */
const toUTCISO = (timestamp: number): string => new Date(timestamp).toISOString();
@ -1479,34 +1485,51 @@ PluginAPI.registerIssueProvider({
extractSyncValues(issue: PluginIssue): Record<string, unknown> {
const raw = issue as Record<string, unknown>;
const startRaw = raw.start as string | undefined;
const endRaw = raw.end as string | undefined;
const startParams = (raw.startParams as string) || '';
const durationRaw = raw.duration as string | undefined;
const startRaw = raw.start;
const durationRaw = raw.duration;
let startDateTime: string | undefined;
let startDate: string | undefined;
let durationMs = 0;
if (startRaw) {
if (typeof startRaw === 'number' && Number.isFinite(startRaw)) {
// Panel / backlog / search shape (PluginSearchResult): epoch-ms `start`,
// numeric `duration`, explicit `isAllDay`. This is what the "+ add to
// schedule" flow feeds, so it must be handled here too — not only the
// iCal-string shape that getById returns. Calling parseIcalDateTime on a
// number used to throw `n.slice is not a function`. See #8564.
// The Number.isFinite guard keeps a NaN/Infinity start from throwing in
// toISOString or seeding a corrupt write-back baseline (it falls through
// to the empty result instead).
if (raw.isAllDay) {
// All-day occurrences are stamped at local midnight, so local getters
// (via toIcalDate) yield the correct calendar date.
startDate = ymdToIsoDate(toIcalDate(new Date(startRaw)));
} else {
startDateTime = new Date(startRaw).toISOString();
}
durationMs = typeof durationRaw === 'number' ? durationRaw : 0;
} else if (typeof startRaw === 'string' && startRaw) {
// getById shape: raw iCal DTSTART string + params.
const startParams = (raw.startParams as string) || '';
const parsed = parseIcalDateTime(startRaw, startParams);
if (parsed) {
if (isDateOnly(startRaw, startParams)) {
// Convert YYYYMMDD to YYYY-MM-DD
startDate = `${startRaw.slice(0, 4)}-${startRaw.slice(4, 6)}-${startRaw.slice(6, 8)}`;
startDate = ymdToIsoDate(startRaw);
} else {
startDateTime = parsed.toISOString();
}
}
}
if (durationRaw) {
durationMs = parseDuration(durationRaw);
} else if (startDateTime && endRaw) {
const endParams = (raw.endParams as string) || '';
const endParsed = parseIcalDateTime(endRaw, endParams);
if (endParsed) {
durationMs = endParsed.getTime() - new Date(startDateTime).getTime();
const endRaw = raw.end as string | undefined;
if (typeof durationRaw === 'string' && durationRaw) {
durationMs = parseDuration(durationRaw);
} else if (startDateTime && endRaw) {
const endParams = (raw.endParams as string) || '';
const endParsed = parseIcalDateTime(endRaw, endParams);
if (endParsed) {
durationMs = endParsed.getTime() - new Date(startDateTime).getTime();
}
}
}