417 lines
12 KiB
PHP
417 lines
12 KiB
PHP
<?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\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 <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
|
|
* @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);
|
|
}
|
|
}
|