mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
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:
parent
0f47c6385e
commit
05690dee63
2 changed files with 92 additions and 16 deletions
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue