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",
|
||||
"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": {
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user