My-Weekly-ToDo-List/src/lib/synology-tasks.ts
mARTin 2ec09fdcca feat: full Synology bidirectional sync and fix someday list delete button
- Add createSynologyTask: new tasks in synced lists push to Synology CalDAV
- Wire Synology create into POST /api/tasks when somedayList is synced
- Fix someday list title input using width:100% instead of flex:1, which
  pushed the delete button off-screen making lists undeletable
- Slightly improved provider icon styling

v1.16.1

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 02:00:55 +01:00

365 lines
13 KiB
TypeScript

import { DAVClient } from 'tsdav';
import ICAL from 'ical.js';
export interface SynologyReminderList {
id: string; // URL
title: string;
color?: string;
}
/**
* Create a configured DAV client for Synology Tasks
*/
const createClient = (serverUrl: string, username: string, password: string) => {
let url = serverUrl.replace(/\/$/, '');
if (!url.includes('/caldav')) {
url = `${url}/caldav/${username}`;
}
return new DAVClient({
serverUrl: url,
credentials: {
username,
password,
},
authMethod: 'Basic',
defaultAccountType: 'caldav',
});
};
/**
* Fetch all reminder lists (VTODO collections) from Synology
*/
export const fetchSynologyReminderLists = async (serverUrl: string, username: string, password: string): Promise<SynologyReminderList[]> => {
try {
const client = createClient(serverUrl, username, password);
await client.login();
const calendars = await client.fetchCalendars();
// Filter to VTODO collections (Reminders)
const reminderLists = calendars.filter(cal => {
const components: string[] = (cal as any).components || [];
return components.includes('VTODO');
});
return reminderLists.map(cal => ({
id: cal.url,
title: (cal.displayName as string) || 'Untitled List',
color: (cal as any).calendarColor,
}));
} catch (error) {
console.error('Error fetching Synology reminder lists:', error);
throw error;
}
};
/**
* Create a new reminder list (collection)
*/
export const createSynologyReminderList = async (serverUrl: string, username: string, password: string, title: string): Promise<SynologyReminderList> => {
try {
console.log('[SYNOLOGY] Creating reminder list:', title, 'at', serverUrl);
const client = createClient(serverUrl, username, password);
await client.login();
const calendars = await client.fetchCalendars();
if (calendars.length === 0) {
console.error('[SYNOLOGY] No calendars found to derive base URL');
throw new Error('No existing Synology calendars found to derive base URL');
}
// Deriving the base URL for new collections
const firstCalUrl = calendars[0].url;
const urlParts = firstCalUrl.split('/');
if (firstCalUrl.endsWith('/')) urlParts.pop();
urlParts.pop();
const baseWebDavUrl = urlParts.join('/') + '/';
const newId = crypto.randomUUID();
const newCalendarUrl = `${baseWebDavUrl}${newId}/`;
console.log('[SYNOLOGY] Proposed new calendar URL:', newCalendarUrl);
// Create the calendar collection with explicit namespaces if possible
// Synology requires specific properties to recognize it as a Task list
await (client as any).makeCalendar({
url: newCalendarUrl,
props: {
'displayname': title,
'calendar-description': 'Created by My Weekly ToDo List',
'calendar-color': '#007AFF',
'supported-calendar-component-set': {
'comp': { _attributes: { name: 'VTODO' } }
}
}
});
console.log('[SYNOLOGY] List created successfully');
return {
id: newCalendarUrl,
title: title,
};
} catch (error) {
console.error('[SYNOLOGY] Error creating reminder list:', error);
throw error;
}
};
/**
* Create a new task (VTODO) in a Synology list via CalDAV
*/
export const createSynologyTask = async (
serverUrl: string, username: string, password: string,
listId: string, task: { title: string; notes?: string; due?: string | null }
): Promise<{ id: 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 uid = crypto.randomUUID();
const now = ICAL.Time.now();
const vcalendar = new ICAL.Component(['vcalendar', [], []]);
vcalendar.updatePropertyWithValue('prodid', '-//My Weekly ToDo List//EN');
vcalendar.updatePropertyWithValue('version', '2.0');
const vtodo = new ICAL.Component('vtodo');
vtodo.updatePropertyWithValue('uid', uid);
vtodo.updatePropertyWithValue('summary', task.title);
vtodo.updatePropertyWithValue('created', now);
vtodo.updatePropertyWithValue('dtstamp', now);
vtodo.updatePropertyWithValue('status', 'NEEDS-ACTION');
if (task.notes) {
vtodo.updatePropertyWithValue('description', task.notes);
}
if (task.due) {
const dueTime = ICAL.Time.fromJSDate(new Date(task.due), false);
vtodo.updatePropertyWithValue('due', dueTime);
}
vcalendar.addSubcomponent(vtodo);
const calendarUrl = targetCalendar.url.replace(/\/$/, '');
const objectUrl = `${calendarUrl}/${uid}.ics`;
await client.createCalendarObject({
calendar: targetCalendar,
filename: `${uid}.ics`,
iCalString: vcalendar.toString(),
});
return { id: `synology::${uid}` };
};
/**
* Fetch tasks (VTODOs) from a specific Synology list
*/
export const fetchSynologyTasks = async (serverUrl: string, username: string, password: string, listId: string) => {
try {
const client = createClient(serverUrl, username, password);
await client.login();
const calendars = await client.fetchCalendars();
// Match by URL path only (ignoring host/port differences that can arise
// when the stored URL was saved before the port was included in the server URL)
const getPath = (url: string) => {
try { return new URL(url).pathname; } catch { return url; }
};
const targetPath = getPath(listId);
const targetCalendar = calendars.find(c => getPath(c.url) === targetPath);
if (!targetCalendar) {
throw new Error(`List not found: ${listId}`);
}
// Use VTODO filter to ensure we get tasks, not just events
const objects = await client.fetchCalendarObjects({
calendar: targetCalendar,
filters: [{
'comp-filter': {
_attributes: { name: 'VCALENDAR' },
'comp-filter': {
_attributes: { name: 'VTODO' },
},
},
}],
});
const parsedTasks: any[] = [];
objects.forEach((obj) => {
const data = (obj as any).data;
if (!data) return;
try {
const jcalData = ICAL.parse(data);
const comp = new ICAL.Component(jcalData);
const vtodos = comp.getAllSubcomponents('vtodo');
vtodos.forEach((vtodo: any) => {
const status = vtodo.getFirstPropertyValue('status') || 'NEEDS-ACTION';
const isCompleted = status === 'COMPLETED';
const dueProp = vtodo.getFirstProperty('due');
parsedTasks.push({
id: `synology::${vtodo.getFirstPropertyValue('uid')}`,
title: vtodo.getFirstPropertyValue('summary') || 'Untitled Task',
notes: vtodo.getFirstPropertyValue('description') || '',
status: isCompleted ? 'completed' : 'needsAction',
due: dueProp ? dueProp.getFirstValue().toJSDate() : null,
});
});
} catch (err) {
console.error('[SYNOLOGY TASKS] Error parsing VTODO:', err);
}
});
return parsedTasks;
} catch (error) {
console.error('[SYNOLOGY TASKS] Error fetching tasks:', 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,
},
});
};