✨feature: Initial commit
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user