mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
Merge pull request #1256 from sanitz/fix/task-due-localdatetime
fix(tasks): send an all-day task's due and start as a LocalDateTime
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"_source": "Stalwart v0.16.24, local server, 2026-10-01. Each row: a JMAP CalendarEvent/set (or a CalDAV PUT), then CalendarEvent/get and a CalDAV GET of the stored iCalendar. icalDue is the DUE line of that iCalendar, null when it has none.",
|
||||
"allDayTaskDue": [
|
||||
{
|
||||
"write": { "method": "update", "patch": { "due": "2026-10-14" } },
|
||||
"jmapAfter": { "due": "2026-10-14", "showWithoutTime": true },
|
||||
"icalDue": null
|
||||
},
|
||||
{
|
||||
"write": { "method": "update", "patch": { "due": "2026-10-14T00:00:00" } },
|
||||
"jmapAfter": { "due": "2026-10-14T00:00:00", "showWithoutTime": true },
|
||||
"icalDue": "DUE;VALUE=DATE:20261014"
|
||||
},
|
||||
{
|
||||
"write": { "method": "create", "patch": { "due": "2026-10-07", "showWithoutTime": true } },
|
||||
"jmapAfter": { "due": "2026-10-07", "showWithoutTime": true },
|
||||
"icalDue": null
|
||||
},
|
||||
{
|
||||
"write": { "method": "caldav-put", "ical": "DUE;VALUE=DATE:20261020" },
|
||||
"jmapAfter": { "due": "2026-10-20T00:00:00", "showWithoutTime": true },
|
||||
"icalDue": "DUE;VALUE=DATE:20261020"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { JMAPClient } from '../jmap/client';
|
||||
import type { CalendarTask } from '../jmap/types';
|
||||
import wire from './fixtures/stalwart-task-due-wire.json';
|
||||
|
||||
function makeSession() {
|
||||
return {
|
||||
@@ -22,6 +23,31 @@ function jsonResponse(body: unknown): Response {
|
||||
|
||||
type Call = { method: string; args: Record<string, unknown> };
|
||||
|
||||
const isBareDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value);
|
||||
|
||||
/**
|
||||
* Whether Stalwart keeps a task due of this shape in the stored iCalendar,
|
||||
* read from recorded round trips: it echoes a bare date over JMAP but writes
|
||||
* no DUE, so CalDAV clients lose the date.
|
||||
*/
|
||||
function stalwartKeepsDue(due: string): boolean {
|
||||
const rows = wire.allDayTaskDue.filter((row) => row.write.patch && isBareDate(row.write.patch.due) === isBareDate(due));
|
||||
if (rows.length === 0) throw new Error(`no recorded round trip for a due like ${due}`);
|
||||
return rows.every((row) => row.icalDue !== null);
|
||||
}
|
||||
|
||||
/** What the task modal sends on save: every field, here for an all-day task. */
|
||||
const modalPayload = (due: string | null): Partial<CalendarTask> => ({
|
||||
'@type': 'Task',
|
||||
title: 'Pay rent',
|
||||
description: '',
|
||||
due,
|
||||
showWithoutTime: true,
|
||||
priority: 0,
|
||||
progress: 'needs-action',
|
||||
calendarIds: { 'cal-1': true },
|
||||
});
|
||||
|
||||
describe('JMAPClient task operations (#958)', () => {
|
||||
let client: JMAPClient;
|
||||
let sentCalls: Call[];
|
||||
@@ -121,6 +147,32 @@ describe('JMAPClient task operations (#958)', () => {
|
||||
expect(createPayload.title).toBe('New Task');
|
||||
expect(createPayload).not.toHaveProperty('progressUpdated');
|
||||
});
|
||||
|
||||
const sentTask = (kind: 'create' | 'update', key: string) => {
|
||||
const setCall = sentCalls.find((c) => c.method === 'CalendarEvent/set');
|
||||
return (setCall?.args[kind] as Record<string, Record<string, unknown>>)[key];
|
||||
};
|
||||
|
||||
it.each([
|
||||
['createCalendarTask', 'create', 'new-task', (c: JMAPClient) => c.createCalendarTask(modalPayload('2026-10-07'))],
|
||||
['updateCalendarTask', 'update', 'task-1', (c: JMAPClient) => c.updateCalendarTask('task-1', modalPayload('2026-10-07'))],
|
||||
] as const)('%s sends an all-day due as a LocalDateTime, which Stalwart keeps in the iCalendar', async (_name, kind, key, write) => {
|
||||
await write(client);
|
||||
|
||||
const due = sentTask(kind, key).due as string;
|
||||
expect(due).toBe('2026-10-07T00:00:00');
|
||||
expect(stalwartKeepsDue(due)).toBe(true);
|
||||
});
|
||||
|
||||
it('completes a bare start date as well and leaves a due with a time alone', async () => {
|
||||
await client.updateCalendarTask('task-1', { start: '2026-10-01', due: '2026-10-14T09:30:00' });
|
||||
expect(sentTask('update', 'task-1')).toMatchObject({ start: '2026-10-01T00:00:00', due: '2026-10-14T09:30:00' });
|
||||
});
|
||||
|
||||
it('keeps a cleared due cleared', async () => {
|
||||
await client.updateCalendarTask('task-1', modalPayload(null));
|
||||
expect(sentTask('update', 'task-1').due).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('JMAPClient tasks in shared calendars (#1204)', () => {
|
||||
@@ -129,7 +181,9 @@ describe('JMAPClient tasks in shared calendars (#1204)', () => {
|
||||
|
||||
// Tasks per account as the server stores them (bare ids).
|
||||
const serverTasks: Record<string, Record<string, unknown>[]> = {
|
||||
'acct-1': [{ id: 'own-1', '@type': 'Task', uid: 'uid-own', title: 'Mine', progress: 'needs-action', calendarIds: { 'cal-1': true }, created: '2026-10-01T00:00:00Z', isDraft: false, isOrigin: true, locations: { l1: { '@type': 'Location', name: 'Desk' } } }],
|
||||
'acct-1': [{ id: 'own-1', '@type': 'Task', uid: 'uid-own', title: 'Mine', progress: 'needs-action', calendarIds: { 'cal-1': true }, created: '2026-10-01T00:00:00Z', isDraft: false, isOrigin: true, locations: { l1: { '@type': 'Location', name: 'Desk' } } },
|
||||
// Saved by a client that sent a bare date: Stalwart echoes it as such.
|
||||
{ id: 'own-2', '@type': 'Task', uid: 'uid-due', title: 'Dated', progress: 'needs-action', calendarIds: { 'cal-1': true }, due: wire.allDayTaskDue[0].jmapAfter.due, showWithoutTime: true }],
|
||||
'acct-2': [{ id: 'shared-1', '@type': 'Task', uid: 'uid-shared', title: 'Theirs', progress: 'needs-action', calendarIds: { 'cal-s': true } }],
|
||||
};
|
||||
|
||||
@@ -195,6 +249,15 @@ describe('JMAPClient tasks in shared calendars (#1204)', () => {
|
||||
expect(newId).toBe('acct-2:new-id');
|
||||
});
|
||||
|
||||
it('moving a task with a bare due date to another account stores the due as a LocalDateTime', async () => {
|
||||
await client.updateCalendarTask('own-2', { calendarIds: { 'acct-2:cal-s': true } });
|
||||
|
||||
const create = sentCalls.find((c) => c.method === 'CalendarEvent/set' && c.args.create);
|
||||
const due = (create?.args.create as Record<string, Record<string, unknown>>)['new-task'].due as string;
|
||||
expect(due).toBe('2026-10-14T00:00:00');
|
||||
expect(stalwartKeepsDue(due)).toBe(true);
|
||||
});
|
||||
|
||||
it('updates a shared task in place in its own account with real ids', async () => {
|
||||
const id = await client.updateCalendarTask('acct-2:shared-1', { progress: 'completed', calendarIds: { 'acct-2:cal-s': true } });
|
||||
|
||||
|
||||
+19
-5
@@ -570,6 +570,20 @@ function getUserTimeZone(): string | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
const BARE_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/**
|
||||
* A task's `due` and `start` are LocalDateTimes (RFC 8984). Stalwart accepts a
|
||||
* bare date and echoes it over JMAP, but stores no DUE in the iCalendar, so
|
||||
* CalDAV clients lose the date. Completes a bare date to midnight.
|
||||
*/
|
||||
function completeBareTaskDates<T extends Partial<CalendarTask>>(task: T): T {
|
||||
const result = { ...task };
|
||||
if (typeof result.due === 'string' && BARE_DATE.test(result.due)) result.due = `${result.due}T00:00:00`;
|
||||
if (typeof result.start === 'string' && BARE_DATE.test(result.start)) result.start = `${result.start}T00:00:00`;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stalwart's calcard crate uses singular property names ("recurrenceRule")
|
||||
* instead of the RFC 8984 plural forms ("recurrenceRules").
|
||||
@@ -7827,7 +7841,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
accountId = resolved.accountId;
|
||||
taskData.calendarIds = resolved.calendarIds;
|
||||
}
|
||||
const cleanTask = { ...taskData, '@type': 'Task' };
|
||||
const cleanTask = { ...completeBareTaskDates(taskData), '@type': 'Task' };
|
||||
|
||||
debug.group('CalendarTask/create', 'tasks');
|
||||
debug.log('tasks', 'CalendarTask/create accountId', accountId);
|
||||
@@ -7910,10 +7924,10 @@ export class JMAPClient implements IJMAPClient {
|
||||
*/
|
||||
async updateCalendarTask(taskId: string, updates: Partial<CalendarTask>, targetAccountId?: string): Promise<string> {
|
||||
const source = targetAccountId ? { accountId: targetAccountId, id: taskId } : this.splitCalendarObjectId(taskId);
|
||||
let patch = updates;
|
||||
if (!targetAccountId && updates.calendarIds) {
|
||||
const dest = this.resolveTaskCalendarIds(updates.calendarIds);
|
||||
patch = { ...updates, calendarIds: dest.calendarIds };
|
||||
let patch = completeBareTaskDates(updates);
|
||||
if (!targetAccountId && patch.calendarIds) {
|
||||
const dest = this.resolveTaskCalendarIds(patch.calendarIds);
|
||||
patch = { ...patch, calendarIds: dest.calendarIds };
|
||||
if (dest.accountId !== source.accountId) {
|
||||
return this.moveCalendarTaskToAccount(source, dest.accountId, patch);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user