Die Dateien schalteten die Typpruefung komplett ab und blieben deshalb auch von ignoreBuildErrors: false unberuehrt -- eine Luecke im gerade erst geschlossenen Netz. - calendar-cache.ts brauchte die Ausnahme gar nicht: ohne sie null Fehler. - microsoft-todo.ts hatte vier Faelle zirkulaerer Typinferenz (url -> data -> response -> url), aufgeloest durch explizite Annotationen. - outlook-calendar.ts verbarg einen moeglichen Laufzeitfehler: Der Code streute response!-Assertions, eine Stelle war vergessen. Statt noch einer Assertion prueft er jetzt echt, ob nach drei Wiederholversuchen ueberhaupt eine Antwort vorliegt. Ausserdem Block I in TODO.md: Hervorhebung des aktuellen Tages im Spaltenkopf und die Symbole in Kalenderterminen nach oben rechts, beide mit Fundstelle in globals.css. Verifiziert: Typecheck 0 Fehler, npm run build Exit 0. v1.118.2 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
505 lines
15 KiB
TypeScript
505 lines
15 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
|
|
const GRAPH_ENDPOINT = 'https://graph.microsoft.com/v1.0';
|
|
|
|
export interface MicrosoftTodoList {
|
|
id: string;
|
|
displayName: string;
|
|
isOwner: boolean;
|
|
isShared: boolean;
|
|
wellknownListName: string;
|
|
}
|
|
|
|
export interface MicrosoftChecklistItem {
|
|
id: string;
|
|
displayName: string;
|
|
isChecked: boolean;
|
|
createdDateTime: string;
|
|
}
|
|
|
|
export interface MicrosoftTodoTask {
|
|
id: string;
|
|
title: string;
|
|
body?: {
|
|
content: string;
|
|
contentType: string;
|
|
};
|
|
status: 'notStarted' | 'inProgress' | 'completed' | 'waitingOnOthers' | 'deferred';
|
|
importance: 'low' | 'normal' | 'high';
|
|
dueDateTime?: {
|
|
dateTime: string;
|
|
timeZone: string;
|
|
};
|
|
completedDateTime?: {
|
|
dateTime: string;
|
|
timeZone: string;
|
|
};
|
|
createdDateTime: string;
|
|
lastModifiedDateTime: string;
|
|
checklistItems?: MicrosoftChecklistItem[];
|
|
}
|
|
|
|
/**
|
|
* Create a new Microsoft To-Do task list.
|
|
*/
|
|
export const createMsTodoList = async (accessToken: string, title: string): Promise<MicrosoftTodoList> => {
|
|
try {
|
|
const response = await fetch(`${GRAPH_ENDPOINT}/me/todo/lists`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${accessToken}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ displayName: title })
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
throw new Error(error.error?.message || 'Failed to create Microsoft To-Do list');
|
|
}
|
|
|
|
return await response.json();
|
|
} catch (error) {
|
|
console.error('Error creating Microsoft To-Do list:', error);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Fetch all Microsoft To-Do task lists.
|
|
*/
|
|
export const fetchMsTodoLists = async (accessToken: string): Promise<MicrosoftTodoList[]> => {
|
|
const response = await fetch(`${GRAPH_ENDPOINT}/me/todo/lists`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${accessToken}`,
|
|
'Content-Type': 'application/json'
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const err = await response.text();
|
|
console.error('Error fetching Microsoft To-Do lists:', err);
|
|
throw new Error(`Failed to fetch To-Do lists: ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
return (data.value || []) as MicrosoftTodoList[];
|
|
};
|
|
|
|
/**
|
|
* Delete a Microsoft To-Do task list.
|
|
*/
|
|
export const deleteMsTodoList = async (
|
|
accessToken: string,
|
|
listId: string
|
|
): Promise<void> => {
|
|
const response = await fetch(
|
|
`${GRAPH_ENDPOINT}/me/todo/lists/${encodeURIComponent(listId)}`,
|
|
{
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Authorization': `Bearer ${accessToken}`
|
|
}
|
|
}
|
|
);
|
|
|
|
// 404/410 mean it's already gone — treat as success
|
|
if (response.status === 404 || response.status === 410) return;
|
|
|
|
if (!response.ok) {
|
|
const err = await response.text();
|
|
throw new Error(`Failed to delete To-Do list: ${err}`);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Fetch active tasks from a specific To-Do list (for import).
|
|
*/
|
|
export const fetchMsTodoTasks = async (
|
|
accessToken: string,
|
|
listId: string
|
|
): Promise<MicrosoftTodoTask[]> => {
|
|
const params = new URLSearchParams({
|
|
'$filter': "status ne 'completed'",
|
|
'$top': '100'
|
|
});
|
|
|
|
const response = await fetch(
|
|
`${GRAPH_ENDPOINT}/me/todo/lists/${encodeURIComponent(listId)}/tasks?${params.toString()}`,
|
|
{
|
|
headers: {
|
|
'Authorization': `Bearer ${accessToken}`,
|
|
'Content-Type': 'application/json'
|
|
}
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const err = await response.text();
|
|
console.error(`Error fetching tasks from list ${listId}:`, err);
|
|
throw new Error(`Failed to fetch To-Do tasks: ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
return (data.value || []) as MicrosoftTodoTask[];
|
|
};
|
|
|
|
/**
|
|
* Fetch all tasks (including completed) with optional modified-since filter (for sync).
|
|
*/
|
|
export const fetchMsTodoTasksForSync = async (
|
|
accessToken: string,
|
|
listId: string,
|
|
modifiedSince?: string
|
|
): Promise<MicrosoftTodoTask[]> => {
|
|
const allTasks: MicrosoftTodoTask[] = [];
|
|
let url: string | null = (() => {
|
|
const params = new URLSearchParams({ '$top': '100' });
|
|
if (modifiedSince) {
|
|
params.set('$filter', `lastModifiedDateTime gt ${modifiedSince}`);
|
|
}
|
|
return `${GRAPH_ENDPOINT}/me/todo/lists/${encodeURIComponent(listId)}/tasks?${params.toString()}`;
|
|
})();
|
|
|
|
while (url) {
|
|
const response: Response = await fetch(url, {
|
|
headers: {
|
|
'Authorization': `Bearer ${accessToken}`,
|
|
'Content-Type': 'application/json'
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const err = await response.text();
|
|
console.error(`Error fetching sync tasks from list ${listId}:`, err);
|
|
throw new Error(`Failed to fetch To-Do tasks for sync: ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
const data: any = await response.json();
|
|
allTasks.push(...((data.value || []) as MicrosoftTodoTask[]));
|
|
url = data['@odata.nextLink'] || null;
|
|
}
|
|
|
|
return allTasks;
|
|
};
|
|
|
|
/**
|
|
* Microsoft Graph delta sync: returns only the tasks that changed (created,
|
|
* updated, or deleted) since the previous delta token, plus a fresh
|
|
* `deltaToken` to use on the next call. Pass `null` to start a new chain.
|
|
*
|
|
* Deleted tasks come back with an `@removed` field; we surface them via
|
|
* the optional `_deleted` boolean on the task object.
|
|
*
|
|
* Cost: typically a few hundred bytes per call when nothing changed.
|
|
*/
|
|
export const fetchMsTodoTasksDelta = async (
|
|
accessToken: string,
|
|
listId: string,
|
|
previousDeltaToken: string | null,
|
|
): Promise<{ tasks: (MicrosoftTodoTask & { _deleted?: boolean })[]; deltaToken: string | null }> => {
|
|
const tasks: (MicrosoftTodoTask & { _deleted?: boolean })[] = [];
|
|
let url: string | null;
|
|
if (previousDeltaToken) {
|
|
// Resume from where we left off
|
|
url = `${GRAPH_ENDPOINT}/me/todo/lists/${encodeURIComponent(listId)}/tasks/delta?$deltatoken=${encodeURIComponent(previousDeltaToken)}`;
|
|
} else {
|
|
// Initial sync — Graph will paginate via @odata.nextLink, then return @odata.deltaLink
|
|
url = `${GRAPH_ENDPOINT}/me/todo/lists/${encodeURIComponent(listId)}/tasks/delta`;
|
|
}
|
|
|
|
let deltaToken: string | null = null;
|
|
|
|
while (url) {
|
|
const response: Response = await fetch(url, {
|
|
headers: {
|
|
'Authorization': `Bearer ${accessToken}`,
|
|
'Content-Type': 'application/json'
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const err = await response.text();
|
|
// 410 Gone = delta token expired (>30 days). Caller should retry with null.
|
|
if (response.status === 410) {
|
|
throw Object.assign(new Error('Delta token expired'), { code: 'DELTA_EXPIRED' });
|
|
}
|
|
console.error(`Error fetching delta from list ${listId}:`, err);
|
|
throw new Error(`Failed to fetch To-Do delta: ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
const data: any = await response.json();
|
|
for (const item of (data.value || [])) {
|
|
if (item['@removed']) {
|
|
tasks.push({ ...item, _deleted: true } as any);
|
|
} else {
|
|
tasks.push(item as MicrosoftTodoTask);
|
|
}
|
|
}
|
|
|
|
const nextLink: string | undefined = data['@odata.nextLink'];
|
|
const deltaLink: string | undefined = data['@odata.deltaLink'];
|
|
|
|
if (deltaLink) {
|
|
// Final page — extract the new delta token
|
|
const match = deltaLink.match(/[?&]\$deltatoken=([^&]+)/);
|
|
deltaToken = match ? decodeURIComponent(match[1]) : null;
|
|
url = null;
|
|
} else if (nextLink) {
|
|
url = nextLink;
|
|
} else {
|
|
url = null;
|
|
}
|
|
}
|
|
|
|
return { tasks, deltaToken };
|
|
};
|
|
|
|
/**
|
|
* Create a new Microsoft To-Do task in a list.
|
|
*/
|
|
export const createMsTodoTask = async (
|
|
accessToken: string,
|
|
listId: string,
|
|
taskData: {
|
|
title: string;
|
|
body?: string;
|
|
dueDateTime?: string;
|
|
importance?: 'low' | 'normal' | 'high';
|
|
}
|
|
): Promise<MicrosoftTodoTask> => {
|
|
const requestBody: any = { title: taskData.title };
|
|
|
|
if (taskData.body) {
|
|
requestBody.body = { content: taskData.body, contentType: 'text' };
|
|
}
|
|
if (taskData.dueDateTime) {
|
|
requestBody.dueDateTime = {
|
|
dateTime: new Date(taskData.dueDateTime).toISOString(),
|
|
timeZone: 'UTC'
|
|
};
|
|
}
|
|
if (taskData.importance) {
|
|
requestBody.importance = taskData.importance;
|
|
}
|
|
|
|
const response = await fetch(
|
|
`${GRAPH_ENDPOINT}/me/todo/lists/${encodeURIComponent(listId)}/tasks`,
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${accessToken}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(requestBody)
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const err = await response.text();
|
|
throw new Error(`Failed to create To-Do task: ${err}`);
|
|
}
|
|
|
|
return response.json();
|
|
};
|
|
|
|
/**
|
|
* Update a Microsoft To-Do task.
|
|
*/
|
|
export const updateMsTodoTask = async (
|
|
accessToken: string,
|
|
listId: string,
|
|
taskId: string,
|
|
updates: {
|
|
title?: string;
|
|
body?: string;
|
|
status?: 'notStarted' | 'completed';
|
|
dueDateTime?: string | null;
|
|
importance?: 'low' | 'normal' | 'high';
|
|
}
|
|
): Promise<MicrosoftTodoTask> => {
|
|
const body: any = {};
|
|
|
|
if (updates.title !== undefined) body.title = updates.title;
|
|
if (updates.body !== undefined) body.body = { content: updates.body, contentType: 'text' };
|
|
if (updates.status !== undefined) {
|
|
body.status = updates.status;
|
|
if (updates.status === 'completed') {
|
|
body.completedDateTime = {
|
|
dateTime: new Date().toISOString(),
|
|
timeZone: 'UTC'
|
|
};
|
|
} else {
|
|
body.completedDateTime = null;
|
|
}
|
|
}
|
|
if (updates.dueDateTime !== undefined) {
|
|
body.dueDateTime = updates.dueDateTime
|
|
? { dateTime: new Date(updates.dueDateTime).toISOString(), timeZone: 'UTC' }
|
|
: null;
|
|
}
|
|
if (updates.importance !== undefined) body.importance = updates.importance;
|
|
|
|
const response = await fetch(
|
|
`${GRAPH_ENDPOINT}/me/todo/lists/${encodeURIComponent(listId)}/tasks/${encodeURIComponent(taskId)}`,
|
|
{
|
|
method: 'PATCH',
|
|
headers: {
|
|
'Authorization': `Bearer ${accessToken}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(body)
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const err = await response.text();
|
|
throw new Error(`Failed to update To-Do task: ${err}`);
|
|
}
|
|
|
|
return response.json();
|
|
};
|
|
|
|
/**
|
|
* Delete a Microsoft To-Do task.
|
|
*/
|
|
export const deleteMsTodoTask = async (
|
|
accessToken: string,
|
|
listId: string,
|
|
taskId: string
|
|
): Promise<void> => {
|
|
const response = await fetch(
|
|
`${GRAPH_ENDPOINT}/me/todo/lists/${encodeURIComponent(listId)}/tasks/${encodeURIComponent(taskId)}`,
|
|
{
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Authorization': `Bearer ${accessToken}`
|
|
}
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const err = await response.text();
|
|
throw new Error(`Failed to delete To-Do task: ${err}`);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Check if a Microsoft To-Do task status maps to completed.
|
|
*/
|
|
export const isMsTodoTaskCompleted = (status: MicrosoftTodoTask['status']): boolean => {
|
|
return status === 'completed';
|
|
};
|
|
|
|
/**
|
|
* Fetch checklist items (sub-tasks) for a Microsoft To-Do task.
|
|
*/
|
|
export const fetchMsChecklistItems = async (
|
|
accessToken: string,
|
|
listId: string,
|
|
taskId: string
|
|
): Promise<MicrosoftChecklistItem[]> => {
|
|
const response = await fetch(
|
|
`${GRAPH_ENDPOINT}/me/todo/lists/${encodeURIComponent(listId)}/tasks/${encodeURIComponent(taskId)}/checklistItems`,
|
|
{
|
|
headers: {
|
|
'Authorization': `Bearer ${accessToken}`,
|
|
'Content-Type': 'application/json'
|
|
}
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const err = await response.text();
|
|
console.error(`Error fetching checklist items for task ${taskId}:`, err);
|
|
return [];
|
|
}
|
|
|
|
const data = await response.json();
|
|
return (data.value || []) as MicrosoftChecklistItem[];
|
|
};
|
|
|
|
/**
|
|
* Create a checklist item (sub-task) for a Microsoft To-Do task.
|
|
*/
|
|
export const createMsChecklistItem = async (
|
|
accessToken: string,
|
|
listId: string,
|
|
taskId: string,
|
|
displayName: string
|
|
): Promise<MicrosoftChecklistItem> => {
|
|
const response = await fetch(
|
|
`${GRAPH_ENDPOINT}/me/todo/lists/${encodeURIComponent(listId)}/tasks/${encodeURIComponent(taskId)}/checklistItems`,
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${accessToken}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ displayName })
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const err = await response.text();
|
|
throw new Error(`Failed to create checklist item: ${err}`);
|
|
}
|
|
|
|
return response.json();
|
|
};
|
|
|
|
/**
|
|
* Update a checklist item (sub-task) for a Microsoft To-Do task.
|
|
*/
|
|
export const updateMsChecklistItem = async (
|
|
accessToken: string,
|
|
listId: string,
|
|
taskId: string,
|
|
checklistItemId: string,
|
|
updates: { displayName?: string; isChecked?: boolean }
|
|
): Promise<MicrosoftChecklistItem> => {
|
|
const response = await fetch(
|
|
`${GRAPH_ENDPOINT}/me/todo/lists/${encodeURIComponent(listId)}/tasks/${encodeURIComponent(taskId)}/checklistItems/${encodeURIComponent(checklistItemId)}`,
|
|
{
|
|
method: 'PATCH',
|
|
headers: {
|
|
'Authorization': `Bearer ${accessToken}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(updates)
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const err = await response.text();
|
|
throw new Error(`Failed to update checklist item: ${err}`);
|
|
}
|
|
|
|
return response.json();
|
|
};
|
|
|
|
/**
|
|
* Delete a checklist item (sub-task) from a Microsoft To-Do task.
|
|
*/
|
|
export const deleteMsChecklistItem = async (
|
|
accessToken: string,
|
|
listId: string,
|
|
taskId: string,
|
|
checklistItemId: string
|
|
): Promise<void> => {
|
|
const response = await fetch(
|
|
`${GRAPH_ENDPOINT}/me/todo/lists/${encodeURIComponent(listId)}/tasks/${encodeURIComponent(taskId)}/checklistItems/${encodeURIComponent(checklistItemId)}`,
|
|
{
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Authorization': `Bearer ${accessToken}`
|
|
}
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const err = await response.text();
|
|
throw new Error(`Failed to delete checklist item: ${err}`);
|
|
}
|
|
};
|