todoManager.ts•7.63 kB
import { readFile, writeFile, access } from 'fs/promises';
import { constants } from 'fs';
import { join } from 'path';
import { randomUUID } from 'crypto';
import type {
TodoItem,
AddTodoRequest,
UpdateTodoRequest,
DeleteTodoRequest,
ListTodosResponse,
} from './types.js';
export class TodoManager {
private todoFilePath: string;
constructor(customPath?: string) {
// Priority: custom path > environment variable > current working directory
if (customPath) {
// If customPath ends with .md, treat it as a file path, otherwise as a directory
if (customPath.endsWith('.md')) {
this.todoFilePath = customPath;
} else {
this.todoFilePath = join(customPath, 'todo.md');
}
} else {
const envPath = process.env.TODO_FILE_PATH;
if (envPath) {
this.todoFilePath = envPath;
} else {
this.todoFilePath = join(process.cwd(), 'todo.md');
}
}
}
private async fileExists(): Promise<boolean> {
try {
await access(this.todoFilePath, constants.F_OK);
return true;
} catch {
return false;
}
}
private parseTodoMarkdown(content: string): TodoItem[] {
const lines = content.split('\n');
const todos: TodoItem[] = [];
for (const line of lines) {
const trimmed = line.trim();
// Match markdown checkbox format: - [ ] text or - [x] text
const checkboxMatch = trimmed.match(/^-\s*\[([x\s])\]\s*(.+)$/i);
if (checkboxMatch) {
const [, checkbox, text] = checkboxMatch;
if (!checkbox || !text) continue;
const completed = checkbox.toLowerCase() === 'x';
// Extract ID from text if it exists (format: text <!-- id:uuid -->)
const idMatch = text.match(/^(.*?)\s*<!--\s*id:([a-f0-9-]+)\s*-->$/);
let todoText: string;
let id: string;
if (idMatch) {
todoText = idMatch[1]?.trim() || '';
id = idMatch[2] || randomUUID();
} else {
todoText = text.trim();
id = randomUUID();
}
todos.push({
id,
text: todoText,
completed,
createdAt: new Date(),
...(completed && { completedAt: new Date() }),
});
}
}
return todos;
}
private formatTodoMarkdown(todos: TodoItem[]): string {
const lines = todos.map((todo) => {
const checkbox = todo.completed ? '[x]' : '[ ]';
return `- ${checkbox} ${todo.text} <!-- id:${todo.id} -->`;
});
const header = '# Todo List\n\n';
const footer = '\n<!-- Generated by MCP Todo Server -->\n';
return header + lines.join('\n') + footer;
}
async listTodos(): Promise<ListTodosResponse> {
if (!(await this.fileExists())) {
return {
todos: [],
total: 0,
completed: 0,
pending: 0,
};
}
const content = await readFile(this.todoFilePath, 'utf-8');
const todos = this.parseTodoMarkdown(content);
const completed = todos.filter((todo) => todo.completed).length;
const pending = todos.length - completed;
return {
todos,
total: todos.length,
completed,
pending,
};
}
async addTodo(request: AddTodoRequest): Promise<TodoItem> {
const todos = (await this.listTodos()).todos;
const newTodo: TodoItem = {
id: randomUUID(),
text: request.text.trim(),
completed: false,
createdAt: new Date(),
};
todos.push(newTodo);
const markdown = this.formatTodoMarkdown(todos);
try {
await writeFile(this.todoFilePath, markdown, 'utf-8');
} catch (error) {
if (error instanceof Error && error.message.includes('EACCES')) {
throw new Error(
`Permission denied: Cannot write to ${this.todoFilePath}. Check file permissions or set TODO_FILE_PATH environment variable to a writable location.`
);
} else if (error instanceof Error && error.message.includes('EROFS')) {
throw new Error(
`Read-only file system: Cannot write to ${this.todoFilePath}. Set TODO_FILE_PATH environment variable to a writable location.`
);
}
throw error;
}
return newTodo;
}
async updateTodo(request: UpdateTodoRequest): Promise<TodoItem> {
const todos = (await this.listTodos()).todos;
const todoIndex = todos.findIndex((todo) => todo.id === request.id);
if (todoIndex === -1) {
throw new Error(`Todo with id ${request.id} not found`);
}
const todo = todos[todoIndex]!;
if (request.text !== undefined) {
todo.text = request.text.trim();
}
if (request.completed !== undefined) {
todo.completed = request.completed;
if (request.completed && !todo.completedAt) {
todo.completedAt = new Date();
} else if (!request.completed) {
delete todo.completedAt;
}
}
todos[todoIndex] = todo;
const markdown = this.formatTodoMarkdown(todos);
try {
await writeFile(this.todoFilePath, markdown, 'utf-8');
} catch (error) {
if (error instanceof Error && error.message.includes('EACCES')) {
throw new Error(
`Permission denied: Cannot write to ${this.todoFilePath}. Check file permissions or set TODO_FILE_PATH environment variable to a writable location.`
);
} else if (error instanceof Error && error.message.includes('EROFS')) {
throw new Error(
`Read-only file system: Cannot write to ${this.todoFilePath}. Set TODO_FILE_PATH environment variable to a writable location.`
);
}
throw error;
}
return todo;
}
async deleteTodo(request: DeleteTodoRequest): Promise<void> {
const todos = (await this.listTodos()).todos;
const todoIndex = todos.findIndex((todo) => todo.id === request.id);
if (todoIndex === -1) {
throw new Error(`Todo with id ${request.id} not found`);
}
todos.splice(todoIndex, 1);
const markdown = this.formatTodoMarkdown(todos);
try {
await writeFile(this.todoFilePath, markdown, 'utf-8');
} catch (error) {
if (error instanceof Error && error.message.includes('EACCES')) {
throw new Error(
`Permission denied: Cannot write to ${this.todoFilePath}. Check file permissions or set TODO_FILE_PATH environment variable to a writable location.`
);
} else if (error instanceof Error && error.message.includes('EROFS')) {
throw new Error(
`Read-only file system: Cannot write to ${this.todoFilePath}. Set TODO_FILE_PATH environment variable to a writable location.`
);
}
throw error;
}
}
async clearCompleted(): Promise<number> {
const todos = (await this.listTodos()).todos;
const completedCount = todos.filter((todo) => todo.completed).length;
const remainingTodos = todos.filter((todo) => !todo.completed);
const markdown = this.formatTodoMarkdown(remainingTodos);
try {
await writeFile(this.todoFilePath, markdown, 'utf-8');
} catch (error) {
if (error instanceof Error && error.message.includes('EACCES')) {
throw new Error(
`Permission denied: Cannot write to ${this.todoFilePath}. Check file permissions or set TODO_FILE_PATH environment variable to a writable location.`
);
} else if (error instanceof Error && error.message.includes('EROFS')) {
throw new Error(
`Read-only file system: Cannot write to ${this.todoFilePath}. Set TODO_FILE_PATH environment variable to a writable location.`
);
}
throw error;
}
return completedCount;
}
}