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 <noreply@anthropic.com>
This commit is contained in:
mARTin 2026-03-08 01:46:40 +01:00
parent aaa3705511
commit fe41c153f7
4 changed files with 172 additions and 6 deletions

View File

@ -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": {

View File

@ -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 },

View File

@ -7011,16 +7011,16 @@ function TaskItem({
<span
className="flex-shrink-0"
title={`Synced with ${task.externalProvider === "outlook" ? "Microsoft" : task.externalProvider === "google" ? "Google" : task.externalProvider === "apple" ? "Apple" : task.externalProvider}`}
style={{ display: "inline-flex", alignItems: "center", opacity: 0.6, marginRight: "2px" }}
style={{ display: "inline-flex", alignItems: "center", opacity: 0.55, marginLeft: "4px" }}
>
{task.externalProvider === "google" ? (
<FontAwesomeIcon icon={faGoogle} style={{ width: 11, height: 11, color: "#4285F4" }} />
<FontAwesomeIcon icon={faGoogle} style={{ width: 12, height: 12, color: "#4285F4" }} />
) : task.externalProvider === "outlook" ? (
<FontAwesomeIcon icon={faMicrosoft} style={{ width: 11, height: 11, color: "#0078D4" }} />
<FontAwesomeIcon icon={faMicrosoft} style={{ width: 12, height: 12, color: "#0078D4" }} />
) : task.externalProvider === "apple" ? (
<FontAwesomeIcon icon={faApple} style={{ width: 11, height: 11, color: "#555" }} />
<FontAwesomeIcon icon={faApple} style={{ width: 12, height: 12, color: "#555" }} />
) : task.externalProvider === "synology" ? (
<FontAwesomeIcon icon={faServer} style={{ width: 11, height: 11, color: "#007AFF" }} />
<FontAwesomeIcon icon={faServer} style={{ width: 12, height: 12, color: "#007AFF" }} />
) : null}
</span>
)}

View File

@ -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,
},
});
};