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:
parent
aaa3705511
commit
fe41c153f7
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "my-weekly-todo-list",
|
"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",
|
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -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
|
// Update lastSyncedAt
|
||||||
await prisma.task.update({
|
await prisma.task.update({
|
||||||
where: { id: taskId },
|
where: { id: taskId },
|
||||||
|
|||||||
@ -7011,16 +7011,16 @@ function TaskItem({
|
|||||||
<span
|
<span
|
||||||
className="flex-shrink-0"
|
className="flex-shrink-0"
|
||||||
title={`Synced with ${task.externalProvider === "outlook" ? "Microsoft" : task.externalProvider === "google" ? "Google" : task.externalProvider === "apple" ? "Apple" : task.externalProvider}`}
|
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" ? (
|
{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" ? (
|
) : 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" ? (
|
) : 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" ? (
|
) : task.externalProvider === "synology" ? (
|
||||||
<FontAwesomeIcon icon={faServer} style={{ width: 11, height: 11, color: "#007AFF" }} />
|
<FontAwesomeIcon icon={faServer} style={{ width: 12, height: 12, color: "#007AFF" }} />
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -173,3 +173,139 @@ export const fetchSynologyTasks = async (serverUrl: string, username: string, pa
|
|||||||
throw error;
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user