diff --git a/locales/rn/en.json b/locales/rn/en.json index 9f7c5c44..d6c30c6d 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -80,6 +80,7 @@ "tasks": { "add_placeholder": "Add a task…", "add_time": "Add time", + "delete_error": "Failed to delete task", "update_error": "Failed to update task" } }, diff --git a/src/api/__tests__/calendar.test.ts b/src/api/__tests__/calendar.test.ts index eeeeed9d..bca46cf1 100644 --- a/src/api/__tests__/calendar.test.ts +++ b/src/api/__tests__/calendar.test.ts @@ -16,6 +16,8 @@ import { createEvent, updateEvent, deleteEvents, + batchCreateEvents, + clearCalendarEvents, findEventsByUid, toLocalDateTime, } from '../calendar'; @@ -378,5 +380,62 @@ describe('calendar operations', () => { const call = mockRequest.mock.calls[0][0][0]; expect(call[1].destroy).toEqual(['ev1']); }); + + it('throws when the server refuses to destroy (B24)', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['CalendarEvent/set', { + destroyed: [], + notDestroyed: { ev1: { type: 'forbidden', description: 'read-only calendar' } }, + }, '0']], + }); + + await expect(deleteEvents(['ev1'])).rejects.toThrow(/destroy event ev1.*read-only calendar/); + }); + + it('throws on a method-level error', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['error', { type: 'accountNotFound' }, '0']], + }); + + await expect(deleteEvents(['ev1'])).rejects.toThrow('accountNotFound'); + }); + }); + + describe('batchCreateEvents', () => { + it('returns how many were created when only some were refused', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['CalendarEvent/set', { + created: { 'evt-0': { id: 'a' } }, + notCreated: { 'evt-1': { type: 'invalidProperties' } }, + }, '0']], + }); + + expect(await batchCreateEvents([{ title: 'A' }, { title: 'B' }], 'cal-1')).toBe(1); + }); + + it('throws when the server refused every event (B24)', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['CalendarEvent/set', { + notCreated: { 'evt-0': { type: 'invalidProperties', properties: ['participants'] } }, + }, '0']], + }); + + await expect(batchCreateEvents([{ title: 'A' }], 'cal-1')).rejects.toThrow(/invalidProperties/); + }); + }); + + describe('clearCalendarEvents', () => { + it('throws when the server refuses to destroy the events (B24)', async () => { + mockRequest + .mockResolvedValueOnce({ methodResponses: [['CalendarEvent/query', { ids: ['ev1'] }, '0']] }) + .mockResolvedValueOnce({ + methodResponses: [['CalendarEvent/get', { list: [{ id: 'ev1', calendarIds: { 'cal-1': true } }] }, '0']], + }) + .mockResolvedValueOnce({ + methodResponses: [['CalendarEvent/set', { notDestroyed: { ev1: { type: 'forbidden' } } }, '0']], + }); + + await expect(clearCalendarEvents('cal-1')).rejects.toThrow(/forbidden/); + }); }); }); diff --git a/src/api/calendar.ts b/src/api/calendar.ts index db2366b0..2aa76137 100644 --- a/src/api/calendar.ts +++ b/src/api/calendar.ts @@ -1,6 +1,7 @@ import { jmapClient } from './jmap-client'; import { CAPABILITIES } from './types'; import type { Calendar, CalendarEvent, CalendarRights } from './types'; +import { assertSetResult } from './jmap-result'; import { getEffectiveTimeZone } from '../lib/calendar-timezone'; import { SCAN_PROPERTIES, type ScannedCalendarObject } from '../lib/calendar-component-detection'; @@ -447,7 +448,9 @@ export async function createEvent( return normalizeRecurrenceProperties({ ...payload, ...created } as CalendarEvent); } -// Batch-create many events in one CalendarEvent/set. Returns the number created. +// Batch-create many events in one CalendarEvent/set. Returns the number +// created; throws when the server refused every one of them, so a refusal +// doesn't read as "nothing new to import". export async function batchCreateEvents( events: Partial[], calendarId: string, @@ -465,8 +468,13 @@ export async function batchCreateEvents( [['CalendarEvent/set', { accountId, create }, '0']], USING, ); - const result = methodResult<{ created?: Record }>(res); - return result.created ? Object.keys(result.created).length : 0; + const result = methodResult<{ + created?: Record; + notCreated?: Record; + }>(res); + const count = result.created ? Object.keys(result.created).length : 0; + if (count === 0) assertSetResult(result, undefined, 'event'); + return count; } export async function updateEvent( @@ -493,10 +501,11 @@ export async function deleteEvents( targetAccountId?: string, ): Promise { const accountId = targetAccountId || jmapClient.accountId; - await jmapClient.request( + const res = await jmapClient.request( [['CalendarEvent/set', setArgs(accountId, { destroy: ids }, sendSchedulingMessages), '0']], USING, ); + assertSetResult(methodResult(res), ids, 'event'); } // RSVP to an invitation: patch the participant's participationStatus via a JSON @@ -722,7 +731,9 @@ export async function clearCalendarEvents( [['CalendarEvent/set', { accountId, destroy: toDestroy }, '0']], USING, ); - removed += (methodResult<{ destroyed?: string[] }>(res).destroyed ?? []).length; + const result = methodResult<{ destroyed?: string[] }>(res); + assertSetResult(result, toDestroy, 'event'); + removed += (result.destroyed ?? []).length; } if (toUnlink.length > 0) { const update: Record = {}; @@ -731,7 +742,9 @@ export async function clearCalendarEvents( [['CalendarEvent/set', { accountId, update }, '0']], USING, ); - removed += Object.keys(methodResult<{ updated?: Record }>(res).updated ?? {}).length; + const result = methodResult<{ updated?: Record }>(res); + assertSetResult(result, Object.keys(update), 'event'); + removed += Object.keys(result.updated ?? {}).length; } if (toDestroy.length === 0 && toUnlink.length === 0) break; if (ids.length < 1000) break; diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index 302833e8..5ebe0998 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -436,15 +436,25 @@ export default function CalendarScreen() { openEditDirect(event); }, [openEditDirect, isReadOnlyEvent]); + const reportError = React.useCallback( + (err: unknown) => { + Alert.alert( + t('calendar.notifications.event_error', 'Something went wrong'), + err instanceof Error ? err.message : undefined, + ); + }, + [t], + ); + const handleDeleteFromDetail = React.useCallback((event: CalendarEvent) => { if (isReadOnlyEvent(event)) { setDetailEvent(null); return; } setDetailEvent(null); if (isRecurringSeriesMember(event)) { setPendingAction({ kind: 'delete', event }); } else { - void deleteEvent(event.id); + deleteEvent(event.id).catch(reportError); } - }, [deleteEvent, isReadOnlyEvent]); + }, [deleteEvent, isReadOnlyEvent, reportError]); // "This and following": end the master at the occurrence and hand back the // master plus its untouched rules so the caller can start a new series (or @@ -588,9 +598,9 @@ export default function CalendarScreen() { setPendingAction({ kind: 'delete', event }); return; } - await deleteEvent(event.id); + await deleteEvent(event.id).catch(reportError); }, - [deleteEvent], + [deleteEvent, reportError], ); const onRefresh = React.useCallback(async () => { @@ -602,16 +612,6 @@ export default function CalendarScreen() { } }, [refresh]); - const reportError = React.useCallback( - (err: unknown) => { - Alert.alert( - t('calendar.notifications.event_error', 'Something went wrong'), - err instanceof Error ? err.message : undefined, - ); - }, - [t], - ); - // The store flips the checkbox optimistically and reverts it when the // server refuses; say so instead of leaving the user guessing. const handleToggleTask = React.useCallback( @@ -626,6 +626,18 @@ export default function CalendarScreen() { [toggleTaskComplete, t], ); + const handleDeleteTask = React.useCallback( + (id: string) => { + deleteTask(id).catch((err: unknown) => { + Alert.alert( + t('calendar.tasks.delete_error', 'Failed to delete task'), + err instanceof Error ? err.message : undefined, + ); + }); + }, + [deleteTask, t], + ); + // Clone the event one day later and open it in the editor (webmail's // handleDuplicateFromDetail). const handleDuplicateFromDetail = React.useCallback( @@ -960,7 +972,7 @@ export default function CalendarScreen() { onCreate={createTask} onUpdate={updateTask} onToggle={handleToggleTask} - onDelete={deleteTask} + onDelete={handleDeleteTask} /> ()( } const prepared = toCreate.map(prepareImportedEvent); let count = 0; - // Batch in chunks of 50 to avoid oversized requests. - for (let i = 0; i < prepared.length; i += 50) { - count += await apiBatchCreateEvents(prepared.slice(i, i + 50), serverCalendarId, accountId); + try { + // Batch in chunks of 50 to avoid oversized requests. + for (let i = 0; i < prepared.length; i += 50) { + count += await apiBatchCreateEvents(prepared.slice(i, i + 50), serverCalendarId, accountId); + } + } finally { + // Also when a later chunk was refused: show what did get in. + if (count > 0 || linked > 0) await get().refresh(); } - if (count > 0 || linked > 0) await get().refresh(); return count + linked; }, @@ -674,9 +678,12 @@ export const useCalendarStore = create()( clearCalendarEvents: async (id) => { const cal = get().calendars.find((c) => c.id === id); - const removed = await apiClearCalendarEvents(cal?.originalId || id, cal?.accountId); - await get().refresh(); - return removed; + try { + return await apiClearCalendarEvents(cal?.originalId || id, cal?.accountId); + } finally { + // Also after a refused batch: earlier batches may have gone through. + await get().refresh(); + } }, shareCalendar: async (id, principalId, rights) => {