fix: let tasks move into and live in calendars shared with you #1204

This commit is contained in:
Linus Rath
2026-10-12 07:36:46 +02:00
parent 19797b4c65
commit 37440e02b0
7 changed files with 238 additions and 10 deletions
+91
View File
@@ -122,3 +122,94 @@ describe('JMAPClient task operations (#958)', () => {
expect(createPayload).not.toHaveProperty('progressUpdated');
});
});
describe('JMAPClient tasks in shared calendars (#1204)', () => {
let client: JMAPClient;
let sentCalls: Call[];
// 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-2': [{ id: 'shared-1', '@type': 'Task', uid: 'uid-shared', title: 'Theirs', progress: 'needs-action', calendarIds: { 'cal-s': true } }],
};
beforeEach(() => {
sentCalls = [];
const session = makeSession();
session.accounts = {
...session.accounts,
'acct-2': { name: 'owner', isPersonal: false, accountCapabilities: { 'urn:ietf:params:jmap:calendars': {} } },
} as typeof session.accounts;
client = new JMAPClient('https://mail.example.com', 'test', 'pass');
(client as unknown as { session: unknown }).session = session;
(client as unknown as { accountId: string }).accountId = 'acct-1';
(client as unknown as { apiUrl: string }).apiUrl = session.apiUrl;
(client as unknown as { accounts: Record<string, unknown> }).accounts = session.accounts;
vi.stubGlobal('fetch', vi.fn(async (_url: string, init?: RequestInit) => {
const body = JSON.parse((init?.body as string) || '{}');
const calls = (body.methodCalls || []) as [string, Record<string, unknown>, string][];
const methodResponses: [string, Record<string, unknown>, string][] = [];
for (const [method, args, tag] of calls) {
sentCalls.push({ method, args });
const list = serverTasks[args.accountId as string] ?? [];
if (method === 'CalendarEvent/query') {
methodResponses.push([method, { ids: list.map((t) => t.id) }, tag]);
} else if (method === 'CalendarEvent/get') {
const ids = args.ids as string[];
const found = list.filter((t) => ids.includes(t.id as string));
// A freshly created task is fetched back by its new id.
const fresh = ids.includes('new-id') ? [{ id: 'new-id', '@type': 'Task', uid: 'uid-own', title: 'Mine', progress: 'needs-action', calendarIds: { 'cal-s': true } }] : [];
methodResponses.push([method, { list: [...found, ...fresh], notFound: [] }, tag]);
} else if (method === 'CalendarEvent/set') {
const created = args.create ? { 'new-task': { id: 'new-id' } } : {};
const updated = Object.fromEntries(Object.keys((args.update as object) ?? {}).map((id) => [id, null]));
methodResponses.push([method, { created, updated, destroyed: args.destroy ?? [] }, tag]);
}
}
return jsonResponse({ methodResponses });
}));
});
it('lists tasks from shared accounts with namespaced ids and calendarIds', async () => {
const tasks = await client.getCalendarTasks();
const shared = tasks.find((t) => t.title === 'Theirs');
expect(shared?.id).toBe('acct-2:shared-1');
expect(shared?.calendarIds).toEqual({ 'acct-2:cal-s': true });
expect(tasks.find((t) => t.title === 'Mine')?.id).toBe('own-1');
});
it('moves a task into a shared calendar by creating it there and destroying the original', async () => {
const newId = await client.updateCalendarTask('own-1', { title: 'Mine', calendarIds: { 'acct-2:cal-s': true } });
const sets = sentCalls.filter((c) => c.method === 'CalendarEvent/set');
expect(sets).toHaveLength(2);
const [create, destroy] = sets;
expect(create.args.accountId).toBe('acct-2');
const payload = (create.args.create as Record<string, Record<string, unknown>>)['new-task'];
expect(payload.calendarIds).toEqual({ 'cal-s': true });
expect(payload.uid).toBe('uid-own');
expect(payload.locations).toEqual({ l1: { '@type': 'Location', name: 'Desk' } });
for (const key of ['id', 'created', 'isDraft', 'isOrigin']) expect(payload).not.toHaveProperty(key);
expect(destroy.args).toMatchObject({ accountId: 'acct-1', destroy: ['own-1'] });
expect(newId).toBe('acct-2:new-id');
});
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 } });
const sets = sentCalls.filter((c) => c.method === 'CalendarEvent/set');
expect(sets).toHaveLength(1);
expect(sets[0].args.accountId).toBe('acct-2');
expect(sets[0].args.update).toEqual({ 'shared-1': { progress: 'completed', calendarIds: { 'cal-s': true } } });
expect(id).toBe('acct-2:shared-1');
});
it('creates a task in a shared calendar in the owning account', async () => {
const created = await client.createCalendarTask({ title: 'New', calendarIds: { 'acct-2:cal-s': true } });
const set = sentCalls.find((c) => c.method === 'CalendarEvent/set');
expect(set?.args.accountId).toBe('acct-2');
expect((set?.args.create as Record<string, Record<string, unknown>>)['new-task'].calendarIds).toEqual({ 'cal-s': true });
expect(created.id).toBe('acct-2:new-id');
});
});
+2 -1
View File
@@ -1093,9 +1093,10 @@ export class DemoJMAPClient implements IJMAPClient {
return full;
}
async updateCalendarTask(taskId: string, updates: Partial<CalendarTask>): Promise<void> {
async updateCalendarTask(taskId: string, updates: Partial<CalendarTask>): Promise<string> {
const task = this.data.calendarTasks.find(t => t.id === taskId);
if (task) Object.assign(task, updates, { updated: new Date().toISOString() });
return taskId;
}
async deleteCalendarTask(taskId: string): Promise<void> {
+2 -1
View File
@@ -476,7 +476,8 @@ export interface IJMAPClient {
// ── Calendar Tasks ────────────────────────────────────────────
getCalendarTasks(calendarIds?: string[], targetAccountId?: string): Promise<CalendarTask[]>;
createCalendarTask(task: Partial<CalendarTask>, targetAccountId?: string): Promise<CalendarTask>;
updateCalendarTask(taskId: string, updates: Partial<CalendarTask>, targetAccountId?: string): Promise<void>;
/** Resolves to the task's id afterwards; it changes when the task moves to another account. */
updateCalendarTask(taskId: string, updates: Partial<CalendarTask>, targetAccountId?: string): Promise<string>;
deleteCalendarTask(taskId: string, targetAccountId?: string): Promise<void>;
// ── Sharing (RFC 9670 Principals) ─────────────────────────────
+119 -7
View File
@@ -7577,8 +7577,72 @@ export class JMAPClient implements IJMAPClient {
* CalendarEvent/query (which returns events *and* tasks), fetch in
* /get-sized batches, and detect tasks client-side.
*/
// Store ids for shared calendars and their events and tasks are namespaced
// `accountId:id`; bare ids belong to the primary calendars account. JMAP ids
// never contain ':'.
private splitCalendarObjectId(id: string): { accountId: string; id: string } {
const sep = id.indexOf(':');
if (sep < 0) return { accountId: this.getCalendarsAccountId(), id };
return { accountId: id.slice(0, sep), id: id.slice(sep + 1) };
}
private namespaceCalendarTask(task: CalendarTask, accountId: string): CalendarTask {
if (accountId === this.getCalendarsAccountId()) return task;
return {
...task,
id: `${accountId}:${task.id}`,
calendarIds: Object.fromEntries(
Object.entries(task.calendarIds || {}).map(([calId, v]) => [`${accountId}:${calId}`, v])
),
originalId: task.id,
accountId,
isShared: true,
};
}
// Maps the (possibly namespaced) calendarIds of a task to the account that
// owns those calendars and their real ids there.
private resolveTaskCalendarIds(calendarIds: Record<string, boolean>): { accountId: string; calendarIds: Record<string, boolean> } {
let accountId: string | undefined;
const real: Record<string, boolean> = {};
for (const [key, value] of Object.entries(calendarIds)) {
const parts = this.splitCalendarObjectId(key);
if (accountId && parts.accountId !== accountId) {
throw new Error("A task can only belong to calendars of one account");
}
accountId = parts.accountId;
real[parts.id] = value;
}
return { accountId: accountId ?? this.getCalendarsAccountId(), calendarIds: real };
}
/**
* Without `targetAccountId`, collects tasks from every calendar account, not
* just the primary one, so tasks in calendars shared with the user show up
* too. Shared tasks come back namespaced like shared events. (#1204)
*/
async getCalendarTasks(calendarIds?: string[], targetAccountId?: string): Promise<CalendarTask[]> {
const accountId = targetAccountId || this.getCalendarsAccountId();
if (targetAccountId) return this.getAccountCalendarTasks(calendarIds, targetAccountId);
const primaryId = this.getCalendarsAccountId();
const tasks: CalendarTask[] = [];
for (const accountId of this.getCalendarCapableAccountIds()) {
if (accountId !== primaryId && this.calendarAccessDenied.has(accountId)) continue;
let accountCalendarIds: string[] | undefined;
if (calendarIds && calendarIds.length > 0) {
accountCalendarIds = calendarIds
.map((id) => this.splitCalendarObjectId(id))
.filter((parts) => parts.accountId === accountId)
.map((parts) => parts.id);
if (accountCalendarIds.length === 0) continue;
}
const accountTasks = await this.getAccountCalendarTasks(accountCalendarIds, accountId);
tasks.push(...accountTasks.map((task) => this.namespaceCalendarTask(task, accountId)));
}
return tasks;
}
private async getAccountCalendarTasks(calendarIds: string[] | undefined, accountId: string): Promise<CalendarTask[]> {
debug.group('CalendarTask/fetch', 'tasks');
debug.log('tasks', 'CalendarTask/fetch start', { accountId, calendarIds: calendarIds || 'all' });
@@ -7670,8 +7734,15 @@ export class JMAPClient implements IJMAPClient {
}
async createCalendarTask(task: Partial<CalendarTask>, targetAccountId?: string): Promise<CalendarTask> {
const accountId = targetAccountId || this.getCalendarsAccountId();
const { '@type': _type, progressUpdated: _pu, ...taskData } = task as Partial<CalendarTask> & { progressUpdated?: string | null };
let accountId = targetAccountId || this.getCalendarsAccountId();
const { '@type': _type, progressUpdated: _pu, id: _id, originalId: _oi, accountId: _ai, isShared: _is, ...taskData } = task as Partial<CalendarTask> & { progressUpdated?: string | null };
if (!targetAccountId && taskData.calendarIds && Object.keys(taskData.calendarIds).length > 0) {
// A task created in a shared calendar has to be created in the account
// that owns the calendar. (#1204)
const resolved = this.resolveTaskCalendarIds(taskData.calendarIds);
accountId = resolved.accountId;
taskData.calendarIds = resolved.calendarIds;
}
const cleanTask = { ...taskData, '@type': 'Task' };
debug.group('CalendarTask/create', 'tasks');
@@ -7723,7 +7794,10 @@ export class JMAPClient implements IJMAPClient {
const notFound = getResponse.methodResponses[0][1].notFound || [];
debug.log('calendar', 'CalendarTask/create get response', { found: list.length, notFound });
if (list[0]) {
const created = normalizeCalendarTask({ ...list[0], '@type': 'Task' as const } as CalendarTask);
const created = this.namespaceCalendarTask(
normalizeCalendarTask({ ...list[0], '@type': 'Task' as const } as CalendarTask),
accountId,
);
debug.log('tasks', 'CalendarTask/create final task object', {
id: created.id,
uid: created.uid,
@@ -7745,12 +7819,50 @@ export class JMAPClient implements IJMAPClient {
throw new Error("Failed to fetch created task");
}
async updateCalendarTask(taskId: string, updates: Partial<CalendarTask>, targetAccountId?: string): Promise<void> {
await this.updateCalendarEvent(taskId, updates as unknown as Partial<CalendarEvent>, false, targetAccountId);
/**
* Updates a task and resolves to its id afterwards. Moving a task into a
* calendar of another account (a shared calendar) recreates it there, so
* the id changes. (#1204)
*/
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 };
if (dest.accountId !== source.accountId) {
return this.moveCalendarTaskToAccount(source, dest.accountId, patch);
}
}
await this.updateCalendarEvent(source.id, patch as unknown as Partial<CalendarEvent>, false, source.accountId);
return taskId;
}
// JMAP has no cross-account CalendarEvent move: create the task in the
// destination account, then destroy the original. Creating first means a
// failed create leaves the task where it was.
private async moveCalendarTaskToAccount(
source: { accountId: string; id: string },
destAccountId: string,
patch: Partial<CalendarTask>,
): Promise<string> {
const response = await this.request([
["CalendarEvent/get", { accountId: source.accountId, ids: [source.id] }, "0"]
], this.calendarUsing());
const [method, result] = response.methodResponses?.[0] ?? [];
const original = method === "CalendarEvent/get" ? result.list?.[0] : undefined;
if (!original) throw new Error("Task not found");
// Drop what the server sets itself; keep the uid so it stays the same task.
const { id: _id, created: _cr, updated: _up, sequence: _sq, isDraft: _idr, isOrigin: _io, baseEventId: _be, blobId: _bl, utcStart: _us, utcEnd: _ue, ...rest } = original as Record<string, unknown>;
const created = await this.createCalendarTask({ ...rest, ...patch } as Partial<CalendarTask>, destAccountId);
await this.deleteCalendarEvent(source.id, false, source.accountId);
return created.id;
}
async deleteCalendarTask(taskId: string, targetAccountId?: string): Promise<void> {
await this.deleteCalendarEvent(taskId, false, targetAccountId);
const target = targetAccountId ? { accountId: targetAccountId, id: taskId } : this.splitCalendarObjectId(taskId);
await this.deleteCalendarEvent(target.id, false, target.accountId);
}
// ─── JMAP FileNode methods (draft-ietf-jmap-filenode) ───
+5
View File
@@ -830,6 +830,11 @@ export interface CalendarTask {
recurrenceRules: CalendarRecurrenceRule[] | null;
alerts: Record<string, CalendarEventAlert> | null;
relatedTo: Record<string, CalendarRelation> | null;
// Client-only, set on tasks from a shared calendar account. Their `id` and
// `calendarIds` are namespaced `accountId:id` like shared events.
originalId?: string;
accountId?: string;
isShared?: boolean;
}
/**
+11
View File
@@ -121,6 +121,17 @@ describe('task-store', () => {
expect(updated?.progress).toBe('needs-action');
});
it('reloads the list when a move to a shared calendar gives the task a new id (#1204)', async () => {
useTaskStore.setState({ tasks: [makeTask({ id: 't5' })], selectedTaskId: 't5' });
(mockClient.updateCalendarTask as ReturnType<typeof vi.fn>).mockResolvedValue('acct-2:new');
(mockClient.getCalendarTasks as ReturnType<typeof vi.fn>).mockResolvedValue([makeTask({ id: 'acct-2:new' })]);
await useTaskStore.getState().updateTask(mockClient as IJMAPClient, 't5', { calendarIds: { 'acct-2:cal-s': true } });
expect(useTaskStore.getState().tasks.map((t) => t.id)).toEqual(['acct-2:new']);
expect(useTaskStore.getState().selectedTaskId).toBe('acct-2:new');
});
it('deletes a task and resets selectedTaskId if selected', async () => {
const task = makeTask({ id: 't4' });
useTaskStore.setState({ tasks: [task], selectedTaskId: 't4' });
+8 -1
View File
@@ -65,7 +65,14 @@ export const useTaskStore = create<TaskStore>((set, get) => ({
},
updateTask: async (client, id, updates) => {
await client.updateCalendarTask(id, updates);
const newId = await client.updateCalendarTask(id, updates);
if (newId && newId !== id) {
// Moved into a calendar of another account: the server recreated it
// under a new id, so reload rather than patch the old entry. (#1204)
set({ selectedTaskId: get().selectedTaskId === id ? newId : get().selectedTaskId });
await get().fetchTasks(client);
return;
}
set({
tasks: get().tasks.map(t => t.id === id ? { ...t, ...updates, updated: new Date().toISOString() } : t),
});