import * as fs from 'fs/promises';
import * as path from 'path';
export interface Task {
id: string;
text: string;
completed: boolean;
originalLine: string;
description: string[];
}
export type BoardItem =
| { type: 'task'; task: Task }
| { type: 'line'; content: string };
export interface Column {
name: string;
items: BoardItem[];
}
export interface Board {
filepath: string;
frontmatter: string[];
columns: Column[];
settings: string[];
}
export class KanbanManager {
constructor(private vaultPath: string) {}
private async readFileLines(filepath: string): Promise<string[]> {
const content = await fs.readFile(filepath, 'utf-8');
return content.split('\n');
}
async listBoards(): Promise<string[]> {
const files = await fs.readdir(this.vaultPath);
const boards: string[] = [];
for (const file of files) {
if (file.endsWith('.md')) {
const content = await fs.readFile(path.join(this.vaultPath, file), 'utf-8');
if (content.includes('kanban-plugin: board')) {
boards.push(file);
}
}
}
return boards;
}
async parseBoard(filename: string): Promise<Board> {
const filepath = path.join(this.vaultPath, filename);
const lines = await this.readFileLines(filepath);
const board: Board = {
filepath,
frontmatter: [],
columns: [],
settings: []
};
let section: 'frontmatter' | 'content' | 'settings' = 'frontmatter';
let currentColumn: Column | null = null;
let inFrontmatter = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Handle Frontmatter
if (i === 0 && line.trim() === '---') {
inFrontmatter = true;
board.frontmatter.push(line);
continue;
}
if (inFrontmatter) {
board.frontmatter.push(line);
if (line.trim() === '---') {
inFrontmatter = false;
section = 'content';
}
continue;
}
// Handle Settings Block
if (line.trim().startsWith('%% kanban:settings')) {
section = 'settings';
board.settings.push(line);
continue;
}
if (section === 'settings') {
board.settings.push(line);
continue;
}
// Handle Content
const headerMatch = line.match(/^##\s+(.*)$/);
if (headerMatch) {
let name = headerMatch[1].trim();
if (name === '') name = 'Untitled';
currentColumn = {
name: name,
items: []
};
board.columns.push(currentColumn);
continue;
}
// Handle Empty Headers (sometimes happens)
if (line.trim() === '##') {
currentColumn = {
name: 'Untitled',
items: []
};
board.columns.push(currentColumn);
continue;
}
if (currentColumn) {
const indentMatch = line.match(/^(\s*)/);
const indent = indentMatch ? indentMatch[1].length : 0;
const taskMatch = line.match(/^(\s*)-\s+\[([ xX])\]\s+(.*)$/); // Checkbox task
const simpleListMatch = line.match(/^(\s*)-\s+(.*)$/); // Simple bullet
// Identify if this line is part of the previous task's description
const lastItem = currentColumn.items[currentColumn.items.length - 1];
// Logic:
// If we have a last item that is a TASK, and the current line is indented MORE than that task,
// treat it as description/content for that task.
// NOTE: We need to know the indentation of the last task.
// We'll store indentation in the Task object implicitly via originalLine or we calculate it.
let isDescription = false;
if (lastItem && lastItem.type === 'task') {
const lastTaskIndentMatch = lastItem.task.originalLine.match(/^(\s*)/);
const lastTaskIndent = lastTaskIndentMatch ? lastTaskIndentMatch[1].length : 0;
if (indent > lastTaskIndent) {
isDescription = true;
}
}
if (isDescription && lastItem && lastItem.type === 'task') {
lastItem.task.description.push(line);
} else if (taskMatch) {
currentColumn.items.push({
type: 'task',
task: {
id: Buffer.from(`${currentColumn.name}-${currentColumn.items.length}`).toString('base64'),
text: taskMatch[3].trim(),
completed: taskMatch[2] !== ' ',
originalLine: line,
description: []
}
});
} else if (simpleListMatch) {
// Treat simple bullets as incomplete tasks
currentColumn.items.push({
type: 'task',
task: {
id: Buffer.from(`${currentColumn.name}-${currentColumn.items.length}`).toString('base64'),
text: simpleListMatch[2].trim(),
completed: false,
originalLine: line,
description: []
}
});
} else {
// Check if it's an empty line inside a task?
// Usually empty lines break the list, but maybe we want to keep them if indented?
// For now, empty lines at top level are just lines.
// If it's a text line (no bullet), it might be description too if indented.
if (isDescription && lastItem && lastItem.type === 'task') {
lastItem.task.description.push(line);
} else {
currentColumn.items.push({ type: 'line', content: line });
}
}
}
}
return board;
}
async saveBoard(board: Board): Promise<void> {
const lines: string[] = [];
// Frontmatter
lines.push(...board.frontmatter);
// Columns and Tasks
for (const column of board.columns) {
lines.push(''); // Ensure spacing before header (Obsidian likes this)
lines.push(`## ${column.name}`);
for (const item of column.items) {
if (item.type === 'task') {
const checkbox = item.task.completed ? '[x]' : '[ ]';
lines.push(`- ${checkbox} ${item.task.text}`);
if (item.task.description && item.task.description.length > 0) {
lines.push(...item.task.description);
}
} else {
lines.push(item.content);
}
}
}
lines.push('');
// Settings
lines.push(...board.settings);
await fs.writeFile(board.filepath, lines.join('\n'));
}
async createTaskNote(title: string, options: { description?: string, labels?: string[], acceptance_criteria?: string[] }): Promise<string> {
const sanitizedTitle = title.replace(/[^a-zA-Z0-9\s-]/g, '').trim();
// Use timestamp to ensure uniqueness
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `${sanitizedTitle} - ${timestamp}.md`;
const filepath = path.join(this.vaultPath, filename);
const lines: string[] = [];
// Frontmatter
lines.push('---');
if (options.labels && options.labels.length > 0) {
lines.push(`tags: [${options.labels.join(', ')}]`);
}
lines.push('---');
lines.push('');
// Title
lines.push(`# ${title}`);
lines.push('');
// Description
if (options.description) {
lines.push(options.description);
lines.push('');
}
// Acceptance Criteria
if (options.acceptance_criteria && options.acceptance_criteria.length > 0) {
lines.push('## Acceptance Criteria');
for (const criteria of options.acceptance_criteria) {
lines.push(`- [ ] ${criteria}`);
}
lines.push('');
}
await fs.writeFile(filepath, lines.join('\n'));
return filename;
}
async addTask(filename: string, columnName: string, taskText: string, options?: { description?: string, labels?: string[], acceptance_criteria?: string[], create_note?: boolean }): Promise<void> {
const board = await this.parseBoard(filename);
const column = board.columns.find(c => c.name === columnName);
if (!column) {
throw new Error(`Column '${columnName}' not found`);
}
let fullTaskText = taskText;
const descriptionLines: string[] = [];
// Check if we should create a linked note
if (options?.create_note) {
// Create the note file
const noteFilename = await this.createTaskNote(taskText, options);
// Link to the note in the task text (remove extension for obsidian link)
const noteName = noteFilename.replace('.md', '');
// Use piped link to keep the board text clean: [[Filename|Task Text]]
fullTaskText = `[[${noteName}|${taskText}]]`;
} else {
// ... (Old Logic)
const indent = ' '; // Standard indent
// Handle Tags/Labels in title
if (options?.labels && options.labels.length > 0) {
const tags = options.labels.map(l => l.startsWith('#') ? l : `#${l}`).join(' ');
fullTaskText = `${fullTaskText} ${tags}`;
}
// Handle Description
if (options?.description) {
const lines = options.description.split('\n');
lines.forEach(line => descriptionLines.push(`${indent}${line}`));
}
// Handle Acceptance Criteria
if (options?.acceptance_criteria && options.acceptance_criteria.length > 0) {
descriptionLines.push(`${indent}**Acceptance Criteria:**`);
options.acceptance_criteria.forEach(criteria => {
descriptionLines.push(`${indent}- [ ] ${criteria}`);
});
}
}
column.items.push({
type: 'task',
task: {
id: 'new',
text: fullTaskText,
completed: false,
originalLine: `- [ ] ${fullTaskText}`,
description: descriptionLines
}
});
await this.saveBoard(board);
}
async moveTaskByText(filename: string, taskText: string, fromColumn: string, toColumn: string): Promise<void> {
const board = await this.parseBoard(filename);
const sourceCol = board.columns.find(c => c.name === fromColumn);
const targetCol = board.columns.find(c => c.name === toColumn);
if (!sourceCol || !targetCol) {
throw new Error("Column not found");
}
const itemIndex = sourceCol.items.findIndex(item => item.type === 'task' && item.task.text === taskText);
if (itemIndex === -1) {
throw new Error("Task not found in source column");
}
const [item] = sourceCol.items.splice(itemIndex, 1);
targetCol.items.push(item);
await this.saveBoard(board);
}
async createBoard(filename: string, columns: string[]): Promise<void> {
const filepath = path.join(this.vaultPath, filename);
const board: Board = {
filepath,
frontmatter: [
'---',
'',
'kanban-plugin: board',
'',
'---'
],
columns: columns.map(name => ({
name,
items: []
})),
settings: [
'%% kanban:settings',
'```',
'{"kanban-plugin":"board","list-collapse":' + JSON.stringify(columns.map(() => false)) + '}',
'```',
'%%'
]
};
await this.saveBoard(board);
}
}