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