- 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>
365 lines
13 KiB
TypeScript
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,
|
|
},
|
|
});
|
|
};
|