* @license Unlicense https://unlicense.org * @link https://git.keithsolomon.net/keith/IronKanban */ declare(strict_types=1); namespace IronKanban\Repository; use IronKanban\Domain\Task; use IronKanban\Markdown\FrontMatterParser; use IronKanban\Support\FileLock; use IronKanban\Support\IdGenerator; use IronKanban\Support\Paths; use IronKanban\Support\TaskMapper; use RuntimeException; /** * TaskRepository manages task persistence and retrieval * * @category Repository * @package IronKanban\Repository * @author Keith Solomon * @license Unlicense https://unlicense.org * @link https://git.keithsolomon.net/keith/IronKanban */ class TaskRepository { /** * Constructor for TaskRepository * * @param Paths $paths The paths helper * @param FrontMatterParser $frontMatterParser The front matter parser * @param TaskMapper $taskMapper The task mapper * @param IdGenerator $idGenerator The ID generator */ public function __construct( private readonly Paths $paths, private readonly FrontMatterParser $frontMatterParser, private readonly TaskMapper $taskMapper, private readonly IdGenerator $idGenerator ) { } /** * Get all tasks for a project * * @param string $projectId The project identifier * * @return Task[] Array of tasks */ public function getAll(string $projectId): array { $tasksPath = $this->paths->getTasksPath($projectId); $files = glob($tasksPath . DIRECTORY_SEPARATOR . '*.md'); if ($files === false) { return []; } $tasks = []; foreach ($files as $filePath) { if (!is_file($filePath)) { continue; } $fileName = basename($filePath); $document = $this->frontMatterParser->parseFile($filePath); $tasks[] = $this->taskMapper->fromDocument( $projectId, $fileName, $document ); } usort( $tasks, function (Task $a, Task $b): int { if ($a->column === $b->column) { return $a->order <=> $b->order; } return strcmp($a->column, $b->column); } ); return $tasks; } /** * Get a specific task by ID * * @param string $projectId The project identifier * @param string $taskId The task identifier * * @return Task The task * * @throws RuntimeException If task not found */ public function get(string $projectId, string $taskId): Task { foreach ($this->getAll($projectId) as $task) { if ($task->id === $taskId) { return $task; } } throw new RuntimeException("Task not found: {$taskId}"); } /** * Save a task * * @param Task $task The task to save * * @return void */ public function save(Task $task): void { $this->paths->ensureTasksPath($task->projectId); $filePath = $this->paths->getTaskFilePath($task->projectId, $task->fileName); $lockPath = $filePath . '.lock'; FileLock::run( $lockPath, function () use ($task, $filePath): void { $document = $this->taskMapper->toDocument($task); $contents = $this->frontMatterParser->dump($document); $this->_atomicWrite($filePath, $contents); } ); } /** * Create a new task * * @param array $data The task data * * @return Task The created task * * @throws RuntimeException If required fields are missing */ public function create(array $data): Task { $projectId = $this->_requireString($data, 'projectId'); $title = $this->_requireString($data, 'title'); $body = (string) ($data['body'] ?? ''); $column = $this->taskMapper->normalizeColumn( (string) ($data['column'] ?? 'backlog') ); $priority = (string) ($data['priority'] ?? 'normal'); $completed = (bool) ($data['completed'] ?? false); $isActive = (bool) ($data['isActive'] ?? true); $order = isset($data['order']) ? (int) $data['order'] : 100; $now = gmdate('c'); $taskId = $this->idGenerator->generateTaskId(); $fileName = $this->idGenerator->generateTaskFileName($taskId); $meta = [ 'id' => $taskId, 'type' => 'task', 'title' => $title, 'project_id' => $projectId, 'column' => $column, 'order' => $order, 'completed' => $completed, 'priority' => $priority, 'is_active' => $isActive, 'created' => $now, 'updated' => $now, 'tags' => [], ]; if (array_key_exists('status', $data)) { $meta['status'] = $data['status']; } if (array_key_exists('statusNote', $data)) { $meta['status_note'] = $data['statusNote']; } if (array_key_exists('section', $data)) { $meta['section'] = $data['section']; } $task = new Task( id: $taskId, title: $title, projectId: $projectId, column: $column, order: $order, completed: $completed, priority: $priority, isActive: $isActive, body: $body, fileName: $fileName, meta: $meta ); $this->save($task); return $task; } /** * Delete a task * * @param string $projectId The project identifier * @param string $taskId The task identifier * * @return void * * @throws RuntimeException If task not found */ public function delete(string $projectId, string $taskId): void { $task = $this->get($projectId, $taskId); $filePath = $this->paths->getTaskFilePath($projectId, $task->fileName); $lockPath = $filePath . '.lock'; FileLock::run( $lockPath, function () use ($filePath): void { if (!is_file($filePath)) { throw new RuntimeException("Task file not found: {$filePath}"); } if (!unlink($filePath)) { throw new RuntimeException( "Unable to delete task file: {$filePath}" ); } } ); } /** * Map a markdown file to a task * * @param string $projectId The project identifier * @param string $filePath The file path to the task * * @return Task The mapped task */ private function _mapFileToTask(string $projectId, string $filePath): Task { $document = $this->frontMatterParser->parseFile($filePath); $meta = $document->meta; $id = (string) ($meta['id'] ?? pathinfo($filePath, PATHINFO_FILENAME)); $title = (string) ($meta['title'] ?? $id); $column = isset($meta['column']) ? $this->_normalizeColumn((string) $meta['column']) : $this->_mapLegacySectionToColumn((string) ($meta['section'] ?? 'backlog')); $order = isset($meta['order']) ? (int) $meta['order'] : 100; $completed = (bool) ($meta['completed'] ?? false); $priority = (string) ($meta['priority'] ?? 'normal'); $isActive = (bool) ($meta['is_active'] ?? true); return new Task( id: $id, title: $title, projectId: (string) ($meta['project_id'] ?? $projectId), column: $column, order: $order, completed: $completed, priority: $priority, isActive: $isActive, body: $document->body, meta: $meta ); } /** * Map a task to a front matter document * * @param Task $task The task to map * * @return FrontMatterDocument The mapped document */ private function _mapTaskToDocument(Task $task): FrontMatterDocument { $meta = $task->meta; $meta['id'] = $task->id; $meta['type'] = 'task'; $meta['title'] = $task->title; $meta['project_id'] = $task->projectId; $meta['column'] = $task->column; $meta['order'] = $task->order; $meta['completed'] = $task->completed; $meta['priority'] = $task->priority; $meta['is_active'] = $task->isActive; if (!isset($meta['created']) || (string) $meta['created'] === '') { $meta['created'] = gmdate('c'); } $meta['updated'] = gmdate('c'); return new FrontMatterDocument($meta, $task->body); } /** * Atomically write contents to a file * * @param string $filePath The file path * @param string $contents The contents to write * * @return void * * @throws RuntimeException If write operation fails */ private function _atomicWrite(string $filePath, string $contents): void { $directory = dirname($filePath); $tempPath = tempnam($directory, 'ikb_'); if ($tempPath === false) { throw new RuntimeException( "Unable to create temp file in: {$directory}" ); } try { $bytesWritten = file_put_contents($tempPath, $contents); if ($bytesWritten === false) { throw new RuntimeException( "Unable to write temp file: {$tempPath}" ); } if (!rename($tempPath, $filePath)) { throw new RuntimeException( "Unable to move temp file into place: {$filePath}" ); } } finally { if (is_file($tempPath)) { @unlink($tempPath); } } } /** * Generate a unique task ID * * @return string The generated task ID */ private function _generateTaskId(): string { $randID = substr(bin2hex(random_bytes(3)), 0, 6); $taskID = 'task-' . gmdate('Ymd-His') . '-' . $randID; return $taskID; } /** * Require a string field from data array * * @param array $data The data array * @param string $key The field key * * @return string The string value * * @throws RuntimeException If field is missing or empty */ private function _requireString(array $data, string $key): string { $value = trim((string) ($data[$key] ?? '')); if ($value === '') { throw new RuntimeException("Missing required field: {$key}"); } return $value; } /** * Normalize a column name * * @param string $column The column name to normalize * * @return string The normalized column name */ private function _normalizeColumn(string $column): string { $column = trim(strtolower($column)); if ($column === '') { return 'backlog'; } $column = preg_replace('/[^a-z0-9]+/', '-', $column) ?? 'backlog'; $column = trim($column, '-'); return $column !== '' ? $column : 'backlog'; } /** * Map legacy section to column name * * @param string $section The legacy section name * * @return string The modern column name */ private function _mapLegacySectionToColumn(string $section): string { return $this->_normalizeColumn($section); } }