From fe41c153f7bad457449e5cf19dd8e60c517c4e8f Mon Sep 17 00:00:00 2001 From: mARTin Date: Sun, 8 Mar 2026 01:46:40 +0100 Subject: [PATCH] feat: Synology bidirectional sync (push updates back to CalDAV) Added updateSynologyTask and deleteSynologyTask functions that modify VTODOs on the Synology CalDAV server. Wired into the sync PATCH handler so completing/editing/deleting a Synology-synced task pushes changes back. Also slightly increased provider icon size for better visibility. v1.16.0 Co-Authored-By: Claude Opus 4.6 --- package.json | 2 +- src/app/api/tasks/sync/route.ts | 30 +++++++ src/components/WeeklyView.tsx | 10 +-- src/lib/synology-tasks.ts | 136 ++++++++++++++++++++++++++++++++ 4 files changed, 172 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 8938ab5..89339c5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-weekly-todo-list", - "version": "1.15.10", + "version": "1.16.0", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view", "main": "index.js", "scripts": { diff --git a/src/app/api/tasks/sync/route.ts b/src/app/api/tasks/sync/route.ts index 32ed8f0..ce5983c 100644 --- a/src/app/api/tasks/sync/route.ts +++ b/src/app/api/tasks/sync/route.ts @@ -501,6 +501,36 @@ export async function PATCH(req: NextRequest) { } } + if (task.externalProvider === 'synology' && task.externalListId) { + const synoConnection = await prisma.calendarConnection.findFirst({ + where: { userId: task.userId, provider: 'synology' } + }); + + if (synoConnection?.accessToken && synoConnection?.refreshToken) { + const [synoUsername, synoPassword] = synoConnection.accessToken.split(':'); + const synoServerUrl = synoConnection.refreshToken; + + if (synoUsername && synoPassword && synoServerUrl) { + const { updateSynologyTask, deleteSynologyTask } = await import('@/lib/synology-tasks'); + + if (action === 'delete') { + await deleteSynologyTask(synoServerUrl, synoUsername, synoPassword, task.externalListId, task.externalId); + } else { + const updates: { title?: string; notes?: string; completed?: boolean; due?: string | null } = {}; + if (title !== undefined) updates.title = title; + if (notes !== undefined) updates.notes = notes; + if (completed !== undefined) updates.completed = completed; + if (scheduledDate !== undefined) { + updates.due = scheduledDate ? new Date(scheduledDate).toISOString() : null; + } + if (Object.keys(updates).length > 0) { + await updateSynologyTask(synoServerUrl, synoUsername, synoPassword, task.externalListId, task.externalId, updates); + } + } + } + } + } + // Update lastSyncedAt await prisma.task.update({ where: { id: taskId }, diff --git a/src/components/WeeklyView.tsx b/src/components/WeeklyView.tsx index 536ca16..c81305f 100644 --- a/src/components/WeeklyView.tsx +++ b/src/components/WeeklyView.tsx @@ -7011,16 +7011,16 @@ function TaskItem({ {task.externalProvider === "google" ? ( - + ) : task.externalProvider === "outlook" ? ( - + ) : task.externalProvider === "apple" ? ( - + ) : task.externalProvider === "synology" ? ( - + ) : null} )} diff --git a/src/lib/synology-tasks.ts b/src/lib/synology-tasks.ts index 8f4deca..886cfde 100644 --- a/src/lib/synology-tasks.ts +++ b/src/lib/synology-tasks.ts @@ -173,3 +173,139 @@ export const fetchSynologyTasks = async (serverUrl: string, username: string, pa throw error; } }; + +/** + * Update a task (VTODO) on Synology via CalDAV + */ +export const updateSynologyTask = async ( + serverUrl: string, username: string, password: string, + listId: string, taskUid: string, + updates: { title?: string; notes?: string; completed?: boolean; due?: string | null } +) => { + const client = createClient(serverUrl, username, password); + await client.login(); + + const calendars = await client.fetchCalendars(); + const getPath = (url: string) => { + try { return new URL(url).pathname; } catch { return url; } + }; + const targetCalendar = calendars.find(c => getPath(c.url) === getPath(listId)); + if (!targetCalendar) throw new Error(`List not found: ${listId}`); + + // Fetch all objects to find the one matching our UID + const objects = await client.fetchCalendarObjects({ + calendar: targetCalendar, + filters: [{ + 'comp-filter': { + _attributes: { name: 'VCALENDAR' }, + 'comp-filter': { _attributes: { name: 'VTODO' } }, + }, + }], + }); + + const cleanUid = taskUid.replace(/^synology::/, ''); + const matchObj = objects.find((obj) => { + const data = (obj as any).data; + if (!data) return false; + try { + const jcal = ICAL.parse(data); + const comp = new ICAL.Component(jcal); + const vtodo = comp.getFirstSubcomponent('vtodo'); + return vtodo?.getFirstPropertyValue('uid') === cleanUid; + } catch { return false; } + }); + + if (!matchObj) throw new Error(`Task not found: ${cleanUid}`); + + // Parse and modify the VTODO + const jcal = ICAL.parse((matchObj as any).data); + const comp = new ICAL.Component(jcal); + const vtodo = comp.getFirstSubcomponent('vtodo')!; + + if (updates.title !== undefined) { + vtodo.updatePropertyWithValue('summary', updates.title); + } + if (updates.notes !== undefined) { + vtodo.updatePropertyWithValue('description', updates.notes); + } + if (updates.completed !== undefined) { + if (updates.completed) { + vtodo.updatePropertyWithValue('status', 'COMPLETED'); + vtodo.updatePropertyWithValue('percent-complete', 100); + const now = ICAL.Time.now(); + vtodo.updatePropertyWithValue('completed', now); + } else { + vtodo.updatePropertyWithValue('status', 'NEEDS-ACTION'); + vtodo.removeProperty('percent-complete'); + vtodo.removeProperty('completed'); + } + } + if (updates.due !== undefined) { + if (updates.due) { + const dueTime = ICAL.Time.fromJSDate(new Date(updates.due), false); + vtodo.updatePropertyWithValue('due', dueTime); + } else { + vtodo.removeProperty('due'); + } + } + + // Update last-modified + vtodo.updatePropertyWithValue('last-modified', ICAL.Time.now()); + + await client.updateCalendarObject({ + calendarObject: { + url: (matchObj as any).url, + data: comp.toString(), + etag: (matchObj as any).etag, + }, + }); +}; + +/** + * Delete a task (VTODO) from Synology via CalDAV + */ +export const deleteSynologyTask = async ( + serverUrl: string, username: string, password: string, + listId: string, taskUid: string, +) => { + const client = createClient(serverUrl, username, password); + await client.login(); + + const calendars = await client.fetchCalendars(); + const getPath = (url: string) => { + try { return new URL(url).pathname; } catch { return url; } + }; + const targetCalendar = calendars.find(c => getPath(c.url) === getPath(listId)); + if (!targetCalendar) throw new Error(`List not found: ${listId}`); + + const objects = await client.fetchCalendarObjects({ + calendar: targetCalendar, + filters: [{ + 'comp-filter': { + _attributes: { name: 'VCALENDAR' }, + 'comp-filter': { _attributes: { name: 'VTODO' } }, + }, + }], + }); + + const cleanUid = taskUid.replace(/^synology::/, ''); + const matchObj = objects.find((obj) => { + const data = (obj as any).data; + if (!data) return false; + try { + const jcal = ICAL.parse(data); + const comp = new ICAL.Component(jcal); + const vtodo = comp.getFirstSubcomponent('vtodo'); + return vtodo?.getFirstPropertyValue('uid') === cleanUid; + } catch { return false; } + }); + + if (!matchObj) throw new Error(`Task not found: ${cleanUid}`); + + await client.deleteCalendarObject({ + calendarObject: { + url: (matchObj as any).url, + etag: (matchObj as any).etag, + }, + }); +};