feature: Initial commit

This commit is contained in:
Keith Solomon
2026-03-21 22:35:20 -05:00
commit adabacc48d
55 changed files with 6803 additions and 0 deletions
+393
View File
@@ -0,0 +1,393 @@
<?php
/**
* Task Repository
*
* Manages persisting and retrieving tasks from markdown files.
*
* PHP version 8.2+
*
* @category Repository
* @package IronKanban\Repository
* @author Keith Solomon <keith@keithsolomon.net>
* @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\FrontMatterDocument;
use IronKanban\Markdown\FrontMatterParser;
use IronKanban\Support\FileLock;
use IronKanban\Support\Paths;
use RuntimeException;
/**
* TaskRepository manages task persistence and retrieval
*
* @category Repository
* @package IronKanban\Repository
* @author Keith Solomon <keith@keithsolomon.net>
* @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
*/
public function __construct(
private readonly Paths $paths,
private readonly FrontMatterParser $frontMatterParser
) {
}
/**
* 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;
}
$tasks[] = $this->_mapFileToTask($projectId, $filePath);
}
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 {
$filePath = $this->paths->getTaskFilePath($projectId, $taskId);
if (!is_file($filePath)) {
throw new RuntimeException("Task not found: {$taskId}");
}
return $this->_mapFileToTask($projectId, $filePath);
}
/**
* 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->id);
$lockPath = $filePath . '.lock';
FileLock::run(
$lockPath, function () use ($task, $filePath): void {
$document = $this->_mapTaskToDocument($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->_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->_generateTaskId();
$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 (isset($data['status'])) {
$meta['status'] = (string) $data['status'];
}
if (isset($data['statusNote'])) {
$meta['status_note'] = (string) $data['statusNote'];
}
$task = new Task(
id: $taskId,
title: $title,
projectId: $projectId,
column: $column,
order: $order,
completed: $completed,
priority: $priority,
isActive: $isActive,
body: $body,
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 {
$filePath = $this->paths->getTaskFilePath($projectId, $taskId);
$lockPath = $filePath . '.lock';
if (!is_file($filePath)) {
throw new RuntimeException("Task not found: {$taskId}");
}
FileLock::run(
$lockPath, function () use ($filePath): void {
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);
}
}