✨feature: Initial MVP
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
/node_modules/
|
||||||
|
/vendor/
|
||||||
|
/composer.lock
|
||||||
|
/storage/projects/*/.task.lock
|
||||||
|
/storage/projects/*/.board.lock
|
||||||
|
/storage/projects/*/.notes.lock
|
||||||
|
/storage/projects/*/.revision.lock
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":2,"defects":{"Tests\\Support\\FrontMatterTest::testDumpAndParseRoundTrip":7,"Tests\\Api\\BoardApiTest::testBoardStateEndpointReturnsProjectState":7,"Tests\\Api\\BoardApiTest::testCreateTaskEndpointRejectsMissingCsrfToken":7,"Tests\\Api\\BoardApiTest::testCreateTaskEndpointCreatesTaskWithValidCsrfToken":7,"Tests\\Api\\BoardApiTest::testCreateTaskEndpointReturnsConflictForStaleRevision":7,"Tests\\Api\\BoardApiTest::testSaveTaskEndpointUpdatesTaskFields":7},"times":{"Tests\\Domain\\BoardTest::testToArrayIncludesSerializedColumnsAndMeta":0,"Tests\\Support\\FrontMatterTest::testDumpAndParseRoundTrip":0,"Tests\\Support\\FrontMatterTest::testParseWithoutFrontMatterReturnsBodyOnly":0,"Tests\\Service\\BoardServiceTest::testGetBoardStateBuildsDefaultColumnsForNewProject":0.015,"Tests\\Service\\BoardServiceTest::testCreateTaskPersistsTaskInRequestedColumn":0.075,"Tests\\Service\\BoardServiceTest::testDeleteTaskMovesTaskToTrashByDefault":0.132,"Tests\\Service\\BoardServiceTest::testUpdateBoardMovesDeletedColumnTasksIntoTrash":0.153,"Tests\\Api\\BoardApiTest::testBoardStateEndpointReturnsProjectState":0.036,"Tests\\Api\\BoardApiTest::testCreateTaskEndpointRejectsMissingCsrfToken":0.004,"Tests\\Api\\BoardApiTest::testCreateTaskEndpointCreatesTaskWithValidCsrfToken":0.07,"Tests\\Api\\BoardApiTest::testCreateTaskEndpointReturnsConflictForStaleRevision":0.073,"Tests\\Api\\BoardApiTest::testMoveTaskEndpointMovesTaskToRequestedColumn":0.121,"Tests\\Api\\BoardApiTest::testDeleteTaskEndpointMovesTaskToTrash":0.107,"Tests\\Api\\BoardApiTest::testSaveNoteEndpointCreatesNote":0.061,"Tests\\Api\\BoardApiTest::testCreateColumnEndpointCreatesNewColumn":0.072,"Tests\\Api\\BoardApiTest::testSaveTaskEndpointUpdatesTaskFields":0.139,"Tests\\Api\\BoardApiTest::testUpdateBoardEndpointReplacesColumnDefinitions":0.074}}
|
||||||
Vendored
+16
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"workbench.colorCustomizations": {
|
||||||
|
"tree.indentGuidesStroke": "#3d92ec",
|
||||||
|
"activityBar.background": "#4C0A64",
|
||||||
|
"titleBar.activeBackground": "#6A0E8C",
|
||||||
|
"titleBar.activeForeground": "#FDFAFE",
|
||||||
|
"titleBar.inactiveBackground": "#4C0A64",
|
||||||
|
"titleBar.inactiveForeground": "#FDFAFE",
|
||||||
|
"statusBar.background": "#4C0A64",
|
||||||
|
"statusBar.foreground": "#FDFAFE",
|
||||||
|
"statusBar.debuggingBackground": "#4C0A64",
|
||||||
|
"statusBar.debuggingForeground": "#FDFAFE",
|
||||||
|
"statusBar.noFolderBackground": "#4C0A64",
|
||||||
|
"statusBar.noFolderForeground": "#FDFAFE"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Working Agreements
|
||||||
|
|
||||||
|
## 1. Code Style & Quality
|
||||||
|
|
||||||
|
- PHP: PHPCS with my custom coding standards (`phpcs.xml`), PHPStan level ≥ 6.
|
||||||
|
- JS: ESLint (airbnb/base), Prettier.
|
||||||
|
- Commits: Conventional Commits.
|
||||||
|
- Branches: main (stable), develop, feature branches → PRs.
|
||||||
|
- CI Required Checks: lint, static analysis, unit/integration tests, e2e (smoke), composer validate. No merge to main without green.
|
||||||
|
|
||||||
|
## 2. Security Model
|
||||||
|
|
||||||
|
- Nonces on all state‑changing forms/requests; verify before mutate.
|
||||||
|
- Always sanitize input and escape output.
|
||||||
|
|
||||||
|
## 3. Build & Tooling
|
||||||
|
|
||||||
|
Composer dev deps (suggested):
|
||||||
|
|
||||||
|
- dealerdirect/phpcodesniffer-composer-installer
|
||||||
|
- squizlabs/php_codesniffer
|
||||||
|
- phpstan/phpstan
|
||||||
|
- phpunit/phpunit
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# IronKanban
|
||||||
|
|
||||||
|
I'm currently using this project <https://github.com/OlaProeis/ironPad> in a custom docker container I wrapped it in. While it works, and I like the flat-file git-backed nature of it, I'm not thrilled with the interaction pattern. I would like to keep the markdown powered (using the same format for the notes and projects if at all possible) git repo aspect of it, but build it out into a kanban-style interface. I would also like to move away from the stack IronPad is using, and move to something I'm more comfortable with.
|
||||||
|
|
||||||
|
I would prefer PHP with vanilla javascript as needed. That said, I would like to have the abilty to drag and drop cards between columns, and have the interface update in real(ish) time as changes are made. I would also like to have the ability to create new columns and cards, and have those changes reflected in the underlying markdown files. Each Project is currently built as a folder that is laid out like this:
|
||||||
|
|
||||||
|
```plain
|
||||||
|
project-name/
|
||||||
|
├── index.md
|
||||||
|
├── notes/
|
||||||
|
│ └── 20260314-154222.md
|
||||||
|
├── tasks/
|
||||||
|
│ ├── task-20260227-124827.md
|
||||||
|
│ ├── task-20260227-124901.md
|
||||||
|
│ ├── task-20260227-165334.md
|
||||||
|
│ └── task-20260314-154158.md
|
||||||
|
```
|
||||||
|
|
||||||
|
The index.md file contains the project description and any relevant information about the project. The optional notes folder contains any notes related to the project, and the tasks folder contains the individual tasks for the project.
|
||||||
|
|
||||||
|
The UI should be dark mode by default, and should be responsive. The columns should be able to be reordered, and the tasks within the columns should also be able to be reordered. The tasks should also be able to be moved between columns. The UI should also have a way to create new columns and tasks, and to edit existing ones. The UI should also have a way to delete columns and tasks, and to move tasks to a "trash" column before they are permanently deleted.
|
||||||
|
|
||||||
|
See `dev-spec.md` for more detailed specifications on the system architecture, request lifecycle, and filesystem contract.
|
||||||
@@ -0,0 +1,635 @@
|
|||||||
|
# IronKanban — Technical Blueprint (Dev Spec)
|
||||||
|
|
||||||
|
## 0. Scope
|
||||||
|
|
||||||
|
MVP implementation of:
|
||||||
|
|
||||||
|
- Git-backed markdown project/task system
|
||||||
|
- Kanban board UI
|
||||||
|
- Drag & drop between columns
|
||||||
|
- Real-time-ish updates (polling → SSE-ready)
|
||||||
|
- PHP backend + vanilla JS frontend
|
||||||
|
- No framework (structured, but lightweight)
|
||||||
|
|
||||||
|
## 1. System Architecture
|
||||||
|
|
||||||
|
### 1.1 High-level flow
|
||||||
|
|
||||||
|
```plain
|
||||||
|
Filesystem (markdown + git)
|
||||||
|
↑ ↓
|
||||||
|
Repositories (PHP)
|
||||||
|
↑ ↓
|
||||||
|
Services (business logic)
|
||||||
|
↑ ↓
|
||||||
|
API Endpoints (public/api/*.php)
|
||||||
|
↑ ↓
|
||||||
|
Frontend (vanilla JS + SortableJS)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 Request lifecycle (example: move task)
|
||||||
|
|
||||||
|
```plain
|
||||||
|
Drag card →
|
||||||
|
JS computes new position →
|
||||||
|
POST /api/move-task.php →
|
||||||
|
TaskRepository loads file →
|
||||||
|
BoardService updates column/order →
|
||||||
|
TaskRepository writes file →
|
||||||
|
GitService commits →
|
||||||
|
RevisionService updates revision →
|
||||||
|
Response JSON →
|
||||||
|
UI updates (already optimistic)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Filesystem Contract
|
||||||
|
|
||||||
|
### 2.1 Root config
|
||||||
|
|
||||||
|
```php
|
||||||
|
const PROJECT_ROOT = '/data/projects';
|
||||||
|
```
|
||||||
|
|
||||||
|
All file access must resolve within this root.
|
||||||
|
|
||||||
|
### 2.2 Project layout
|
||||||
|
|
||||||
|
```plain
|
||||||
|
/data/projects/{projectSlug}/
|
||||||
|
├── index.md
|
||||||
|
├── board.md
|
||||||
|
├── notes/
|
||||||
|
│ └── *.md
|
||||||
|
├── tasks/
|
||||||
|
│ └── *.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Markdown Contract
|
||||||
|
|
||||||
|
### 3.1 Front matter parser contract
|
||||||
|
|
||||||
|
```php
|
||||||
|
class FrontMatterDocument {
|
||||||
|
public array $meta;
|
||||||
|
public string $body;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Required keys by type
|
||||||
|
|
||||||
|
#### Project (`index.md`)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
type: project
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
created: ISO8601
|
||||||
|
updated: ISO8601
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Board (`board.md`)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
type: board
|
||||||
|
project_id: string
|
||||||
|
columns:
|
||||||
|
- id: string
|
||||||
|
label: string
|
||||||
|
order: int
|
||||||
|
updated: ISO8601
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Task (`tasks/*.md`)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
type: task
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
project_id: string
|
||||||
|
|
||||||
|
column: string
|
||||||
|
order: int
|
||||||
|
|
||||||
|
completed: bool
|
||||||
|
priority: string
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
created: ISO8601
|
||||||
|
updated: ISO8601
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional (must be preserved if present):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
status: string|null
|
||||||
|
status_note: string|null
|
||||||
|
assignee: string|null
|
||||||
|
tags: array
|
||||||
|
section: string # legacy
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Note (`notes/*.md`)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
type: note
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
project_id: string
|
||||||
|
created: ISO8601
|
||||||
|
updated: ISO8601
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Domain Models
|
||||||
|
|
||||||
|
### 4.1 Project
|
||||||
|
|
||||||
|
```php
|
||||||
|
class Project {
|
||||||
|
public string $id;
|
||||||
|
public string $title;
|
||||||
|
public string $path;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Task
|
||||||
|
|
||||||
|
```php
|
||||||
|
class Task {
|
||||||
|
public string $id;
|
||||||
|
public string $title;
|
||||||
|
public string $projectId;
|
||||||
|
|
||||||
|
public string $column;
|
||||||
|
public int $order;
|
||||||
|
|
||||||
|
public bool $completed;
|
||||||
|
public string $priority;
|
||||||
|
public bool $isActive;
|
||||||
|
|
||||||
|
public array $meta;
|
||||||
|
public string $body;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Board
|
||||||
|
|
||||||
|
```php
|
||||||
|
class Board {
|
||||||
|
public string $projectId;
|
||||||
|
|
||||||
|
/** @var Column[] */
|
||||||
|
public array $columns;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 Column
|
||||||
|
|
||||||
|
```php
|
||||||
|
class Column {
|
||||||
|
public string $id;
|
||||||
|
public string $label;
|
||||||
|
public int $order;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 Note
|
||||||
|
|
||||||
|
```php
|
||||||
|
class Note {
|
||||||
|
public string $id;
|
||||||
|
public string $title;
|
||||||
|
public string $body;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Repositories
|
||||||
|
|
||||||
|
### 5.1 ProjectRepository
|
||||||
|
|
||||||
|
```php
|
||||||
|
class ProjectRepository {
|
||||||
|
public function getAll(): array;
|
||||||
|
public function get(string $slug): Project;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 TaskRepository
|
||||||
|
|
||||||
|
```php
|
||||||
|
class TaskRepository {
|
||||||
|
public function getAll(string $projectId): array;
|
||||||
|
public function get(string $projectId, string $taskId): Task;
|
||||||
|
|
||||||
|
public function save(Task $task): void;
|
||||||
|
public function create(array $data): Task;
|
||||||
|
public function delete(string $projectId, string $taskId): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Responsibilities
|
||||||
|
|
||||||
|
- read/write markdown
|
||||||
|
- preserve unknown front matter keys
|
||||||
|
- handle file paths
|
||||||
|
- ensure atomic writes
|
||||||
|
|
||||||
|
### 5.3 BoardRepository
|
||||||
|
|
||||||
|
```php
|
||||||
|
class BoardRepository {
|
||||||
|
public function get(string $projectId): Board;
|
||||||
|
public function save(Board $board): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 NoteRepository
|
||||||
|
|
||||||
|
```php
|
||||||
|
class NoteRepository {
|
||||||
|
public function getAll(string $projectId): array;
|
||||||
|
public function save(Note $note): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Services
|
||||||
|
|
||||||
|
### 6.1 BoardService
|
||||||
|
|
||||||
|
```php
|
||||||
|
class BoardService {
|
||||||
|
public function getBoardState(string $projectId): array;
|
||||||
|
|
||||||
|
public function moveTask(
|
||||||
|
string $projectId,
|
||||||
|
string $taskId,
|
||||||
|
string $column,
|
||||||
|
int $newOrder
|
||||||
|
): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 TaskOrderingService
|
||||||
|
|
||||||
|
```php
|
||||||
|
class TaskOrderingService {
|
||||||
|
public function computeOrder(array $tasks, int $targetIndex): int;
|
||||||
|
public function rebalance(array $tasks): array;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Rule
|
||||||
|
|
||||||
|
- Use gaps: 100, 200, 300
|
||||||
|
- Insert = midpoint
|
||||||
|
- Rebalance only if gap < threshold
|
||||||
|
|
||||||
|
### 6.3 GitService
|
||||||
|
|
||||||
|
```php
|
||||||
|
class GitService {
|
||||||
|
public function commit(string $message): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Implementation (MVP)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
git commit -m "..."
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Future
|
||||||
|
|
||||||
|
- scoped add
|
||||||
|
- branch support
|
||||||
|
|
||||||
|
### 6.4 RevisionService
|
||||||
|
|
||||||
|
```php
|
||||||
|
class RevisionService {
|
||||||
|
public function get(string $projectId): string;
|
||||||
|
public function bump(string $projectId): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Storage
|
||||||
|
|
||||||
|
```plain
|
||||||
|
/data/projects/{project}/.revision
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.5 FileLock
|
||||||
|
|
||||||
|
```php
|
||||||
|
class FileLock {
|
||||||
|
public static function run(string $path, callable $callback);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Uses `flock()`.
|
||||||
|
|
||||||
|
## 7. API Specification
|
||||||
|
|
||||||
|
All endpoints return JSON.
|
||||||
|
|
||||||
|
### 7.1 GET `/api/board-state.php`
|
||||||
|
|
||||||
|
#### Query
|
||||||
|
|
||||||
|
`?project=personal-projects`
|
||||||
|
|
||||||
|
#### Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"project": {},
|
||||||
|
"board": {},
|
||||||
|
"columns": [],
|
||||||
|
"tasks": [],
|
||||||
|
"notes": [],
|
||||||
|
"revision": "timestamp"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 POST `/api/move-task.php`
|
||||||
|
|
||||||
|
#### Input
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"projectId": "personal-projects",
|
||||||
|
"taskId": "task-123",
|
||||||
|
"column": "doing",
|
||||||
|
"index": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Flow
|
||||||
|
|
||||||
|
```plain
|
||||||
|
load tasks in column →
|
||||||
|
compute order →
|
||||||
|
update task →
|
||||||
|
save →
|
||||||
|
git commit →
|
||||||
|
revision bump
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 POST `/api/create-task.php`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"projectId": "...",
|
||||||
|
"title": "...",
|
||||||
|
"column": "backlog",
|
||||||
|
"body": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.4 POST `/api/save-task.php`
|
||||||
|
|
||||||
|
Full update:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"projectId": "...",
|
||||||
|
"taskId": "...",
|
||||||
|
"title": "...",
|
||||||
|
"body": "...",
|
||||||
|
"meta": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.5 POST `/api/delete-task.php`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"projectId": "...",
|
||||||
|
"taskId": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.6 POST `/api/create-column.php`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"projectId": "...",
|
||||||
|
"label": "Blocked"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.7 POST `/api/update-board.php`
|
||||||
|
|
||||||
|
Reorder / rename columns.
|
||||||
|
|
||||||
|
### 7.8 POST `/api/save-note.php`
|
||||||
|
|
||||||
|
### 7.9 GET `/api/events.php` (Phase 2)
|
||||||
|
|
||||||
|
SSE stream:
|
||||||
|
|
||||||
|
```plain
|
||||||
|
event: boardUpdated
|
||||||
|
data: { "revision": "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. Frontend Spec
|
||||||
|
|
||||||
|
### 8.1 Stack
|
||||||
|
|
||||||
|
- Vanilla JS
|
||||||
|
- SortableJS (drag/drop)
|
||||||
|
- Server-rendered HTML + JS hydration
|
||||||
|
|
||||||
|
### 8.2 State model
|
||||||
|
|
||||||
|
```javaScript
|
||||||
|
const state = {
|
||||||
|
projectId: null,
|
||||||
|
columns: [],
|
||||||
|
tasks: [],
|
||||||
|
revision: null
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 Drag/drop flow
|
||||||
|
|
||||||
|
```javaScript
|
||||||
|
onEnd →
|
||||||
|
get taskId
|
||||||
|
get new column
|
||||||
|
get index
|
||||||
|
compute optimistic order
|
||||||
|
update DOM
|
||||||
|
POST move-task
|
||||||
|
if fail → revert
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.4 Polling loop (MVP)
|
||||||
|
|
||||||
|
```javaScript
|
||||||
|
setInterval(async () => {
|
||||||
|
const res = await fetch('/api/board-state?project=...');
|
||||||
|
if (res.revision !== state.revision) {
|
||||||
|
reloadBoard();
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.5 SSE (Phase 2)
|
||||||
|
|
||||||
|
```javaScript
|
||||||
|
const evt = new EventSource('/api/events.php?project=...');
|
||||||
|
evt.onmessage = () => reloadBoard();
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. Migration Logic
|
||||||
|
|
||||||
|
### 9.1 Missing board.md
|
||||||
|
|
||||||
|
```plain
|
||||||
|
scan tasks →
|
||||||
|
collect unique section values →
|
||||||
|
generate columns →
|
||||||
|
write board.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 Missing column
|
||||||
|
|
||||||
|
```php
|
||||||
|
if (!column && section) {
|
||||||
|
column = slugify(section);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.3 Missing order
|
||||||
|
|
||||||
|
Assign:
|
||||||
|
|
||||||
|
`order = index * 100;`
|
||||||
|
|
||||||
|
## 10. File Write Rules
|
||||||
|
|
||||||
|
### 10.1 Atomic write
|
||||||
|
|
||||||
|
```plain
|
||||||
|
write temp file →
|
||||||
|
fsync →
|
||||||
|
rename →
|
||||||
|
unlock
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 Preserve unknown metadata
|
||||||
|
|
||||||
|
Never discard keys.
|
||||||
|
|
||||||
|
### 10.3 Encoding
|
||||||
|
|
||||||
|
UTF-8 only.
|
||||||
|
|
||||||
|
## 11. Error Handling
|
||||||
|
|
||||||
|
### 11.1 API response format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"error": "message"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.2 Conflict detection
|
||||||
|
|
||||||
|
If revision mismatch:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"error": "conflict"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 12. Security
|
||||||
|
|
||||||
|
- No path traversal
|
||||||
|
- Sanitize projectId
|
||||||
|
- Whitelist file extensions
|
||||||
|
- Escape shell commands
|
||||||
|
- Run git in repo root only
|
||||||
|
|
||||||
|
## 13. Performance Considerations
|
||||||
|
|
||||||
|
- Cache parsed board in memory per request
|
||||||
|
- Avoid re-reading unchanged files
|
||||||
|
- Lazy-load notes if needed
|
||||||
|
- Debounce save operations (frontend)
|
||||||
|
|
||||||
|
## 14. MVP Milestones
|
||||||
|
|
||||||
|
### Phase 1
|
||||||
|
|
||||||
|
- File parsing
|
||||||
|
- Board rendering
|
||||||
|
- Basic UI
|
||||||
|
|
||||||
|
### Phase 2
|
||||||
|
|
||||||
|
- Drag/drop
|
||||||
|
- Save APIs
|
||||||
|
|
||||||
|
### Phase 3
|
||||||
|
|
||||||
|
- Task CRUD
|
||||||
|
- Column CRUD
|
||||||
|
|
||||||
|
### Phase 4
|
||||||
|
|
||||||
|
- Git integration
|
||||||
|
- Revision tracking
|
||||||
|
|
||||||
|
### Phase 5
|
||||||
|
|
||||||
|
- Polling updates
|
||||||
|
|
||||||
|
### Phase 6 (optional)
|
||||||
|
|
||||||
|
- SSE
|
||||||
|
|
||||||
|
## 15. Out-of-Scope (MVP)
|
||||||
|
|
||||||
|
- Auth system
|
||||||
|
- Multi-repo support
|
||||||
|
- Comments
|
||||||
|
- Subtasks
|
||||||
|
- Attachments
|
||||||
|
- Permissions
|
||||||
|
- Websockets
|
||||||
|
|
||||||
|
## 16. Build Order (Practical)
|
||||||
|
|
||||||
|
I suggest this order:
|
||||||
|
|
||||||
|
1. Markdown parser + writer
|
||||||
|
2. TaskRepository
|
||||||
|
3. BoardRepository
|
||||||
|
4. BoardService (read-only)
|
||||||
|
5. Render board (no JS yet)
|
||||||
|
6. Add SortableJS
|
||||||
|
7. Move-task endpoint
|
||||||
|
8. Create/edit task
|
||||||
|
9. GitService
|
||||||
|
10. Revision + polling
|
||||||
|
|
||||||
|
## 17. Key Architectural Decisions (Locked)
|
||||||
|
|
||||||
|
These are your “don’t rethink this at 2am” decisions:
|
||||||
|
|
||||||
|
- ✅ tasks remain flat files in /tasks
|
||||||
|
- ✅ kanban state = column in front matter
|
||||||
|
- ✅ board config = board.md
|
||||||
|
- ✅ ordering = sparse integers
|
||||||
|
- ✅ git is first-class
|
||||||
|
- ✅ PHP handles everything server-side
|
||||||
|
- ✅ SortableJS is allowed
|
||||||
|
- ❌ no folder-per-column model
|
||||||
|
- ❌ no heavy framework
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# IronKanban
|
||||||
|
|
||||||
|
IronKanban is a lightweight PHP and vanilla JavaScript app for managing markdown-backed projects as a kanban board. It keeps project notes and tasks as flat files, stores board column configuration in `board.md`, and updates the filesystem whenever cards are moved or edited.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Git-backed markdown storage under `storage/projects` by default
|
||||||
|
- Server-rendered project view with dark responsive UI
|
||||||
|
- SortableJS drag-and-drop for columns and task cards
|
||||||
|
- Sparse integer ordering with rebalance support
|
||||||
|
- Task create, edit, move, trash, and permanent delete flows
|
||||||
|
- Note create and edit flow
|
||||||
|
- Revision file polling for real-time-ish refreshes
|
||||||
|
- Best-effort git commits after project changes
|
||||||
|
- Sensible default columns for brand-new projects: Backlog, Ready, In Progress, Review, Done, and Trash
|
||||||
|
|
||||||
|
## Project Layout
|
||||||
|
|
||||||
|
```plain
|
||||||
|
storage/projects/{projectSlug}/
|
||||||
|
├── index.md
|
||||||
|
├── board.md
|
||||||
|
├── notes/
|
||||||
|
│ └── *.md
|
||||||
|
└── tasks/
|
||||||
|
└── *.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running Locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php -S localhost:8000 -t public
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open `http://localhost:8000`.
|
||||||
|
|
||||||
|
To point at a different projects directory, set `IKB_PROJECT_ROOT`.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application bootstrap for IronKanban.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
$rootPath = __DIR__;
|
||||||
|
|
||||||
|
require_once $rootPath . '/src/Support/helpers.php';
|
||||||
|
|
||||||
|
spl_autoload_register(
|
||||||
|
static function (string $class) use ($rootPath): void {
|
||||||
|
$prefix = 'IronKanban\\';
|
||||||
|
|
||||||
|
if (!str_starts_with($class, $prefix)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$relativeClass = substr($class, strlen($prefix));
|
||||||
|
$file = $rootPath . '/src/' . str_replace('\\', '/', $relativeClass) . '.php';
|
||||||
|
|
||||||
|
if (is_file($file)) {
|
||||||
|
include_once $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
$appConfig = include $rootPath . '/config/app.php';
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "ksolo/ironkanban",
|
||||||
|
"description": "Git-backed markdown kanban board in PHP and vanilla JS.",
|
||||||
|
"type": "project",
|
||||||
|
"require": {
|
||||||
|
"php": "^8.1"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"dealerdirect/phpcodesniffer-composer-installer": "^1.0",
|
||||||
|
"phpstan/phpstan": "^1.12",
|
||||||
|
"phpunit/phpunit": "^11.5",
|
||||||
|
"squizlabs/php_codesniffer": "^3.10"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"IronKanban\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"lint": "phpcs",
|
||||||
|
"stan": "phpstan analyse src public --level=6",
|
||||||
|
"test": "phpunit"
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"allow-plugins": {
|
||||||
|
"dealerdirect/phpcodesniffer-composer-installer": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application configuration values.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$rootPath = dirname(__DIR__);
|
||||||
|
$projectRoot = getenv('IKB_PROJECT_ROOT');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'app_name' => 'IronKanban',
|
||||||
|
'base_path' => $rootPath,
|
||||||
|
'project_root' => $projectRoot !== false && $projectRoot !== ''
|
||||||
|
? $projectRoot
|
||||||
|
: $rootPath . DIRECTORY_SEPARATOR . 'storage' . DIRECTORY_SEPARATOR . 'projects',
|
||||||
|
];
|
||||||
Generated
+76
@@ -0,0 +1,76 @@
|
|||||||
|
{
|
||||||
|
"name": "ironkanban",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "ironkanban",
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.54.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.59.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
||||||
|
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.59.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.59.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||||
|
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.59.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.59.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
||||||
|
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "ironkanban",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"test:e2e": "playwright test"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.54.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<ruleset name="Coding Style Checks">
|
||||||
|
<description>Coding Style Checks</description>
|
||||||
|
<ini name="error_reporting" value="E_ALL & ~E_DEPRECATED" />
|
||||||
|
|
||||||
|
<arg value="sp"/>
|
||||||
|
<arg name="colors"/>
|
||||||
|
<arg name="extensions" value="php,html,css"/>
|
||||||
|
<arg name="parallel" value="2048"/>
|
||||||
|
|
||||||
|
<exclude-pattern>vendor/</exclude-pattern>
|
||||||
|
<exclude-pattern>node_modules/</exclude-pattern>
|
||||||
|
|
||||||
|
<rule ref="PEAR">
|
||||||
|
<exclude name="Generic.Files.LineLength.TooLong"/>
|
||||||
|
<exclude name="PEAR.Classes.ClassDeclaration"/>
|
||||||
|
<exclude name="PEAR.Functions.FunctionDeclaration"/>
|
||||||
|
<exclude name="Generic.WhiteSpace.DisallowSpaceIndent.SpacesUsed"/>
|
||||||
|
<exclude name="Generic.Functions.CallTimePassByReference"/>
|
||||||
|
<exclude name="Squiz.Commenting.FileComment.MissingPackageTag"/>
|
||||||
|
<exclude name="Squiz.Commenting.FileComment.Missing"/>
|
||||||
|
<exclude name="Squiz.Commenting.FileComment.WrongStyle"/>
|
||||||
|
<exclude name="Squiz.Commenting.InlineComment.InvalidEndChar"/>
|
||||||
|
</rule>
|
||||||
|
|
||||||
|
<rule ref="Internal.NoCodeFound">
|
||||||
|
<severity>0</severity>
|
||||||
|
</rule>
|
||||||
|
</ruleset>
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<phpunit
|
||||||
|
bootstrap="tests/bootstrap.php"
|
||||||
|
cacheDirectory=".phpunit.cache"
|
||||||
|
colors="true"
|
||||||
|
>
|
||||||
|
<testsuites>
|
||||||
|
<testsuite name="default">
|
||||||
|
<directory>tests</directory>
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
|
</phpunit>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// @ts-check
|
||||||
|
|
||||||
|
const { defineConfig } = require('@playwright/test');
|
||||||
|
|
||||||
|
module.exports = defineConfig({
|
||||||
|
testDir: './tests/e2e',
|
||||||
|
fullyParallel: true,
|
||||||
|
reporter: 'list',
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://127.0.0.1:8101',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
},
|
||||||
|
webServer: {
|
||||||
|
command: 'php -S 127.0.0.1:8101 -t public',
|
||||||
|
url: 'http://127.0.0.1:8101',
|
||||||
|
reuseExistingServer: true,
|
||||||
|
timeout: 30000,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared API bootstrap.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__, 2) . '/bootstrap.php';
|
||||||
|
|
||||||
|
use IronKanban\Service\BoardService;
|
||||||
|
|
||||||
|
return new BoardService();
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the full board state for a project.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$boardService = include __DIR__ . '/_bootstrap.php';
|
||||||
|
|
||||||
|
use IronKanban\Support\Http;
|
||||||
|
use IronKanban\Support\HttpHalt;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$projectId = trim((string) ($_GET['project'] ?? ''));
|
||||||
|
|
||||||
|
if ($projectId === '') {
|
||||||
|
Http::error('project_required', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
Http::json($boardService->getBoardState($projectId));
|
||||||
|
} catch (HttpHalt $halt) {
|
||||||
|
return;
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
Http::error($exception->getMessage(), 500);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new board column.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$boardService = include __DIR__ . '/_bootstrap.php';
|
||||||
|
|
||||||
|
use IronKanban\Support\Http;
|
||||||
|
use IronKanban\Support\HttpHalt;
|
||||||
|
|
||||||
|
try {
|
||||||
|
Http::requireMethod('POST');
|
||||||
|
Http::verifyCsrf();
|
||||||
|
$input = Http::input();
|
||||||
|
|
||||||
|
$boardService->assertRevision((string) $input['projectId'], isset($input['revision']) ? (string) $input['revision'] : null);
|
||||||
|
|
||||||
|
Http::json(
|
||||||
|
[
|
||||||
|
'success' => true,
|
||||||
|
'state' => $boardService->createColumn(
|
||||||
|
(string) $input['projectId'],
|
||||||
|
trim((string) ($input['label'] ?? 'Untitled Column'))
|
||||||
|
),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} catch (HttpHalt $halt) {
|
||||||
|
return;
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
Http::error($exception->getMessage(), $exception->getMessage() === 'conflict' ? 409 : 500);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new task.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$boardService = include __DIR__ . '/_bootstrap.php';
|
||||||
|
|
||||||
|
use IronKanban\Support\Http;
|
||||||
|
use IronKanban\Support\HttpHalt;
|
||||||
|
|
||||||
|
try {
|
||||||
|
Http::requireMethod('POST');
|
||||||
|
Http::verifyCsrf();
|
||||||
|
$input = Http::input();
|
||||||
|
|
||||||
|
$boardService->assertRevision((string) $input['projectId'], isset($input['revision']) ? (string) $input['revision'] : null);
|
||||||
|
|
||||||
|
Http::json(
|
||||||
|
[
|
||||||
|
'success' => true,
|
||||||
|
'state' => $boardService->createTask(
|
||||||
|
(string) $input['projectId'],
|
||||||
|
trim((string) ($input['title'] ?? 'Untitled Task')),
|
||||||
|
(string) ($input['column'] ?? 'backlog'),
|
||||||
|
(string) ($input['body'] ?? '')
|
||||||
|
),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} catch (HttpHalt $halt) {
|
||||||
|
return;
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
Http::error($exception->getMessage(), $exception->getMessage() === 'conflict' ? 409 : 500);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete or trash a task.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$boardService = include __DIR__ . '/_bootstrap.php';
|
||||||
|
|
||||||
|
use IronKanban\Support\Http;
|
||||||
|
use IronKanban\Support\HttpHalt;
|
||||||
|
|
||||||
|
try {
|
||||||
|
Http::requireMethod('POST');
|
||||||
|
Http::verifyCsrf();
|
||||||
|
$input = Http::input();
|
||||||
|
|
||||||
|
$boardService->assertRevision((string) $input['projectId'], isset($input['revision']) ? (string) $input['revision'] : null);
|
||||||
|
|
||||||
|
Http::json(
|
||||||
|
[
|
||||||
|
'success' => true,
|
||||||
|
'state' => $boardService->deleteTask(
|
||||||
|
(string) $input['projectId'],
|
||||||
|
(string) $input['taskId'],
|
||||||
|
(bool) ($input['permanent'] ?? false)
|
||||||
|
),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} catch (HttpHalt $halt) {
|
||||||
|
return;
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
Http::error($exception->getMessage(), $exception->getMessage() === 'conflict' ? 409 : 500);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move a task between columns.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$boardService = include __DIR__ . '/_bootstrap.php';
|
||||||
|
|
||||||
|
use IronKanban\Support\Http;
|
||||||
|
use IronKanban\Support\HttpHalt;
|
||||||
|
|
||||||
|
try {
|
||||||
|
Http::requireMethod('POST');
|
||||||
|
Http::verifyCsrf();
|
||||||
|
$input = Http::input();
|
||||||
|
|
||||||
|
$boardService->assertRevision((string) $input['projectId'], isset($input['revision']) ? (string) $input['revision'] : null);
|
||||||
|
|
||||||
|
Http::json(
|
||||||
|
[
|
||||||
|
'success' => true,
|
||||||
|
'state' => $boardService->moveTask(
|
||||||
|
(string) $input['projectId'],
|
||||||
|
(string) $input['taskId'],
|
||||||
|
(string) $input['column'],
|
||||||
|
max(0, (int) ($input['index'] ?? 0))
|
||||||
|
),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} catch (HttpHalt $halt) {
|
||||||
|
return;
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
Http::error($exception->getMessage(), $exception->getMessage() === 'conflict' ? 409 : 500);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create or update a note.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$boardService = include __DIR__ . '/_bootstrap.php';
|
||||||
|
|
||||||
|
use IronKanban\Support\Http;
|
||||||
|
use IronKanban\Support\HttpHalt;
|
||||||
|
|
||||||
|
try {
|
||||||
|
Http::requireMethod('POST');
|
||||||
|
Http::verifyCsrf();
|
||||||
|
$input = Http::input();
|
||||||
|
|
||||||
|
$boardService->assertRevision((string) $input['projectId'], isset($input['revision']) ? (string) $input['revision'] : null);
|
||||||
|
|
||||||
|
Http::json(
|
||||||
|
[
|
||||||
|
'success' => true,
|
||||||
|
'state' => $boardService->saveNote(
|
||||||
|
(string) $input['projectId'],
|
||||||
|
$input
|
||||||
|
),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} catch (HttpHalt $halt) {
|
||||||
|
return;
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
Http::error($exception->getMessage(), $exception->getMessage() === 'conflict' ? 409 : 500);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing task.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$boardService = include __DIR__ . '/_bootstrap.php';
|
||||||
|
|
||||||
|
use IronKanban\Support\Http;
|
||||||
|
use IronKanban\Support\HttpHalt;
|
||||||
|
|
||||||
|
try {
|
||||||
|
Http::requireMethod('POST');
|
||||||
|
Http::verifyCsrf();
|
||||||
|
$input = Http::input();
|
||||||
|
|
||||||
|
$boardService->assertRevision((string) $input['projectId'], isset($input['revision']) ? (string) $input['revision'] : null);
|
||||||
|
|
||||||
|
Http::json(
|
||||||
|
[
|
||||||
|
'success' => true,
|
||||||
|
'state' => $boardService->saveTask(
|
||||||
|
(string) $input['projectId'],
|
||||||
|
(string) $input['taskId'],
|
||||||
|
$input
|
||||||
|
),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} catch (HttpHalt $halt) {
|
||||||
|
return;
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
Http::error($exception->getMessage(), $exception->getMessage() === 'conflict' ? 409 : 500);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update board column definitions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$boardService = include __DIR__ . '/_bootstrap.php';
|
||||||
|
|
||||||
|
use IronKanban\Support\Http;
|
||||||
|
use IronKanban\Support\HttpHalt;
|
||||||
|
|
||||||
|
try {
|
||||||
|
Http::requireMethod('POST');
|
||||||
|
Http::verifyCsrf();
|
||||||
|
$input = Http::input();
|
||||||
|
|
||||||
|
$boardService->assertRevision((string) $input['projectId'], isset($input['revision']) ? (string) $input['revision'] : null);
|
||||||
|
|
||||||
|
Http::json(
|
||||||
|
[
|
||||||
|
'success' => true,
|
||||||
|
'state' => $boardService->updateBoard(
|
||||||
|
(string) $input['projectId'],
|
||||||
|
is_array($input['columns'] ?? null) ? $input['columns'] : [],
|
||||||
|
isset($input['deletedColumnId']) ? (string) $input['deletedColumnId'] : null
|
||||||
|
),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} catch (HttpHalt $halt) {
|
||||||
|
return;
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
Http::error($exception->getMessage(), $exception->getMessage() === 'conflict' ? 409 : 500);
|
||||||
|
}
|
||||||
@@ -0,0 +1,551 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #0b0f14;
|
||||||
|
--panel: rgba(18, 27, 38, 0.94);
|
||||||
|
--border: rgba(160, 190, 220, 0.16);
|
||||||
|
--text: #f3f7fb;
|
||||||
|
--muted: #9fb2c7;
|
||||||
|
--accent: #7dd3a7;
|
||||||
|
--warning: #f6c76a;
|
||||||
|
--shadow: 0 24px 60px rgba(0, 0, 0, 0.35);
|
||||||
|
--radius: 22px;
|
||||||
|
--font-sans: "Segoe UI", "Aptos", "Helvetica Neue", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(125, 211, 167, 0.18), transparent 28%),
|
||||||
|
radial-gradient(circle at top right, rgba(98, 160, 255, 0.16), transparent 24%),
|
||||||
|
linear-gradient(180deg, #071018 0%, #0b0f14 100%);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: "Cascadia Code", Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(260px, 304px) 1fr;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 28px;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
background: rgba(10, 16, 24, 0.75);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace {
|
||||||
|
padding: 28px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
p {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-copy,
|
||||||
|
.project-description,
|
||||||
|
.project-root p,
|
||||||
|
.note-preview,
|
||||||
|
.column-meta,
|
||||||
|
.task-card p {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-list,
|
||||||
|
.project-root,
|
||||||
|
.board-panel,
|
||||||
|
.notes-panel,
|
||||||
|
.trash-panel,
|
||||||
|
.hero {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-list,
|
||||||
|
.project-root,
|
||||||
|
.board-panel,
|
||||||
|
.notes-panel,
|
||||||
|
.trash-panel,
|
||||||
|
.hero {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-panel,
|
||||||
|
.notes-panel,
|
||||||
|
.trash-panel {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-link {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 16px;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-link:hover,
|
||||||
|
.project-link.active {
|
||||||
|
border-color: rgba(125, 211, 167, 0.45);
|
||||||
|
background: rgba(125, 211, 167, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-link span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-header > div,
|
||||||
|
.notes-panel-header > div,
|
||||||
|
.column-title-wrap {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trash-panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-panel-header h2 {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trash-panel-header h2 {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-panel-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions,
|
||||||
|
.dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button,
|
||||||
|
.icon-button,
|
||||||
|
.column-actions button,
|
||||||
|
.task-mini-actions button {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
padding: 11px 18px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.primary {
|
||||||
|
border-color: transparent;
|
||||||
|
background: linear-gradient(135deg, var(--accent) 0%, #70e0c6 100%);
|
||||||
|
color: #0b1410;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.compact {
|
||||||
|
padding: 8px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trash-columns-wrap {
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trash-dropzone {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 18px;
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px dashed rgba(246, 199, 106, 0.42);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(246, 199, 106, 0.08);
|
||||||
|
color: var(--muted);
|
||||||
|
min-height: 92px;
|
||||||
|
align-content: center;
|
||||||
|
transition:
|
||||||
|
border-color 160ms ease,
|
||||||
|
background 160ms ease,
|
||||||
|
color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trash-dropzone-title,
|
||||||
|
.trash-dropzone-meta {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trash-dropzone.sortable-ghost,
|
||||||
|
.trash-dropzone.sortable-chosen,
|
||||||
|
.trash-dropzone.is-active-dropzone {
|
||||||
|
border-color: rgba(246, 199, 106, 0.78);
|
||||||
|
background: rgba(246, 199, 106, 0.16);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-columns {
|
||||||
|
display: flex;
|
||||||
|
gap: 18px;
|
||||||
|
align-items: flex-start;
|
||||||
|
min-height: 60vh;
|
||||||
|
width: max-content;
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: min(320px, calc(100vw - 72px));
|
||||||
|
min-width: 280px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||||
|
border-radius: 20px;
|
||||||
|
background: linear-gradient(180deg, rgba(28, 39, 54, 0.96), rgba(18, 25, 36, 0.96));
|
||||||
|
}
|
||||||
|
|
||||||
|
.trash-panel .column {
|
||||||
|
width: min(360px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-title-wrap h3 {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-actions,
|
||||||
|
.task-mini-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-actions button,
|
||||||
|
.task-mini-actions button,
|
||||||
|
.icon-button {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
min-height: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-card,
|
||||||
|
.note-card {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 14px;
|
||||||
|
background: rgba(8, 12, 18, 0.55);
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-card {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-card.is-completed {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(125, 211, 167, 0.12);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill.priority-high,
|
||||||
|
.pill.priority-urgent {
|
||||||
|
background: rgba(246, 199, 106, 0.14);
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill.priority-low {
|
||||||
|
background: rgba(98, 160, 255, 0.14);
|
||||||
|
color: #8ec1ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 18px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-panel.is-collapsed .notes-list {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-panel.is-expanded .notes-list {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-card {
|
||||||
|
cursor: pointer;
|
||||||
|
min-height: 160px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-card h4,
|
||||||
|
.task-card h4,
|
||||||
|
.column-title-wrap h3,
|
||||||
|
.project-description,
|
||||||
|
.note-preview,
|
||||||
|
.task-card p {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-preview {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-toggle {
|
||||||
|
min-width: 122px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-toggle::after {
|
||||||
|
content: "▾";
|
||||||
|
margin-left: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-toggle[aria-expanded="true"]::after {
|
||||||
|
content: "▴";
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
width: min(720px, calc(100vw - 24px));
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog::backdrop {
|
||||||
|
background: rgba(2, 5, 10, 0.72);
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 24px;
|
||||||
|
background: #0e1620;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog label {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog input[type="text"],
|
||||||
|
.dialog textarea,
|
||||||
|
.dialog select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-row {
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state.compact {
|
||||||
|
padding: 12px 0 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sortable-ghost {
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-card-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-tags {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1080px) {
|
||||||
|
.app-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-list {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.workspace,
|
||||||
|
.sidebar {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-header {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-panel-header {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trash-panel-header {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-panel-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions .button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-panel-actions .button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trash-panel .column {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column {
|
||||||
|
width: min(320px, calc(100vw - 52px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,619 @@
|
|||||||
|
const initialStateNode = document.getElementById('initial-state');
|
||||||
|
const appRoot = document.getElementById('app');
|
||||||
|
|
||||||
|
if (initialStateNode && appRoot && initialStateNode.textContent !== 'null') {
|
||||||
|
const state = JSON.parse(initialStateNode.textContent);
|
||||||
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||||
|
const boardColumnsEl = document.getElementById('board-columns');
|
||||||
|
const trashColumnsEl = document.getElementById('trash-columns');
|
||||||
|
const trashDropzoneEl = document.getElementById('trash-dropzone');
|
||||||
|
const trashDropzoneMetaEl = document.getElementById('trash-dropzone-meta');
|
||||||
|
const notesPanelEl = document.getElementById('notes-panel');
|
||||||
|
const notesListEl = document.getElementById('notes-list');
|
||||||
|
const notesToggleEl = document.querySelector('[data-action="toggle-notes"]');
|
||||||
|
const trashWrapEl = document.getElementById('trash-columns-wrap');
|
||||||
|
const trashToggleEl = document.querySelector('[data-action="toggle-trash"]');
|
||||||
|
const trashPanelEl = document.getElementById('trash-panel');
|
||||||
|
const taskDialog = document.getElementById('task-dialog');
|
||||||
|
const noteDialog = document.getElementById('note-dialog');
|
||||||
|
const taskForm = document.getElementById('task-form');
|
||||||
|
const noteForm = document.getElementById('note-form');
|
||||||
|
const projectId = appRoot.dataset.projectId;
|
||||||
|
const uiState = {
|
||||||
|
notesExpanded: false,
|
||||||
|
trashExpanded: false,
|
||||||
|
trashPinned: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let columnSortable = null;
|
||||||
|
const taskSortables = [];
|
||||||
|
let trashAutoCollapseTimer = null;
|
||||||
|
|
||||||
|
const escapeHtml = (value = '') =>
|
||||||
|
value
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
|
||||||
|
const markdownPreview = (value = '') => escapeHtml(value).replaceAll('\n', '<br>');
|
||||||
|
|
||||||
|
const fetchJson = async (url, options = {}) => {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': csrfToken,
|
||||||
|
...(options.headers || {}),
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok || payload.success === false) {
|
||||||
|
throw new Error(payload.error || 'Request failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateState = (nextState) => {
|
||||||
|
state.project = nextState.project;
|
||||||
|
state.board = nextState.board;
|
||||||
|
state.columns = nextState.columns;
|
||||||
|
state.tasks = nextState.tasks;
|
||||||
|
state.notes = nextState.notes;
|
||||||
|
state.revision = nextState.revision;
|
||||||
|
render();
|
||||||
|
};
|
||||||
|
|
||||||
|
const taskForId = (taskId) => state.tasks.find((task) => task.id === taskId);
|
||||||
|
const noteForId = (noteId) => state.notes.find((note) => note.id === noteId);
|
||||||
|
const priorityClass = (priority) => `priority-${priority || 'normal'}`;
|
||||||
|
const notesToggleLabel = (count, isExpanded) =>
|
||||||
|
isExpanded ? `Hide Notes (${count})` : `Show Notes (${count})`;
|
||||||
|
const trashToggleLabel = (count, isExpanded) =>
|
||||||
|
isExpanded ? `Hide Trash (${count})` : `Show Trash (${count})`;
|
||||||
|
const sortedColumns = () => state.columns.slice().sort((a, b) => a.order - b.order);
|
||||||
|
const boardColumns = () => sortedColumns().filter((column) => column.id !== 'trash');
|
||||||
|
const trashColumn = () => state.columns.find((column) => column.id === 'trash') || null;
|
||||||
|
|
||||||
|
const tasksByColumn = () =>
|
||||||
|
state.tasks.reduce((acc, task) => {
|
||||||
|
if (!acc[task.column]) {
|
||||||
|
acc[task.column] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
acc[task.column].push(task);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const renderColumn = (column, tasks) => `
|
||||||
|
<section class="column" data-column-id="${escapeHtml(column.id)}">
|
||||||
|
<header class="column-header">
|
||||||
|
<div class="column-title-wrap">
|
||||||
|
<h3>${escapeHtml(column.label)}</h3>
|
||||||
|
<p class="column-meta">${tasks.length} task${tasks.length === 1 ? '' : 's'}</p>
|
||||||
|
</div>
|
||||||
|
<div class="column-actions">
|
||||||
|
<button type="button" data-action="rename-column" data-column-id="${escapeHtml(column.id)}" title="Rename column">✎</button>
|
||||||
|
<button type="button" data-action="delete-column" data-column-id="${escapeHtml(column.id)}" title="Delete column">×</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="task-list" data-column-id="${escapeHtml(column.id)}">
|
||||||
|
${tasks
|
||||||
|
.map(
|
||||||
|
(task) => `
|
||||||
|
<article class="task-card ${task.completed ? 'is-completed' : ''}" data-task-id="${escapeHtml(task.id)}">
|
||||||
|
<h4>${escapeHtml(task.title)}</h4>
|
||||||
|
<p>${markdownPreview(task.body.slice(0, 160))}</p>
|
||||||
|
<div class="task-card-footer">
|
||||||
|
<div class="task-tags">
|
||||||
|
<span class="pill ${priorityClass(task.priority)}">${escapeHtml(task.priority)}</span>
|
||||||
|
${task.completed ? '<span class="pill">done</span>' : ''}
|
||||||
|
${task.is_active ? '' : '<span class="pill">paused</span>'}
|
||||||
|
</div>
|
||||||
|
<div class="task-mini-actions">
|
||||||
|
<button type="button" data-action="edit-task" data-task-id="${escapeHtml(task.id)}" title="Edit task">✎</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.join('')}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const renderBoard = () => {
|
||||||
|
const tasksByColumnMap = tasksByColumn();
|
||||||
|
|
||||||
|
boardColumnsEl.innerHTML = boardColumns()
|
||||||
|
.map((column) => {
|
||||||
|
const tasks = (tasksByColumnMap[column.id] || []).slice().sort((a, b) => a.order - b.order);
|
||||||
|
|
||||||
|
return renderColumn(column, tasks);
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
const trash = trashColumn();
|
||||||
|
const trashTasks = trash
|
||||||
|
? (tasksByColumnMap[trash.id] || []).slice().sort((a, b) => a.order - b.order)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (trashDropzoneEl) {
|
||||||
|
trashDropzoneEl.querySelectorAll('.task-card').forEach((taskCard) => taskCard.remove());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trashPanelEl && trashWrapEl && trashToggleEl && trashColumnsEl) {
|
||||||
|
trashPanelEl.hidden = trash === null;
|
||||||
|
|
||||||
|
if (trash === null) {
|
||||||
|
trashColumnsEl.innerHTML = '';
|
||||||
|
if (trashDropzoneMetaEl) {
|
||||||
|
trashDropzoneMetaEl.textContent = 'Trash is unavailable.';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
trashColumnsEl.innerHTML = renderColumn(trash, trashTasks);
|
||||||
|
if (trashDropzoneMetaEl) {
|
||||||
|
trashDropzoneMetaEl.textContent =
|
||||||
|
trashTasks.length === 0
|
||||||
|
? 'Trash is empty.'
|
||||||
|
: `${trashTasks.length} task${trashTasks.length === 1 ? '' : 's'} in trash.`;
|
||||||
|
}
|
||||||
|
setTrashExpanded(uiState.trashExpanded, trashTasks.length);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderNotes = () => {
|
||||||
|
notesListEl.innerHTML =
|
||||||
|
state.notes.length === 0
|
||||||
|
? '<div class="empty-state compact"><p>No notes yet.</p></div>'
|
||||||
|
: state.notes
|
||||||
|
.map(
|
||||||
|
(note) => `
|
||||||
|
<article class="note-card" data-note-id="${escapeHtml(note.id)}">
|
||||||
|
<h4>${escapeHtml(note.title)}</h4>
|
||||||
|
<p class="note-preview">${markdownPreview((note.body || '').slice(0, 200))}</p>
|
||||||
|
</article>
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const setNotesExpanded = (isExpanded) => {
|
||||||
|
if (!notesListEl || !notesToggleEl || !notesPanelEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const noteCount = state.notes.length;
|
||||||
|
uiState.notesExpanded = isExpanded;
|
||||||
|
notesListEl.hidden = !isExpanded;
|
||||||
|
notesListEl.setAttribute('aria-hidden', String(!isExpanded));
|
||||||
|
notesPanelEl.classList.toggle('is-expanded', isExpanded);
|
||||||
|
notesPanelEl.classList.toggle('is-collapsed', !isExpanded);
|
||||||
|
notesToggleEl.setAttribute('aria-expanded', String(isExpanded));
|
||||||
|
notesToggleEl.textContent = notesToggleLabel(noteCount, isExpanded);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setTrashExpanded = (isExpanded, count = 0, pinned = uiState.trashPinned) => {
|
||||||
|
if (!trashWrapEl || !trashToggleEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uiState.trashPinned = pinned;
|
||||||
|
uiState.trashExpanded = isExpanded;
|
||||||
|
trashWrapEl.hidden = !isExpanded;
|
||||||
|
trashToggleEl.setAttribute('aria-expanded', String(isExpanded));
|
||||||
|
trashToggleEl.textContent = trashToggleLabel(count, isExpanded);
|
||||||
|
};
|
||||||
|
|
||||||
|
const revealTrashTemporarily = () => {
|
||||||
|
const trashTaskCount = state.tasks.filter((task) => task.column === 'trash').length;
|
||||||
|
|
||||||
|
if (!trashPanelEl || trashTaskCount === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trashAutoCollapseTimer !== null) {
|
||||||
|
window.clearTimeout(trashAutoCollapseTimer);
|
||||||
|
trashAutoCollapseTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTrashExpanded(true, trashTaskCount, false);
|
||||||
|
trashPanelEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
|
||||||
|
trashAutoCollapseTimer = window.setTimeout(() => {
|
||||||
|
if (!uiState.trashPinned) {
|
||||||
|
setTrashExpanded(false, state.tasks.filter((task) => task.column === 'trash').length, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
trashAutoCollapseTimer = null;
|
||||||
|
}, 4000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const destroySortables = () => {
|
||||||
|
if (columnSortable) {
|
||||||
|
columnSortable.destroy();
|
||||||
|
columnSortable = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (taskSortables.length > 0) {
|
||||||
|
taskSortables.pop().destroy();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveColumns = async (deletedColumnId = null) => {
|
||||||
|
const columns = [...boardColumnsEl.querySelectorAll('.column')].map((columnEl, index) => ({
|
||||||
|
id: columnEl.dataset.columnId,
|
||||||
|
label: columnEl.querySelector('h3')?.textContent || columnEl.dataset.columnId,
|
||||||
|
order: (index + 1) * 100,
|
||||||
|
}));
|
||||||
|
const trash = trashColumn();
|
||||||
|
|
||||||
|
if (trash) {
|
||||||
|
columns.push({ ...trash, order: (columns.length + 1) * 100 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await fetchJson('/api/update-board.php', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ projectId, revision: state.revision, columns, deletedColumnId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
updateState(payload.state);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mountSortables = () => {
|
||||||
|
destroySortables();
|
||||||
|
|
||||||
|
columnSortable = new Sortable(boardColumnsEl, {
|
||||||
|
animation: 180,
|
||||||
|
draggable: '.column',
|
||||||
|
ghostClass: 'sortable-ghost',
|
||||||
|
onEnd: () => {
|
||||||
|
saveColumns().catch(handleError);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.task-list').forEach((listEl) => {
|
||||||
|
const sortable = new Sortable(listEl, {
|
||||||
|
group: 'tasks',
|
||||||
|
animation: 180,
|
||||||
|
draggable: '.task-card',
|
||||||
|
ghostClass: 'sortable-ghost',
|
||||||
|
onEnd: async (event) => {
|
||||||
|
try {
|
||||||
|
const payload = await fetchJson('/api/move-task.php', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
projectId,
|
||||||
|
taskId: event.item.dataset.taskId,
|
||||||
|
column: event.to.dataset.columnId,
|
||||||
|
index: event.newIndex ?? 0,
|
||||||
|
revision: state.revision,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
updateState(payload.state);
|
||||||
|
|
||||||
|
if (event.to.dataset.columnId === 'trash') {
|
||||||
|
revealTrashTemporarily();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
reloadBoard();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
taskSortables.push(sortable);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (trashDropzoneEl) {
|
||||||
|
const trashDropzoneSortable = new Sortable(trashDropzoneEl, {
|
||||||
|
group: 'tasks',
|
||||||
|
animation: 180,
|
||||||
|
draggable: '.task-card',
|
||||||
|
ghostClass: 'sortable-ghost',
|
||||||
|
onStart: () => {
|
||||||
|
trashDropzoneEl.classList.add('is-active-dropzone');
|
||||||
|
},
|
||||||
|
onEnd: async (event) => {
|
||||||
|
trashDropzoneEl.classList.remove('is-active-dropzone');
|
||||||
|
|
||||||
|
if (event.to !== trashDropzoneEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await fetchJson('/api/move-task.php', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
projectId,
|
||||||
|
taskId: event.item.dataset.taskId,
|
||||||
|
column: 'trash',
|
||||||
|
index: 0,
|
||||||
|
revision: state.revision,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
updateState(payload.state);
|
||||||
|
revealTrashTemporarily();
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
reloadBoard();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
taskSortables.push(trashDropzoneSortable);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const render = () => {
|
||||||
|
renderBoard();
|
||||||
|
renderNotes();
|
||||||
|
mountSortables();
|
||||||
|
setNotesExpanded(uiState.notesExpanded);
|
||||||
|
};
|
||||||
|
|
||||||
|
const populateTaskDialog = (task = null) => {
|
||||||
|
taskForm.reset();
|
||||||
|
taskForm.elements.id.value = task?.id || '';
|
||||||
|
taskForm.elements.title.value = task?.title || '';
|
||||||
|
taskForm.elements.priority.value = task?.priority || 'normal';
|
||||||
|
taskForm.elements.body.value = task?.body || '';
|
||||||
|
taskForm.elements.completed.checked = Boolean(task?.completed);
|
||||||
|
taskForm.elements.is_active.checked = task ? Boolean(task.is_active) : true;
|
||||||
|
taskForm.querySelector('[data-action="trash-task"]').hidden = !task;
|
||||||
|
taskForm.elements.column.innerHTML = state.columns
|
||||||
|
.map(
|
||||||
|
(column) =>
|
||||||
|
`<option value="${escapeHtml(column.id)}"${column.id === (task?.column || state.columns[0]?.id) ? ' selected' : ''}>${escapeHtml(column.label)}</option>`
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const populateNoteDialog = (note = null) => {
|
||||||
|
noteForm.reset();
|
||||||
|
noteForm.elements.id.value = note?.id || '';
|
||||||
|
noteForm.elements.title.value = note?.title || '';
|
||||||
|
noteForm.elements.body.value = note?.body || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const openTaskDialog = (taskId = null) => {
|
||||||
|
populateTaskDialog(taskId ? taskForId(taskId) : null);
|
||||||
|
taskDialog.showModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openNoteDialog = (noteId = null) => {
|
||||||
|
populateNoteDialog(noteId ? noteForId(noteId) : null);
|
||||||
|
noteDialog.showModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleError = (error) => {
|
||||||
|
window.alert(error.message || 'Something went wrong.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const reloadBoard = async () => {
|
||||||
|
const response = await fetch(`/api/board-state.php?project=${encodeURIComponent(projectId)}`);
|
||||||
|
updateState(await response.json());
|
||||||
|
};
|
||||||
|
|
||||||
|
appRoot.addEventListener('click', async (event) => {
|
||||||
|
const button = event.target.closest('[data-action]');
|
||||||
|
const taskCard = event.target.closest('.task-card');
|
||||||
|
const noteCard = event.target.closest('.note-card');
|
||||||
|
|
||||||
|
if (taskCard && !button) {
|
||||||
|
openTaskDialog(taskCard.dataset.taskId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (noteCard && !button) {
|
||||||
|
openNoteDialog(noteCard.dataset.noteId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!button) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (button.dataset.action === 'new-task') {
|
||||||
|
openTaskDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (button.dataset.action === 'new-note') {
|
||||||
|
openNoteDialog();
|
||||||
|
setNotesExpanded(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (button.dataset.action === 'toggle-notes') {
|
||||||
|
setNotesExpanded(!uiState.notesExpanded);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (button.dataset.action === 'toggle-trash') {
|
||||||
|
const trashTaskCount = state.tasks.filter((task) => task.column === 'trash').length;
|
||||||
|
const nextExpanded = !uiState.trashExpanded;
|
||||||
|
|
||||||
|
if (trashAutoCollapseTimer !== null) {
|
||||||
|
window.clearTimeout(trashAutoCollapseTimer);
|
||||||
|
trashAutoCollapseTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTrashExpanded(nextExpanded, trashTaskCount, nextExpanded);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (button.dataset.action === 'new-column') {
|
||||||
|
const label = window.prompt('Column name');
|
||||||
|
|
||||||
|
if (label) {
|
||||||
|
updateState(
|
||||||
|
(
|
||||||
|
await fetchJson('/api/create-column.php', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ projectId, label, revision: state.revision }),
|
||||||
|
})
|
||||||
|
).state
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (button.dataset.action === 'edit-task') {
|
||||||
|
openTaskDialog(button.dataset.taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (button.dataset.action === 'rename-column') {
|
||||||
|
const column = state.columns.find((item) => item.id === button.dataset.columnId);
|
||||||
|
const label = window.prompt('Rename column', column?.label || button.dataset.columnId);
|
||||||
|
|
||||||
|
if (label) {
|
||||||
|
const columns = state.columns.map((item) =>
|
||||||
|
item.id === button.dataset.columnId ? { ...item, label } : item
|
||||||
|
);
|
||||||
|
updateState(
|
||||||
|
(
|
||||||
|
await fetchJson('/api/update-board.php', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ projectId, revision: state.revision, columns }),
|
||||||
|
})
|
||||||
|
).state
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (button.dataset.action === 'delete-column') {
|
||||||
|
if (button.dataset.columnId === 'trash') {
|
||||||
|
window.alert('Trash cannot be removed.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.confirm('Delete this column and move its tasks to Trash?')) {
|
||||||
|
updateState(
|
||||||
|
(
|
||||||
|
await fetchJson('/api/update-board.php', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
projectId,
|
||||||
|
revision: state.revision,
|
||||||
|
columns: state.columns,
|
||||||
|
deletedColumnId: button.dataset.columnId,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
).state
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
taskForm.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const formData = new FormData(taskForm);
|
||||||
|
const taskId = formData.get('id');
|
||||||
|
const payload = {
|
||||||
|
projectId,
|
||||||
|
revision: state.revision,
|
||||||
|
title: formData.get('title'),
|
||||||
|
column: formData.get('column'),
|
||||||
|
body: formData.get('body'),
|
||||||
|
priority: formData.get('priority'),
|
||||||
|
completed: taskForm.elements.completed.checked,
|
||||||
|
is_active: taskForm.elements.is_active.checked,
|
||||||
|
meta: {
|
||||||
|
priority: formData.get('priority'),
|
||||||
|
completed: taskForm.elements.completed.checked,
|
||||||
|
is_active: taskForm.elements.is_active.checked,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
updateState(
|
||||||
|
(
|
||||||
|
await fetchJson(taskId ? '/api/save-task.php' : '/api/create-task.php', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(taskId ? { ...payload, taskId } : payload),
|
||||||
|
})
|
||||||
|
).state
|
||||||
|
);
|
||||||
|
taskDialog.close();
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
noteForm.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const formData = new FormData(noteForm);
|
||||||
|
|
||||||
|
try {
|
||||||
|
updateState(
|
||||||
|
(
|
||||||
|
await fetchJson('/api/save-note.php', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
projectId,
|
||||||
|
revision: state.revision,
|
||||||
|
id: formData.get('id') || undefined,
|
||||||
|
title: formData.get('title'),
|
||||||
|
body: formData.get('body'),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
).state
|
||||||
|
);
|
||||||
|
setNotesExpanded(true);
|
||||||
|
noteDialog.close();
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
taskForm.querySelector('[data-action="trash-task"]').addEventListener('click', async () => {
|
||||||
|
const taskId = taskForm.elements.id.value;
|
||||||
|
|
||||||
|
if (!taskId) {
|
||||||
|
taskDialog.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
updateState(
|
||||||
|
(
|
||||||
|
await fetchJson('/api/delete-task.php', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ projectId, taskId, revision: state.revision }),
|
||||||
|
})
|
||||||
|
).state
|
||||||
|
);
|
||||||
|
revealTrashTemporarily();
|
||||||
|
taskDialog.close();
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/board-state.php?project=${encodeURIComponent(projectId)}`);
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
if (payload.revision !== state.revision) {
|
||||||
|
updateState(payload);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
render();
|
||||||
|
setNotesExpanded(false);
|
||||||
|
setTrashExpanded(false, state.tasks.filter((task) => task.column === 'trash').length);
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
<?php
|
||||||
|
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main application entrypoint.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/bootstrap.php';
|
||||||
|
|
||||||
|
use IronKanban\Repository\ProjectRepository;
|
||||||
|
use IronKanban\Service\BoardService;
|
||||||
|
|
||||||
|
$projectRepository = new ProjectRepository();
|
||||||
|
$projects = $projectRepository->getAll();
|
||||||
|
$requestedProject = isset($_GET['project']) ? trim((string) $_GET['project']) : '';
|
||||||
|
$activeProjectId = $requestedProject !== '' ? $requestedProject : ($projects[0]->id ?? '');
|
||||||
|
$boardService = new BoardService();
|
||||||
|
$initialState = null;
|
||||||
|
|
||||||
|
if ($activeProjectId !== '') {
|
||||||
|
try {
|
||||||
|
$initialState = $boardService->getBoardState($activeProjectId);
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
$initialState = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="csrf-token" content="<?php echo e(csrf_token()); ?>">
|
||||||
|
<title><?php echo e((string) config('app_name')); ?></title>
|
||||||
|
<link rel="stylesheet" href="/assets/app.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-shell">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Git-backed markdown kanban</p>
|
||||||
|
<h1>IronKanban</h1>
|
||||||
|
<p class="sidebar-copy">Projects stay as flat files, while the UI gives you drag-and-drop columns, quick edits, notes, and polling-based refreshes.</p>
|
||||||
|
</div>
|
||||||
|
<section class="project-list">
|
||||||
|
<div class="section-heading">
|
||||||
|
<h2>Projects</h2>
|
||||||
|
<span><?php echo count($projects); ?></span>
|
||||||
|
</div>
|
||||||
|
<?php if ($projects === []) : ?>
|
||||||
|
<div class="empty-state compact">
|
||||||
|
<p>No projects found in <code><?php echo e(project_root()); ?></code>.</p>
|
||||||
|
</div>
|
||||||
|
<?php else : ?>
|
||||||
|
<?php foreach ($projects as $project) : ?>
|
||||||
|
<a class="project-link<?php echo $project->id === $activeProjectId ? ' active' : ''; ?>" href="/?project=<?php echo rawurlencode($project->id); ?>">
|
||||||
|
<strong><?php echo e($project->title); ?></strong>
|
||||||
|
<span><?php echo e($project->id); ?></span>
|
||||||
|
</a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</section>
|
||||||
|
<section class="project-root">
|
||||||
|
<div class="section-heading">
|
||||||
|
<h2>Storage</h2>
|
||||||
|
</div>
|
||||||
|
<p><code><?php echo e(project_root()); ?></code></p>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="workspace" id="app" data-project-id="<?php echo e($activeProjectId); ?>">
|
||||||
|
<?php if ($initialState === null) : ?>
|
||||||
|
<section class="hero empty-state">
|
||||||
|
<h2>No project selected</h2>
|
||||||
|
<p>Create or copy a project folder into <code><?php echo e(project_root()); ?></code> using the markdown layout from the notes.</p>
|
||||||
|
</section>
|
||||||
|
<?php else : ?>
|
||||||
|
<header class="workspace-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Project Board</p>
|
||||||
|
<h2><?php echo e((string) $initialState['project']['title']); ?></h2>
|
||||||
|
<p class="project-description"><?php echo nl2br(e((string) $initialState['project']['body'])); ?></p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button type="button" class="button ghost" data-action="new-column">New Column</button>
|
||||||
|
<button type="button" class="button primary" data-action="new-task">New Task</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="workspace-stack">
|
||||||
|
<section class="notes-panel is-collapsible is-collapsed" id="notes-panel" data-collapsible>
|
||||||
|
<div class="notes-panel-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Project Notes</p>
|
||||||
|
<h2>Notes</h2>
|
||||||
|
</div>
|
||||||
|
<div class="notes-panel-actions">
|
||||||
|
<button type="button" class="button ghost compact" data-action="new-note">Add Note</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button ghost compact collapse-toggle"
|
||||||
|
data-action="toggle-notes"
|
||||||
|
data-target="notes-list"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls="notes-list"
|
||||||
|
>
|
||||||
|
Show Notes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="notes-list" class="notes-list" hidden></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="board-panel">
|
||||||
|
<div class="board-scroll">
|
||||||
|
<div class="board-columns" id="board-columns"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="trash-panel" id="trash-panel">
|
||||||
|
<div class="trash-panel-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Discarded Tasks</p>
|
||||||
|
<h2>Trash</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button ghost compact collapse-toggle"
|
||||||
|
data-action="toggle-trash"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls="trash-columns-wrap"
|
||||||
|
>
|
||||||
|
Show Trash
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="trash-dropzone" id="trash-dropzone" data-column-id="trash">
|
||||||
|
<p class="trash-dropzone-title">Drop tasks here to discard them</p>
|
||||||
|
<p class="trash-dropzone-meta" id="trash-dropzone-meta">Trash is empty.</p>
|
||||||
|
</div>
|
||||||
|
<div id="trash-columns-wrap" class="trash-columns-wrap" hidden>
|
||||||
|
<div class="board-scroll">
|
||||||
|
<div class="board-columns board-columns-trash" id="trash-columns"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
<?php endif; ?>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dialog id="task-dialog" class="dialog">
|
||||||
|
<form method="dialog" class="dialog-card" id="task-form">
|
||||||
|
<div class="dialog-header">
|
||||||
|
<h3 id="task-dialog-title">Task</h3>
|
||||||
|
<button type="submit" class="icon-button" aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="id">
|
||||||
|
<label>
|
||||||
|
<span>Title</span>
|
||||||
|
<input type="text" name="title" maxlength="200" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Column</span>
|
||||||
|
<select name="column"></select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Priority</span>
|
||||||
|
<select name="priority">
|
||||||
|
<option value="low">Low</option>
|
||||||
|
<option value="normal">Normal</option>
|
||||||
|
<option value="high">High</option>
|
||||||
|
<option value="urgent">Urgent</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input type="checkbox" name="completed">
|
||||||
|
<span>Completed</span>
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input type="checkbox" name="is_active" checked>
|
||||||
|
<span>Active</span>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Markdown</span>
|
||||||
|
<textarea name="body" rows="12"></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button type="button" class="button ghost" data-action="trash-task">Move To Trash</button>
|
||||||
|
<button type="submit" class="button primary">Save Task</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="note-dialog" class="dialog">
|
||||||
|
<form method="dialog" class="dialog-card" id="note-form">
|
||||||
|
<div class="dialog-header">
|
||||||
|
<h3 id="note-dialog-title">Note</h3>
|
||||||
|
<button type="submit" class="icon-button" aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="id">
|
||||||
|
<label>
|
||||||
|
<span>Title</span>
|
||||||
|
<input type="text" name="title" maxlength="200" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Markdown</span>
|
||||||
|
<textarea name="body" rows="14"></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button type="submit" class="button primary">Save Note</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<script id="initial-state" type="application/json"><?php echo
|
||||||
|
$initialState !== null
|
||||||
|
? (string) json_encode(
|
||||||
|
$initialState,
|
||||||
|
JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
|
||||||
|
)
|
||||||
|
: 'null';
|
||||||
|
?></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.6/Sortable.min.js"></script>
|
||||||
|
<script src="/assets/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Board domain model.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace IronKanban\Domain;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a project's kanban board.
|
||||||
|
*/
|
||||||
|
final class Board
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Create a board instance.
|
||||||
|
*
|
||||||
|
* @param string $projectId Project identifier.
|
||||||
|
* @param list<Column> $columns Board columns.
|
||||||
|
* @param array<string, mixed> $meta Additional board metadata.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public string $projectId,
|
||||||
|
public array $columns,
|
||||||
|
public array $meta = []
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert the board into an API-friendly array.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'project_id' => $this->projectId,
|
||||||
|
'columns' => array_map(static fn (Column $column) => $column->toArray(), $this->columns),
|
||||||
|
'meta' => $this->meta,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column domain model.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace IronKanban\Domain;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a board column.
|
||||||
|
*/
|
||||||
|
final class Column
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Create a column instance.
|
||||||
|
*
|
||||||
|
* @param string $id Column identifier.
|
||||||
|
* @param string $label Display label.
|
||||||
|
* @param int $order Sort order.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public string $id,
|
||||||
|
public string $label,
|
||||||
|
public int $order
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert the column into an API-friendly array.
|
||||||
|
*
|
||||||
|
* @return array<string, int|string>
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'label' => $this->label,
|
||||||
|
'order' => $this->order,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Note domain model.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace IronKanban\Domain;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a project note.
|
||||||
|
*/
|
||||||
|
final class Note
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Create a note instance.
|
||||||
|
*
|
||||||
|
* @param string $id Note identifier.
|
||||||
|
* @param string $title Note title.
|
||||||
|
* @param string $projectId Owning project identifier.
|
||||||
|
* @param string $body Markdown body.
|
||||||
|
* @param array<string, mixed> $meta Additional note metadata.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public string $id,
|
||||||
|
public string $title,
|
||||||
|
public string $projectId,
|
||||||
|
public string $body,
|
||||||
|
public array $meta = []
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert the note into an API-friendly array.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'title' => $this->title,
|
||||||
|
'project_id' => $this->projectId,
|
||||||
|
'body' => $this->body,
|
||||||
|
'meta' => $this->meta,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project domain model.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace IronKanban\Domain;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a project and its metadata.
|
||||||
|
*/
|
||||||
|
final class Project
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Create a project instance.
|
||||||
|
*
|
||||||
|
* @param string $id Project identifier.
|
||||||
|
* @param string $title Project title.
|
||||||
|
* @param string $path Project storage path.
|
||||||
|
* @param string $body Project description body.
|
||||||
|
* @param array<string, mixed> $meta Additional project metadata.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public string $id,
|
||||||
|
public string $title,
|
||||||
|
public string $path,
|
||||||
|
public string $body = '',
|
||||||
|
public array $meta = []
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert the project into an API-friendly array.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'title' => $this->title,
|
||||||
|
'path' => $this->path,
|
||||||
|
'body' => $this->body,
|
||||||
|
'meta' => $this->meta,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task domain model.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace IronKanban\Domain;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a task on the board.
|
||||||
|
*/
|
||||||
|
final class Task
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Create a task instance.
|
||||||
|
*
|
||||||
|
* @param string $id Task identifier.
|
||||||
|
* @param string $title Task title.
|
||||||
|
* @param string $projectId Owning project identifier.
|
||||||
|
* @param string $column Column identifier.
|
||||||
|
* @param int $order Sort order within the column.
|
||||||
|
* @param bool $completed Completion flag.
|
||||||
|
* @param string $priority Priority label.
|
||||||
|
* @param bool $isActive Active state flag.
|
||||||
|
* @param string $body Markdown body.
|
||||||
|
* @param array<string, mixed> $meta Additional task metadata.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public string $id,
|
||||||
|
public string $title,
|
||||||
|
public string $projectId,
|
||||||
|
public string $column,
|
||||||
|
public int $order,
|
||||||
|
public bool $completed,
|
||||||
|
public string $priority,
|
||||||
|
public bool $isActive,
|
||||||
|
public string $body,
|
||||||
|
public array $meta = []
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert the task into an API-friendly array.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'title' => $this->title,
|
||||||
|
'project_id' => $this->projectId,
|
||||||
|
'column' => $this->column,
|
||||||
|
'order' => $this->order,
|
||||||
|
'completed' => $this->completed,
|
||||||
|
'priority' => $this->priority,
|
||||||
|
'is_active' => $this->isActive,
|
||||||
|
'body' => $this->body,
|
||||||
|
'meta' => $this->meta,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
<?php
|
||||||
|
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Board persistence repository.
|
||||||
|
*/
|
||||||
|
// phpcs:disable PEAR.NamingConventions.ValidFunctionName.PrivateNoUnderscore
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace IronKanban\Repository;
|
||||||
|
|
||||||
|
use IronKanban\Domain\Board;
|
||||||
|
use IronKanban\Domain\Column;
|
||||||
|
use IronKanban\Support\AtomicWriter;
|
||||||
|
use IronKanban\Support\FileLock;
|
||||||
|
use IronKanban\Support\FrontMatter;
|
||||||
|
use IronKanban\Support\Path;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads and stores board metadata.
|
||||||
|
*/
|
||||||
|
final class BoardRepository
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Create the repository.
|
||||||
|
*
|
||||||
|
* @param TaskRepository $taskRepository Task repository dependency.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly TaskRepository $taskRepository = new TaskRepository()
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a board for the given project.
|
||||||
|
*
|
||||||
|
* @param string $projectId Project identifier.
|
||||||
|
*
|
||||||
|
* @return Board
|
||||||
|
*/
|
||||||
|
public function get(string $projectId): Board
|
||||||
|
{
|
||||||
|
$boardPath = $this->boardPath($projectId);
|
||||||
|
|
||||||
|
if (!is_file($boardPath)) {
|
||||||
|
$board = $this->createFromTasks($projectId);
|
||||||
|
$this->save($board);
|
||||||
|
|
||||||
|
return $board;
|
||||||
|
}
|
||||||
|
|
||||||
|
$document = FrontMatter::parseFile($boardPath);
|
||||||
|
$columnRows = is_array($document->meta['columns'] ?? null) ? $document->meta['columns'] : [];
|
||||||
|
$columns = [];
|
||||||
|
|
||||||
|
foreach ($columnRows as $row) {
|
||||||
|
if (!is_array($row)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$columns[] = new Column(
|
||||||
|
(string) ($row['id'] ?? ''),
|
||||||
|
(string) ($row['label'] ?? 'Untitled'),
|
||||||
|
(int) ($row['order'] ?? 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($columns, static fn (Column $left, Column $right): int => $left->order <=> $right->order);
|
||||||
|
|
||||||
|
if ($columns === []) {
|
||||||
|
$board = $this->createFromTasks($projectId);
|
||||||
|
$this->save($board);
|
||||||
|
|
||||||
|
return $board;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Board($projectId, $columns, $document->meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist a board definition.
|
||||||
|
*
|
||||||
|
* @param Board $board Board instance.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function save(Board $board): void
|
||||||
|
{
|
||||||
|
$meta = $board->meta;
|
||||||
|
$meta['type'] = 'board';
|
||||||
|
$meta['project_id'] = $board->projectId;
|
||||||
|
$meta['columns'] = array_map(static fn (Column $column): array => $column->toArray(), $board->columns);
|
||||||
|
$meta['updated'] = now_iso8601();
|
||||||
|
|
||||||
|
FileLock::run(
|
||||||
|
$this->lockFile($board->projectId), function () use ($board, $meta): void {
|
||||||
|
AtomicWriter::write($this->boardPath($board->projectId), FrontMatter::dump($meta, ''));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a default board from the project's tasks.
|
||||||
|
*
|
||||||
|
* @param string $projectId Project identifier.
|
||||||
|
*
|
||||||
|
* @return Board
|
||||||
|
*/
|
||||||
|
private function createFromTasks(string $projectId): Board
|
||||||
|
{
|
||||||
|
$tasks = $this->taskRepository->getAll($projectId);
|
||||||
|
$columnIds = [];
|
||||||
|
|
||||||
|
foreach ($tasks as $task) {
|
||||||
|
$columnIds[$task->column] = ucwords(str_replace('-', ' ', $task->column));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($columnIds === []) {
|
||||||
|
$columnIds = [
|
||||||
|
'backlog' => 'Backlog',
|
||||||
|
'ready' => 'Ready',
|
||||||
|
'in-progress' => 'In Progress',
|
||||||
|
'review' => 'Review',
|
||||||
|
'done' => 'Done',
|
||||||
|
'trash' => 'Trash',
|
||||||
|
];
|
||||||
|
} elseif (!isset($columnIds['trash'])) {
|
||||||
|
$columnIds['trash'] = 'Trash';
|
||||||
|
}
|
||||||
|
|
||||||
|
$order = 100;
|
||||||
|
$columns = [];
|
||||||
|
|
||||||
|
foreach ($columnIds as $id => $label) {
|
||||||
|
$columns[] = new Column($id, $label, $order);
|
||||||
|
$order += 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Board(
|
||||||
|
$projectId, $columns, [
|
||||||
|
'type' => 'board',
|
||||||
|
'project_id' => $projectId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the board markdown path.
|
||||||
|
*
|
||||||
|
* @param string $projectId Project identifier.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function boardPath(string $projectId): string
|
||||||
|
{
|
||||||
|
return Path::project($projectId) . DIRECTORY_SEPARATOR . 'board.md';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the board lock file path.
|
||||||
|
*
|
||||||
|
* @param string $projectId Project identifier.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function lockFile(string $projectId): string
|
||||||
|
{
|
||||||
|
return Path::project($projectId) . DIRECTORY_SEPARATOR . '.board.lock';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<?php
|
||||||
|
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Note persistence repository.
|
||||||
|
*/
|
||||||
|
// phpcs:disable PEAR.NamingConventions.ValidFunctionName.PrivateNoUnderscore
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace IronKanban\Repository;
|
||||||
|
|
||||||
|
use IronKanban\Domain\Note;
|
||||||
|
use IronKanban\Support\AtomicWriter;
|
||||||
|
use IronKanban\Support\FileLock;
|
||||||
|
use IronKanban\Support\FrontMatter;
|
||||||
|
use IronKanban\Support\Path;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads and stores project notes.
|
||||||
|
*/
|
||||||
|
final class NoteRepository
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Load every note for a project.
|
||||||
|
*
|
||||||
|
* @param string $projectId Project identifier.
|
||||||
|
*
|
||||||
|
* @return Note[]
|
||||||
|
*/
|
||||||
|
public function getAll(string $projectId): array
|
||||||
|
{
|
||||||
|
$notesPath = $this->notesPath($projectId);
|
||||||
|
ensure_directory($notesPath);
|
||||||
|
|
||||||
|
$notes = [];
|
||||||
|
$files = glob($notesPath . DIRECTORY_SEPARATOR . '*.md') ?: [];
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
$document = FrontMatter::parseFile($file);
|
||||||
|
$notes[] = new Note(
|
||||||
|
(string) ($document->meta['id'] ?? ''),
|
||||||
|
(string) ($document->meta['title'] ?? 'Untitled Note'),
|
||||||
|
(string) ($document->meta['project_id'] ?? $projectId),
|
||||||
|
$document->body,
|
||||||
|
$document->meta
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
usort(
|
||||||
|
$notes,
|
||||||
|
static fn (Note $left, Note $right): int => strcmp(
|
||||||
|
(string) ($right->meta['updated'] ?? ''),
|
||||||
|
(string) ($left->meta['updated'] ?? '')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return $notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist a note.
|
||||||
|
*
|
||||||
|
* @param Note $note Note instance.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function save(Note $note): void
|
||||||
|
{
|
||||||
|
$path = $this->notesPath($note->projectId) . DIRECTORY_SEPARATOR . $note->id . '.md';
|
||||||
|
$preserved = array_diff_key($note->meta, array_flip(['type', 'id', 'title', 'project_id', 'created', 'updated']));
|
||||||
|
$created = $note->meta['created'] ?? now_iso8601();
|
||||||
|
$meta = [
|
||||||
|
'type' => 'note',
|
||||||
|
'id' => $note->id,
|
||||||
|
'title' => $note->title,
|
||||||
|
'project_id' => $note->projectId,
|
||||||
|
'created' => $created,
|
||||||
|
'updated' => now_iso8601(),
|
||||||
|
] + $preserved;
|
||||||
|
|
||||||
|
FileLock::run(
|
||||||
|
$this->lockFile($note->projectId), static function () use ($path, $meta, $note): void {
|
||||||
|
AtomicWriter::write($path, FrontMatter::dump($meta, $note->body));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the notes directory path.
|
||||||
|
*
|
||||||
|
* @param string $projectId Project identifier.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function notesPath(string $projectId): string
|
||||||
|
{
|
||||||
|
return Path::project($projectId) . DIRECTORY_SEPARATOR . 'notes';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the notes lock file path.
|
||||||
|
*
|
||||||
|
* @param string $projectId Project identifier.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function lockFile(string $projectId): string
|
||||||
|
{
|
||||||
|
return Path::project($projectId) . DIRECTORY_SEPARATOR . '.notes.lock';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project persistence repository.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace IronKanban\Repository;
|
||||||
|
|
||||||
|
use IronKanban\Domain\Project;
|
||||||
|
use IronKanban\Support\FrontMatter;
|
||||||
|
use IronKanban\Support\Path;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads project metadata from markdown storage.
|
||||||
|
*/
|
||||||
|
final class ProjectRepository
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Load all available projects.
|
||||||
|
*
|
||||||
|
* @return Project[]
|
||||||
|
*/
|
||||||
|
public function getAll(): array
|
||||||
|
{
|
||||||
|
ensure_directory(project_root());
|
||||||
|
|
||||||
|
$projects = [];
|
||||||
|
$entries = scandir(project_root()) ?: [];
|
||||||
|
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = project_root() . DIRECTORY_SEPARATOR . $entry;
|
||||||
|
|
||||||
|
if (!is_dir($path) || !is_file($path . DIRECTORY_SEPARATOR . 'index.md')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$projects[] = $this->get($entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
usort(
|
||||||
|
$projects,
|
||||||
|
static fn (Project $left, Project $right): int => strcasecmp($left->title, $right->title)
|
||||||
|
);
|
||||||
|
|
||||||
|
return $projects;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a single project by slug.
|
||||||
|
*
|
||||||
|
* @param string $slug Project slug.
|
||||||
|
*
|
||||||
|
* @return Project
|
||||||
|
*/
|
||||||
|
public function get(string $slug): Project
|
||||||
|
{
|
||||||
|
$projectPath = Path::project($slug);
|
||||||
|
$document = FrontMatter::parseFile($projectPath . DIRECTORY_SEPARATOR . 'index.md');
|
||||||
|
|
||||||
|
return new Project(
|
||||||
|
(string) ($document->meta['id'] ?? $slug),
|
||||||
|
(string) ($document->meta['title'] ?? $slug),
|
||||||
|
$projectPath,
|
||||||
|
$document->body,
|
||||||
|
$document->meta
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
<?php
|
||||||
|
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task persistence repository.
|
||||||
|
*/
|
||||||
|
// phpcs:disable PEAR.NamingConventions.ValidFunctionName.PrivateNoUnderscore
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace IronKanban\Repository;
|
||||||
|
|
||||||
|
use IronKanban\Domain\Task;
|
||||||
|
use IronKanban\Support\AtomicWriter;
|
||||||
|
use IronKanban\Support\FileLock;
|
||||||
|
use IronKanban\Support\FrontMatter;
|
||||||
|
use IronKanban\Support\FrontMatterDocument;
|
||||||
|
use IronKanban\Support\Path;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads and stores project tasks.
|
||||||
|
*/
|
||||||
|
final class TaskRepository
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Load all tasks for a project.
|
||||||
|
*
|
||||||
|
* @param string $projectId Project identifier.
|
||||||
|
*
|
||||||
|
* @return Task[]
|
||||||
|
*/
|
||||||
|
public function getAll(string $projectId): array
|
||||||
|
{
|
||||||
|
$tasksPath = $this->tasksPath($projectId);
|
||||||
|
ensure_directory($tasksPath);
|
||||||
|
|
||||||
|
$tasks = [];
|
||||||
|
$files = glob($tasksPath . DIRECTORY_SEPARATOR . '*.md') ?: [];
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
$tasks[] = $this->hydrate(FrontMatter::parseFile($file));
|
||||||
|
}
|
||||||
|
|
||||||
|
usort(
|
||||||
|
$tasks, static function (Task $left, Task $right): int {
|
||||||
|
if ($left->column === $right->column) {
|
||||||
|
return $left->order <=> $right->order ?: strcasecmp($left->title, $right->title);
|
||||||
|
}
|
||||||
|
|
||||||
|
return strcmp($left->column, $right->column);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return $tasks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a single task by identifier.
|
||||||
|
*
|
||||||
|
* @param string $projectId Project identifier.
|
||||||
|
* @param string $taskId Task identifier.
|
||||||
|
*
|
||||||
|
* @return Task
|
||||||
|
*/
|
||||||
|
public function get(string $projectId, string $taskId): Task
|
||||||
|
{
|
||||||
|
Path::assertSlug($taskId);
|
||||||
|
$path = $this->taskFile($projectId, $taskId);
|
||||||
|
|
||||||
|
if (!is_file($path)) {
|
||||||
|
throw new \RuntimeException('Task not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->hydrate(FrontMatter::parseFile($path));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist a task.
|
||||||
|
*
|
||||||
|
* @param Task $task Task instance.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function save(Task $task): void
|
||||||
|
{
|
||||||
|
$path = $this->taskFile($task->projectId, $task->id);
|
||||||
|
$knownKeys = [
|
||||||
|
'type',
|
||||||
|
'id',
|
||||||
|
'title',
|
||||||
|
'project_id',
|
||||||
|
'column',
|
||||||
|
'order',
|
||||||
|
'completed',
|
||||||
|
'priority',
|
||||||
|
'is_active',
|
||||||
|
'created',
|
||||||
|
'updated',
|
||||||
|
];
|
||||||
|
$preserved = array_diff_key($task->meta, array_flip($knownKeys));
|
||||||
|
$created = $task->meta['created'] ?? now_iso8601();
|
||||||
|
|
||||||
|
$meta = [
|
||||||
|
'type' => 'task',
|
||||||
|
'id' => $task->id,
|
||||||
|
'title' => $task->title,
|
||||||
|
'project_id' => $task->projectId,
|
||||||
|
'column' => $task->column,
|
||||||
|
'order' => $task->order,
|
||||||
|
'completed' => $task->completed,
|
||||||
|
'priority' => $task->priority,
|
||||||
|
'is_active' => $task->isActive,
|
||||||
|
'created' => $created,
|
||||||
|
'updated' => now_iso8601(),
|
||||||
|
] + $preserved;
|
||||||
|
|
||||||
|
FileLock::run(
|
||||||
|
$this->lockFile($task->projectId), static function () use ($path, $meta, $task): void {
|
||||||
|
AtomicWriter::write($path, FrontMatter::dump($meta, $task->body));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create and persist a new task from raw data.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $data Task seed data.
|
||||||
|
*
|
||||||
|
* @return Task
|
||||||
|
*/
|
||||||
|
public function create(array $data): Task
|
||||||
|
{
|
||||||
|
$task = new Task(
|
||||||
|
(string) ($data['id'] ?? generate_id('task')),
|
||||||
|
trim((string) ($data['title'] ?? 'Untitled Task')),
|
||||||
|
(string) $data['projectId'],
|
||||||
|
(string) ($data['column'] ?? 'backlog'),
|
||||||
|
(int) ($data['order'] ?? 100),
|
||||||
|
(bool) ($data['completed'] ?? false),
|
||||||
|
(string) ($data['priority'] ?? 'normal'),
|
||||||
|
(bool) ($data['is_active'] ?? true),
|
||||||
|
(string) ($data['body'] ?? ''),
|
||||||
|
[
|
||||||
|
'created' => now_iso8601(),
|
||||||
|
'updated' => now_iso8601(),
|
||||||
|
'tags' => [],
|
||||||
|
] + (array) ($data['meta'] ?? [])
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->save($task);
|
||||||
|
|
||||||
|
return $task;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a persisted task if it exists.
|
||||||
|
*
|
||||||
|
* @param string $projectId Project identifier.
|
||||||
|
* @param string $taskId Task identifier.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function delete(string $projectId, string $taskId): void
|
||||||
|
{
|
||||||
|
$path = $this->taskFile($projectId, $taskId);
|
||||||
|
|
||||||
|
if (!is_file($path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
FileLock::run(
|
||||||
|
$this->lockFile($projectId), static function () use ($path): void {
|
||||||
|
if (!unlink($path)) {
|
||||||
|
throw new \RuntimeException('Unable to delete task.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hydrate a task from a parsed markdown document.
|
||||||
|
*
|
||||||
|
* @param FrontMatterDocument $document Parsed document.
|
||||||
|
*
|
||||||
|
* @return Task
|
||||||
|
*/
|
||||||
|
private function hydrate(FrontMatterDocument $document): Task
|
||||||
|
{
|
||||||
|
$meta = $document->meta;
|
||||||
|
|
||||||
|
return new Task(
|
||||||
|
(string) ($meta['id'] ?? ''),
|
||||||
|
(string) ($meta['title'] ?? 'Untitled Task'),
|
||||||
|
(string) ($meta['project_id'] ?? ''),
|
||||||
|
(string) ($meta['column'] ?? ($meta['section'] ?? 'backlog')),
|
||||||
|
(int) ($meta['order'] ?? 100),
|
||||||
|
(bool) ($meta['completed'] ?? false),
|
||||||
|
(string) ($meta['priority'] ?? 'normal'),
|
||||||
|
(bool) ($meta['is_active'] ?? true),
|
||||||
|
$document->body,
|
||||||
|
$meta
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the tasks directory path.
|
||||||
|
*
|
||||||
|
* @param string $projectId Project identifier.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function tasksPath(string $projectId): string
|
||||||
|
{
|
||||||
|
return Path::project($projectId) . DIRECTORY_SEPARATOR . 'tasks';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a task file path.
|
||||||
|
*
|
||||||
|
* @param string $projectId Project identifier.
|
||||||
|
* @param string $taskId Task identifier.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function taskFile(string $projectId, string $taskId): string
|
||||||
|
{
|
||||||
|
return $this->tasksPath($projectId) . DIRECTORY_SEPARATOR . $taskId . '.md';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the task lock file path.
|
||||||
|
*
|
||||||
|
* @param string $projectId Project identifier.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function lockFile(string $projectId): string
|
||||||
|
{
|
||||||
|
return Path::project($projectId) . DIRECTORY_SEPARATOR . '.task.lock';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,515 @@
|
|||||||
|
<?php
|
||||||
|
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Board application service.
|
||||||
|
*/
|
||||||
|
// phpcs:disable PEAR.NamingConventions.ValidFunctionName.PrivateNoUnderscore
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace IronKanban\Service;
|
||||||
|
|
||||||
|
use IronKanban\Domain\Board;
|
||||||
|
use IronKanban\Domain\Column;
|
||||||
|
use IronKanban\Domain\Note;
|
||||||
|
use IronKanban\Domain\Task;
|
||||||
|
use IronKanban\Repository\BoardRepository;
|
||||||
|
use IronKanban\Repository\NoteRepository;
|
||||||
|
use IronKanban\Repository\ProjectRepository;
|
||||||
|
use IronKanban\Repository\TaskRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coordinates board, task, and note operations.
|
||||||
|
*/
|
||||||
|
final class BoardService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Create the board service.
|
||||||
|
*
|
||||||
|
* @param ProjectRepository $projectRepository Project repository dependency.
|
||||||
|
* @param BoardRepository $boardRepository Board repository dependency.
|
||||||
|
* @param TaskRepository $taskRepository Task repository dependency.
|
||||||
|
* @param NoteRepository $noteRepository Note repository dependency.
|
||||||
|
* @param TaskOrderingService $taskOrderingService Task ordering dependency.
|
||||||
|
* @param RevisionService $revisionService Revision tracking dependency.
|
||||||
|
* @param GitService $gitService Git integration dependency.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly ProjectRepository $projectRepository = new ProjectRepository(),
|
||||||
|
private readonly BoardRepository $boardRepository = new BoardRepository(),
|
||||||
|
private readonly TaskRepository $taskRepository = new TaskRepository(),
|
||||||
|
private readonly NoteRepository $noteRepository = new NoteRepository(),
|
||||||
|
private readonly TaskOrderingService $taskOrderingService = new TaskOrderingService(),
|
||||||
|
private readonly RevisionService $revisionService = new RevisionService(),
|
||||||
|
private readonly GitService $gitService = new GitService()
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the complete board state payload for a project.
|
||||||
|
*
|
||||||
|
* @param string $projectId Project identifier.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function getBoardState(string $projectId): array
|
||||||
|
{
|
||||||
|
$project = $this->projectRepository->get($projectId);
|
||||||
|
$board = $this->boardRepository->get($projectId);
|
||||||
|
$tasks = $this->normalizeTasks($projectId, $board, $this->taskRepository->getAll($projectId));
|
||||||
|
$notes = $this->noteRepository->getAll($projectId);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'project' => $project->toArray(),
|
||||||
|
'board' => $board->toArray(),
|
||||||
|
'columns' => array_map(static fn (Column $column): array => $column->toArray(), $board->columns),
|
||||||
|
'tasks' => array_map(static fn (Task $task): array => $task->toArray(), $tasks),
|
||||||
|
'notes' => array_map(static fn (Note $note): array => $note->toArray(), $notes),
|
||||||
|
'revision' => $this->revisionService->get($projectId),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move a task to a new column and index.
|
||||||
|
*
|
||||||
|
* @param string $projectId Project identifier.
|
||||||
|
* @param string $taskId Task identifier.
|
||||||
|
* @param string $columnId Destination column identifier.
|
||||||
|
* @param int $newIndex Destination position.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function moveTask(string $projectId, string $taskId, string $columnId, int $newIndex): array
|
||||||
|
{
|
||||||
|
$board = $this->ensureTrashColumn($this->boardRepository->get($projectId));
|
||||||
|
$tasks = $this->normalizeTasks($projectId, $board, $this->taskRepository->getAll($projectId));
|
||||||
|
$task = $this->taskRepository->get($projectId, $taskId);
|
||||||
|
|
||||||
|
if (!$this->columnExists($board, $columnId)) {
|
||||||
|
throw new \RuntimeException('Column not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceColumnTasks = array_values(array_filter($tasks, static fn (Task $item): bool => $item->column === $task->column && $item->id !== $taskId));
|
||||||
|
$targetColumnTasks = array_values(array_filter($tasks, static fn (Task $item): bool => $item->column === $columnId && $item->id !== $taskId));
|
||||||
|
|
||||||
|
if ($task->column === $columnId) {
|
||||||
|
$targetColumnTasks = $sourceColumnTasks;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($task->column !== $columnId || $this->needsRebalance($targetColumnTasks, $newIndex)) {
|
||||||
|
$targetColumnTasks = $this->taskOrderingService->rebalance($targetColumnTasks);
|
||||||
|
|
||||||
|
foreach ($targetColumnTasks as $targetTask) {
|
||||||
|
$this->taskRepository->save($targetTask);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($task->column !== $columnId) {
|
||||||
|
$sourceColumnTasks = $this->taskOrderingService->rebalance($sourceColumnTasks);
|
||||||
|
|
||||||
|
foreach ($sourceColumnTasks as $sourceTask) {
|
||||||
|
$this->taskRepository->save($sourceTask);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$task->column = $columnId;
|
||||||
|
$task->order = $this->taskOrderingService->computeOrder($targetColumnTasks, $newIndex);
|
||||||
|
$this->taskRepository->save($task);
|
||||||
|
|
||||||
|
return $this->touchProject($projectId, sprintf('chore(board): move task %s', $taskId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new task in the given column.
|
||||||
|
*
|
||||||
|
* @param string $projectId Project identifier.
|
||||||
|
* @param string $title Task title.
|
||||||
|
* @param string $columnId Destination column identifier.
|
||||||
|
* @param string $body Markdown body.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function createTask(string $projectId, string $title, string $columnId, string $body = ''): array
|
||||||
|
{
|
||||||
|
$board = $this->ensureTrashColumn($this->boardRepository->get($projectId));
|
||||||
|
|
||||||
|
if (!$this->columnExists($board, $columnId)) {
|
||||||
|
throw new \RuntimeException('Column not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$tasks = $this->taskRepository->getAll($projectId);
|
||||||
|
$columnTasks = array_values(array_filter($tasks, static fn (Task $task): bool => $task->column === $columnId));
|
||||||
|
$order = $this->taskOrderingService->computeOrder($columnTasks, count($columnTasks));
|
||||||
|
|
||||||
|
$task = $this->taskRepository->create(
|
||||||
|
[
|
||||||
|
'projectId' => $projectId,
|
||||||
|
'title' => $title,
|
||||||
|
'column' => $columnId,
|
||||||
|
'body' => $body,
|
||||||
|
'order' => $order,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$state = $this->touchProject($projectId, sprintf('feat(task): create %s', $task->id));
|
||||||
|
$state['task'] = $task->toArray();
|
||||||
|
|
||||||
|
return $state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save changes to an existing task.
|
||||||
|
*
|
||||||
|
* @param string $projectId Project identifier.
|
||||||
|
* @param string $taskId Task identifier.
|
||||||
|
* @param array<string, mixed> $payload Task payload.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function saveTask(string $projectId, string $taskId, array $payload): array
|
||||||
|
{
|
||||||
|
$task = $this->taskRepository->get($projectId, $taskId);
|
||||||
|
$meta = isset($payload['meta']) && is_array($payload['meta']) ? $payload['meta'] : [];
|
||||||
|
|
||||||
|
$task->title = trim((string) ($payload['title'] ?? $task->title));
|
||||||
|
$task->body = (string) ($payload['body'] ?? $task->body);
|
||||||
|
$task->completed = (bool) ($meta['completed'] ?? $payload['completed'] ?? $task->completed);
|
||||||
|
$task->priority = (string) ($meta['priority'] ?? $payload['priority'] ?? $task->priority);
|
||||||
|
$task->isActive = (bool) ($meta['is_active'] ?? $payload['is_active'] ?? $task->isActive);
|
||||||
|
$task->meta = array_merge($task->meta, $meta);
|
||||||
|
|
||||||
|
if (isset($payload['column']) && is_string($payload['column']) && $payload['column'] !== '') {
|
||||||
|
$task->column = $payload['column'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->taskRepository->save($task);
|
||||||
|
|
||||||
|
return $this->touchProject($projectId, sprintf('feat(task): update %s', $taskId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a task or move it to trash.
|
||||||
|
*
|
||||||
|
* @param string $projectId Project identifier.
|
||||||
|
* @param string $taskId Task identifier.
|
||||||
|
* @param bool $permanent Whether to permanently delete.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function deleteTask(string $projectId, string $taskId, bool $permanent = false): array
|
||||||
|
{
|
||||||
|
$task = $this->taskRepository->get($projectId, $taskId);
|
||||||
|
|
||||||
|
if (!$permanent && $task->column !== 'trash') {
|
||||||
|
return $this->moveTask($projectId, $taskId, 'trash', PHP_INT_MAX);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->taskRepository->delete($projectId, $taskId);
|
||||||
|
|
||||||
|
return $this->touchProject($projectId, sprintf('chore(task): delete %s', $taskId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new board column.
|
||||||
|
*
|
||||||
|
* @param string $projectId Project identifier.
|
||||||
|
* @param string $label Column label.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function createColumn(string $projectId, string $label): array
|
||||||
|
{
|
||||||
|
$board = $this->ensureTrashColumn($this->boardRepository->get($projectId));
|
||||||
|
$id = slugify($label);
|
||||||
|
$existingIds = array_map(static fn (Column $column): string => $column->id, $board->columns);
|
||||||
|
$suffix = 2;
|
||||||
|
$baseId = $id;
|
||||||
|
|
||||||
|
while (in_array($id, $existingIds, true)) {
|
||||||
|
$id = $baseId . '-' . $suffix;
|
||||||
|
$suffix++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lastOrder = $board->columns === []
|
||||||
|
? 0
|
||||||
|
: max(array_map(static fn (Column $column): int => $column->order, $board->columns));
|
||||||
|
|
||||||
|
$board->columns[] = new Column($id, trim($label), $lastOrder + 100);
|
||||||
|
$this->boardRepository->save($board);
|
||||||
|
|
||||||
|
return $this->touchProject($projectId, sprintf('feat(board): create column %s', $id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the board column definitions.
|
||||||
|
*
|
||||||
|
* @param string $projectId Project identifier.
|
||||||
|
* @param array<int, array<string, mixed>> $columns Column payloads.
|
||||||
|
* @param string|null $deletedColumnId Deleted column identifier.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function updateBoard(string $projectId, array $columns, ?string $deletedColumnId = null): array
|
||||||
|
{
|
||||||
|
$board = $this->ensureTrashColumn($this->boardRepository->get($projectId));
|
||||||
|
$existingLabels = [];
|
||||||
|
|
||||||
|
foreach ($board->columns as $column) {
|
||||||
|
$existingLabels[$column->id] = $column->label;
|
||||||
|
}
|
||||||
|
|
||||||
|
$newColumns = [];
|
||||||
|
$order = 100;
|
||||||
|
|
||||||
|
foreach ($columns as $column) {
|
||||||
|
if (!is_array($column) || !isset($column['id'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = (string) $column['id'];
|
||||||
|
$label = trim((string) ($column['label'] ?? ($existingLabels[$id] ?? ucwords(str_replace('-', ' ', $id)))));
|
||||||
|
|
||||||
|
if ($deletedColumnId !== null && $id === $deletedColumnId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$newColumns[] = new Column($id, $label, $order);
|
||||||
|
$order += 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->columnExists(new Board($projectId, $newColumns), 'trash')) {
|
||||||
|
$newColumns[] = new Column('trash', 'Trash', $order);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($deletedColumnId !== null && $deletedColumnId !== 'trash') {
|
||||||
|
$tasks = $this->taskRepository->getAll($projectId);
|
||||||
|
$trashTasks = array_values(array_filter($tasks, static fn (Task $task): bool => $task->column === 'trash'));
|
||||||
|
|
||||||
|
foreach ($tasks as $task) {
|
||||||
|
if ($task->column !== $deletedColumnId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$task->column = 'trash';
|
||||||
|
$task->order = $this->taskOrderingService->computeOrder($trashTasks, count($trashTasks));
|
||||||
|
$trashTasks[] = $task;
|
||||||
|
$this->taskRepository->save($task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$board->columns = $newColumns;
|
||||||
|
$this->boardRepository->save($board);
|
||||||
|
|
||||||
|
return $this->touchProject($projectId, 'feat(board): update columns');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create or update a project note.
|
||||||
|
*
|
||||||
|
* @param string $projectId Project identifier.
|
||||||
|
* @param array<string, mixed> $payload Note payload.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function saveNote(string $projectId, array $payload): array
|
||||||
|
{
|
||||||
|
$title = trim((string) ($payload['title'] ?? 'Untitled Note'));
|
||||||
|
$existingMeta = [];
|
||||||
|
|
||||||
|
if (isset($payload['id']) && is_string($payload['id']) && $payload['id'] !== '') {
|
||||||
|
foreach ($this->noteRepository->getAll($projectId) as $note) {
|
||||||
|
if ($note->id === $payload['id']) {
|
||||||
|
$existingMeta = $note->meta;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$note = new Note(
|
||||||
|
(string) ($payload['id'] ?? generate_id('note')),
|
||||||
|
$title,
|
||||||
|
$projectId,
|
||||||
|
(string) ($payload['body'] ?? ''),
|
||||||
|
$existingMeta + ['created' => now_iso8601()]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->noteRepository->save($note);
|
||||||
|
|
||||||
|
$state = $this->touchProject($projectId, sprintf('feat(note): save %s', $note->id));
|
||||||
|
$state['note'] = $note->toArray();
|
||||||
|
|
||||||
|
return $state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a client revision before mutating state.
|
||||||
|
*
|
||||||
|
* @param string $projectId Project identifier.
|
||||||
|
* @param string|null $revision Client revision token.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function assertRevision(string $projectId, ?string $revision): void
|
||||||
|
{
|
||||||
|
if ($revision === null || $revision === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($revision !== $this->revisionService->get($projectId)) {
|
||||||
|
throw new \RuntimeException('conflict');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize tasks so they align with the current board columns.
|
||||||
|
*
|
||||||
|
* @param string $projectId Project identifier.
|
||||||
|
* @param Board $board Board definition.
|
||||||
|
* @param Task[] $tasks Task collection.
|
||||||
|
*
|
||||||
|
* @return Task[]
|
||||||
|
*/
|
||||||
|
private function normalizeTasks(string $projectId, Board $board, array $tasks): array
|
||||||
|
{
|
||||||
|
$validColumns = array_map(static fn (Column $column): string => $column->id, $board->columns);
|
||||||
|
$updated = false;
|
||||||
|
$groupedTasks = [];
|
||||||
|
|
||||||
|
foreach ($tasks as $task) {
|
||||||
|
if (!in_array($task->column, $validColumns, true)) {
|
||||||
|
$task->column = isset($task->meta['section']) && is_string($task->meta['section'])
|
||||||
|
? slugify($task->meta['section'])
|
||||||
|
: 'backlog';
|
||||||
|
$updated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$groupedTasks[$task->column][] = $task;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($groupedTasks as $columnTasks) {
|
||||||
|
usort($columnTasks, static fn (Task $left, Task $right): int => $left->order <=> $right->order);
|
||||||
|
|
||||||
|
foreach ($columnTasks as $index => $task) {
|
||||||
|
if ($task->order <= 0) {
|
||||||
|
$task->order = ($index + 1) * 100;
|
||||||
|
$updated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($updated) {
|
||||||
|
foreach ($tasks as $task) {
|
||||||
|
$this->taskRepository->save($task);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->revisionService->bump($projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$columnOrderLookup = [];
|
||||||
|
|
||||||
|
foreach ($board->columns as $column) {
|
||||||
|
$columnOrderLookup[$column->id] = $column->order;
|
||||||
|
}
|
||||||
|
|
||||||
|
usort(
|
||||||
|
$tasks, static function (Task $left, Task $right) use ($columnOrderLookup): int {
|
||||||
|
$leftColumn = $columnOrderLookup[$left->column] ?? PHP_INT_MAX;
|
||||||
|
$rightColumn = $columnOrderLookup[$right->column] ?? PHP_INT_MAX;
|
||||||
|
|
||||||
|
if ($leftColumn === $rightColumn) {
|
||||||
|
return $left->order <=> $right->order;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $leftColumn <=> $rightColumn;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return $tasks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a board contains the requested column.
|
||||||
|
*
|
||||||
|
* @param Board $board Board definition.
|
||||||
|
* @param string $columnId Column identifier.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
private function columnExists(Board $board, string $columnId): bool
|
||||||
|
{
|
||||||
|
foreach ($board->columns as $column) {
|
||||||
|
if ($column->id === $columnId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the board includes a trash column.
|
||||||
|
*
|
||||||
|
* @param Board $board Board definition.
|
||||||
|
*
|
||||||
|
* @return Board
|
||||||
|
*/
|
||||||
|
private function ensureTrashColumn(Board $board): Board
|
||||||
|
{
|
||||||
|
if ($this->columnExists($board, 'trash')) {
|
||||||
|
return $board;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lastOrder = $board->columns === []
|
||||||
|
? 0
|
||||||
|
: max(array_map(static fn (Column $column): int => $column->order, $board->columns));
|
||||||
|
|
||||||
|
$board->columns[] = new Column('trash', 'Trash', $lastOrder + 100);
|
||||||
|
$this->boardRepository->save($board);
|
||||||
|
|
||||||
|
return $board;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether a column needs order rebalancing.
|
||||||
|
*
|
||||||
|
* @param Task[] $tasks Tasks already present in the target column.
|
||||||
|
* @param int $targetIndex Target insertion index.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
private function needsRebalance(array $tasks, int $targetIndex): bool
|
||||||
|
{
|
||||||
|
usort($tasks, static fn (Task $left, Task $right): int => $left->order <=> $right->order);
|
||||||
|
$count = count($tasks);
|
||||||
|
|
||||||
|
if ($count < 2 || $targetIndex <= 0 || $targetIndex >= $count) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ($tasks[$targetIndex]->order - $tasks[$targetIndex - 1]->order) <= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh revision state and attempt a git commit.
|
||||||
|
*
|
||||||
|
* @param string $projectId Project identifier.
|
||||||
|
* @param string $message Commit message.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function touchProject(string $projectId, string $message): array
|
||||||
|
{
|
||||||
|
$revision = $this->revisionService->bump($projectId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->gitService->commit($projectId, $message);
|
||||||
|
} catch (\Throwable $exception) {
|
||||||
|
}
|
||||||
|
|
||||||
|
$state = $this->getBoardState($projectId);
|
||||||
|
$state['revision'] = $revision;
|
||||||
|
|
||||||
|
return $state;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Git integration service.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace IronKanban\Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commits project file changes to git when possible.
|
||||||
|
*/
|
||||||
|
final class GitService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Commit staged project changes to git.
|
||||||
|
*
|
||||||
|
* @param string $projectId Project identifier.
|
||||||
|
* @param string $message Commit message.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function commit(string $projectId, string $message): void
|
||||||
|
{
|
||||||
|
$repoRoot = base_path();
|
||||||
|
$relativeProjectPath = 'storage/projects/' . $projectId;
|
||||||
|
|
||||||
|
$addCommand = sprintf(
|
||||||
|
'git -C %s add -- %s 2>&1',
|
||||||
|
escapeshellarg($repoRoot),
|
||||||
|
escapeshellarg($relativeProjectPath)
|
||||||
|
);
|
||||||
|
exec($addCommand, $addOutput, $addCode);
|
||||||
|
|
||||||
|
if ($addCode !== 0) {
|
||||||
|
throw new \RuntimeException('Git add failed: ' . implode("\n", $addOutput));
|
||||||
|
}
|
||||||
|
|
||||||
|
$statusCommand = sprintf(
|
||||||
|
'git -C %s diff --cached --quiet -- %s 2>&1',
|
||||||
|
escapeshellarg($repoRoot),
|
||||||
|
escapeshellarg($relativeProjectPath)
|
||||||
|
);
|
||||||
|
exec($statusCommand, $statusOutput, $statusCode);
|
||||||
|
|
||||||
|
if ($statusCode === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$commitCommand = sprintf(
|
||||||
|
'git -C %s commit -m %s -- %s 2>&1',
|
||||||
|
escapeshellarg($repoRoot),
|
||||||
|
escapeshellarg($message),
|
||||||
|
escapeshellarg($relativeProjectPath)
|
||||||
|
);
|
||||||
|
exec($commitCommand, $commitOutput, $commitCode);
|
||||||
|
|
||||||
|
if ($commitCode !== 0) {
|
||||||
|
throw new \RuntimeException('Git commit failed: ' . implode("\n", $commitOutput));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revision tracking service.
|
||||||
|
*/
|
||||||
|
// phpcs:disable PEAR.NamingConventions.ValidFunctionName.PrivateNoUnderscore
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace IronKanban\Service;
|
||||||
|
|
||||||
|
use IronKanban\Support\AtomicWriter;
|
||||||
|
use IronKanban\Support\FileLock;
|
||||||
|
use IronKanban\Support\Path;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads and updates per-project revision markers.
|
||||||
|
*/
|
||||||
|
final class RevisionService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the current project revision.
|
||||||
|
*
|
||||||
|
* @param string $projectId Project identifier.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function get(string $projectId): string
|
||||||
|
{
|
||||||
|
$path = $this->revisionPath($projectId);
|
||||||
|
|
||||||
|
if (!is_file($path)) {
|
||||||
|
return '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim((string) file_get_contents($path));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bump the current project revision.
|
||||||
|
*
|
||||||
|
* @param string $projectId Project identifier.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function bump(string $projectId): string
|
||||||
|
{
|
||||||
|
$revision = (string) round(microtime(true) * 1000);
|
||||||
|
|
||||||
|
FileLock::run(
|
||||||
|
$this->lockFile($projectId), static function () use ($projectId, $revision): void {
|
||||||
|
AtomicWriter::write(Path::project($projectId) . DIRECTORY_SEPARATOR . '.revision', $revision . "\n");
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return $revision;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the revision file path.
|
||||||
|
*
|
||||||
|
* @param string $projectId Project identifier.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function revisionPath(string $projectId): string
|
||||||
|
{
|
||||||
|
return Path::project($projectId) . DIRECTORY_SEPARATOR . '.revision';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the revision lock file path.
|
||||||
|
*
|
||||||
|
* @param string $projectId Project identifier.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function lockFile(string $projectId): string
|
||||||
|
{
|
||||||
|
return Path::project($projectId) . DIRECTORY_SEPARATOR . '.revision.lock';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task ordering domain service.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace IronKanban\Service;
|
||||||
|
|
||||||
|
use IronKanban\Domain\Task;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes task ordering values for drag-and-drop moves.
|
||||||
|
*/
|
||||||
|
final class TaskOrderingService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Compute an insertion order for a task move.
|
||||||
|
*
|
||||||
|
* @param Task[] $tasks Existing tasks in the destination column.
|
||||||
|
* @param int $targetIndex Target insertion index.
|
||||||
|
*
|
||||||
|
* @return int
|
||||||
|
*/
|
||||||
|
public function computeOrder(array $tasks, int $targetIndex): int
|
||||||
|
{
|
||||||
|
usort($tasks, static fn (Task $left, Task $right): int => $left->order <=> $right->order);
|
||||||
|
$count = count($tasks);
|
||||||
|
|
||||||
|
if ($count === 0) {
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($targetIndex <= 0) {
|
||||||
|
return max(100, (int) floor($tasks[0]->order / 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($targetIndex >= $count) {
|
||||||
|
return $tasks[$count - 1]->order + 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
$previous = $tasks[$targetIndex - 1]->order;
|
||||||
|
$next = $tasks[$targetIndex]->order;
|
||||||
|
|
||||||
|
if (($next - $previous) <= 1) {
|
||||||
|
return $previous + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) floor(($previous + $next) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebalance task ordering values within a column.
|
||||||
|
*
|
||||||
|
* @param Task[] $tasks Tasks to rebalance.
|
||||||
|
*
|
||||||
|
* @return Task[]
|
||||||
|
*/
|
||||||
|
public function rebalance(array $tasks): array
|
||||||
|
{
|
||||||
|
usort($tasks, static fn (Task $left, Task $right): int => $left->order <=> $right->order);
|
||||||
|
|
||||||
|
foreach ($tasks as $index => $task) {
|
||||||
|
$task->order = ($index + 1) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tasks;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomic file writing support.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace IronKanban\Support;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes files using a temporary file swap.
|
||||||
|
*/
|
||||||
|
final class AtomicWriter
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Atomically write contents to a file.
|
||||||
|
*
|
||||||
|
* @param string $path Destination path.
|
||||||
|
* @param string $contents File contents.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function write(string $path, string $contents): void
|
||||||
|
{
|
||||||
|
ensure_directory(dirname($path));
|
||||||
|
|
||||||
|
$tmpPath = $path . '.tmp-' . bin2hex(random_bytes(4));
|
||||||
|
$handle = fopen($tmpPath, 'wb');
|
||||||
|
|
||||||
|
if ($handle === false) {
|
||||||
|
throw new \RuntimeException('Unable to open temp file: ' . $tmpPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fwrite($handle, $contents) === false) {
|
||||||
|
throw new \RuntimeException('Unable to write temp file: ' . $tmpPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
fflush($handle);
|
||||||
|
} finally {
|
||||||
|
fclose($handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rename($tmpPath, $path)) {
|
||||||
|
@unlink($tmpPath);
|
||||||
|
throw new \RuntimeException('Unable to move temp file into place: ' . $path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File locking support.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace IronKanban\Support;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs callbacks while holding an exclusive file lock.
|
||||||
|
*/
|
||||||
|
final class FileLock
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Execute a callback under an exclusive lock.
|
||||||
|
*
|
||||||
|
* @param string $path Lock file path.
|
||||||
|
* @param callable $callback Callback to execute.
|
||||||
|
*
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
public static function run(string $path, callable $callback): mixed
|
||||||
|
{
|
||||||
|
ensure_directory(dirname($path));
|
||||||
|
$handle = fopen($path, 'c+');
|
||||||
|
|
||||||
|
if ($handle === false) {
|
||||||
|
throw new \RuntimeException('Unable to open lock file: ' . $path);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!flock($handle, LOCK_EX)) {
|
||||||
|
throw new \RuntimeException('Unable to lock file: ' . $path);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $callback();
|
||||||
|
} finally {
|
||||||
|
flock($handle, LOCK_UN);
|
||||||
|
fclose($handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
<?php
|
||||||
|
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Front matter parsing and dumping support.
|
||||||
|
*/
|
||||||
|
// phpcs:disable PEAR.NamingConventions.ValidFunctionName.PrivateNoUnderscore
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace IronKanban\Support;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses and serializes a small YAML-like front matter format.
|
||||||
|
*/
|
||||||
|
final class FrontMatter
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Parse a front matter document from disk.
|
||||||
|
*
|
||||||
|
* @param string $path File path.
|
||||||
|
*
|
||||||
|
* @return FrontMatterDocument
|
||||||
|
*/
|
||||||
|
public static function parseFile(string $path): FrontMatterDocument
|
||||||
|
{
|
||||||
|
$contents = is_file($path) ? (string) file_get_contents($path) : '';
|
||||||
|
|
||||||
|
return self::parse($contents);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a front matter document from a string.
|
||||||
|
*
|
||||||
|
* @param string $contents Raw document contents.
|
||||||
|
*
|
||||||
|
* @return FrontMatterDocument
|
||||||
|
*/
|
||||||
|
public static function parse(string $contents): FrontMatterDocument
|
||||||
|
{
|
||||||
|
$normalized = str_replace(["\r\n", "\r"], "\n", $contents);
|
||||||
|
|
||||||
|
if (!str_starts_with($normalized, "---\n")) {
|
||||||
|
return new FrontMatterDocument([], trim($normalized));
|
||||||
|
}
|
||||||
|
|
||||||
|
$endMarker = strpos($normalized, "\n---\n", 4);
|
||||||
|
|
||||||
|
if ($endMarker === false) {
|
||||||
|
return new FrontMatterDocument([], trim($normalized));
|
||||||
|
}
|
||||||
|
|
||||||
|
$yaml = substr($normalized, 4, $endMarker - 4);
|
||||||
|
$body = substr($normalized, $endMarker + 5);
|
||||||
|
|
||||||
|
return new FrontMatterDocument(self::parseYaml($yaml), ltrim($body, "\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize front matter metadata and body.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $meta Metadata map.
|
||||||
|
* @param string $body Markdown body.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public static function dump(array $meta, string $body): string
|
||||||
|
{
|
||||||
|
$yaml = rtrim(self::dumpYaml($meta));
|
||||||
|
$body = ltrim(str_replace(["\r\n", "\r"], "\n", $body), "\n");
|
||||||
|
|
||||||
|
return "---\n" . $yaml . "\n---\n\n" . $body . "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a YAML-like string into an array.
|
||||||
|
*
|
||||||
|
* @param string $yaml Raw YAML block.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function parseYaml(string $yaml): array
|
||||||
|
{
|
||||||
|
$lines = preg_split("/\n/", $yaml) ?: [];
|
||||||
|
$index = 0;
|
||||||
|
|
||||||
|
return self::parseMap($lines, $index, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an indented map structure.
|
||||||
|
*
|
||||||
|
* @param list<string> $lines Source lines.
|
||||||
|
* @param int $index Current parse index.
|
||||||
|
* @param int $indent Expected indentation level.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function parseMap(array $lines, int &$index, int $indent): array
|
||||||
|
{
|
||||||
|
$result = [];
|
||||||
|
$count = count($lines);
|
||||||
|
|
||||||
|
while ($index < $count) {
|
||||||
|
$rawLine = rtrim($lines[$index]);
|
||||||
|
|
||||||
|
if ($rawLine === '' || preg_match('/^\s*#/', $rawLine) === 1) {
|
||||||
|
$index++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lineIndent = self::indent($rawLine);
|
||||||
|
|
||||||
|
if ($lineIndent < $indent) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($lineIndent > $indent) {
|
||||||
|
$index++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$trimmed = trim($rawLine);
|
||||||
|
|
||||||
|
if (str_starts_with($trimmed, '- ')) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$key, $rest] = array_pad(explode(':', $trimmed, 2), 2, '');
|
||||||
|
$key = trim($key);
|
||||||
|
$rest = ltrim($rest);
|
||||||
|
$index++;
|
||||||
|
|
||||||
|
if ($rest !== '') {
|
||||||
|
$result[$key] = self::parseScalar($rest);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($index >= $count) {
|
||||||
|
$result[$key] = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$nextRaw = rtrim($lines[$index]);
|
||||||
|
|
||||||
|
if ($nextRaw === '') {
|
||||||
|
$result[$key] = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$nextIndent = self::indent($nextRaw);
|
||||||
|
|
||||||
|
if ($nextIndent <= $indent) {
|
||||||
|
$result[$key] = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$nextTrimmed = trim($nextRaw);
|
||||||
|
$result[$key] = str_starts_with($nextTrimmed, '- ')
|
||||||
|
? self::parseList($lines, $index, $nextIndent)
|
||||||
|
: self::parseMap($lines, $index, $nextIndent);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an indented list structure.
|
||||||
|
*
|
||||||
|
* @param list<string> $lines Source lines.
|
||||||
|
* @param int $index Current parse index.
|
||||||
|
* @param int $indent Expected indentation level.
|
||||||
|
*
|
||||||
|
* @return list<mixed>
|
||||||
|
*/
|
||||||
|
private static function parseList(array $lines, int &$index, int $indent): array
|
||||||
|
{
|
||||||
|
$result = [];
|
||||||
|
$count = count($lines);
|
||||||
|
|
||||||
|
while ($index < $count) {
|
||||||
|
$rawLine = rtrim($lines[$index]);
|
||||||
|
|
||||||
|
if ($rawLine === '' || preg_match('/^\s*#/', $rawLine) === 1) {
|
||||||
|
$index++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lineIndent = self::indent($rawLine);
|
||||||
|
|
||||||
|
if ($lineIndent < $indent) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$trimmed = trim($rawLine);
|
||||||
|
|
||||||
|
if (!str_starts_with($trimmed, '- ')) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$itemContent = substr($trimmed, 2);
|
||||||
|
$index++;
|
||||||
|
|
||||||
|
if ($itemContent === '') {
|
||||||
|
$result[] = self::parseMap($lines, $index, $indent + 2);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($itemContent, ':')) {
|
||||||
|
[$inlineKey, $inlineRest] = array_pad(explode(':', $itemContent, 2), 2, '');
|
||||||
|
$item = [trim($inlineKey) => self::parseScalar(ltrim($inlineRest))];
|
||||||
|
|
||||||
|
if ($index < $count) {
|
||||||
|
$nextRaw = rtrim($lines[$index]);
|
||||||
|
$nextIndent = $nextRaw === '' ? 0 : self::indent($nextRaw);
|
||||||
|
|
||||||
|
if ($nextRaw !== '' && $nextIndent > $indent) {
|
||||||
|
$item = array_merge($item, self::parseMap($lines, $index, $indent + 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$result[] = $item;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result[] = self::parseScalar($itemContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize an array into YAML-like text.
|
||||||
|
*
|
||||||
|
* @param array<int|string, mixed> $value Value to serialize.
|
||||||
|
* @param int $indent Indentation level.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private static function dumpYaml(array $value, int $indent = 0): string
|
||||||
|
{
|
||||||
|
$lines = [];
|
||||||
|
$prefix = str_repeat(' ', $indent);
|
||||||
|
|
||||||
|
foreach ($value as $key => $item) {
|
||||||
|
if (is_array($item)) {
|
||||||
|
if (self::isList($item)) {
|
||||||
|
$lines[] = $prefix . $key . ':';
|
||||||
|
|
||||||
|
foreach ($item as $listItem) {
|
||||||
|
if (is_array($listItem)) {
|
||||||
|
$first = true;
|
||||||
|
|
||||||
|
foreach ($listItem as $nestedKey => $nestedValue) {
|
||||||
|
if ($first) {
|
||||||
|
$firstLine = $prefix . ' - ' . $nestedKey . ':';
|
||||||
|
|
||||||
|
if (!is_array($nestedValue)) {
|
||||||
|
$firstLine .= ' ' . self::dumpScalar($nestedValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines[] = $firstLine;
|
||||||
|
$first = false;
|
||||||
|
|
||||||
|
if (!is_array($nestedValue)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} elseif (is_array($nestedValue)) {
|
||||||
|
$lines[] = $prefix . ' ' . $nestedKey . ':';
|
||||||
|
} else {
|
||||||
|
$lines[] = $prefix . ' ' . $nestedKey . ': ' . self::dumpScalar($nestedValue);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($nestedValue)) {
|
||||||
|
$lines[] = rtrim(self::dumpNested($nestedValue, $indent + 6));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$lines[] = $prefix . ' - ' . self::dumpScalar($listItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines[] = $prefix . $key . ':';
|
||||||
|
$lines[] = rtrim(self::dumpYaml($item, $indent + 2));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines[] = $prefix . $key . ': ' . self::dumpScalar($item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode("\n", array_filter($lines, static fn ($line) => $line !== '')) . "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize nested arrays for YAML-like output.
|
||||||
|
*
|
||||||
|
* @param array<int|string, mixed> $value Value to serialize.
|
||||||
|
* @param int $indent Indentation level.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private static function dumpNested(array $value, int $indent): string
|
||||||
|
{
|
||||||
|
if (self::isList($value)) {
|
||||||
|
$lines = [];
|
||||||
|
$prefix = str_repeat(' ', $indent);
|
||||||
|
|
||||||
|
foreach ($value as $item) {
|
||||||
|
if (is_array($item)) {
|
||||||
|
$lines[] = $prefix . '-';
|
||||||
|
$lines[] = rtrim(self::dumpYaml($item, $indent + 2));
|
||||||
|
} else {
|
||||||
|
$lines[] = $prefix . '- ' . self::dumpScalar($item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode("\n", $lines) . "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::dumpYaml($value, $indent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a scalar value from YAML-like text.
|
||||||
|
*
|
||||||
|
* @param string $value Scalar text.
|
||||||
|
*
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
private static function parseScalar(string $value): mixed
|
||||||
|
{
|
||||||
|
$trimmed = trim($value);
|
||||||
|
|
||||||
|
if ($trimmed === 'null' || $trimmed === '~') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($trimmed === 'true') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($trimmed === 'false') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/^-?\d+$/', $trimmed) === 1) {
|
||||||
|
return (int) $trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((str_starts_with($trimmed, '"') && str_ends_with($trimmed, '"'))
|
||||||
|
|| (str_starts_with($trimmed, "'") && str_ends_with($trimmed, "'"))
|
||||||
|
) {
|
||||||
|
return substr($trimmed, 1, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize a scalar value for YAML-like output.
|
||||||
|
*
|
||||||
|
* @param mixed $value Scalar value.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private static function dumpScalar(mixed $value): string
|
||||||
|
{
|
||||||
|
if ($value === null) {
|
||||||
|
return 'null';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_bool($value)) {
|
||||||
|
return $value ? 'true' : 'false';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_int($value) || is_float($value)) {
|
||||||
|
return (string) $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stringValue = (string) $value;
|
||||||
|
|
||||||
|
if ($stringValue === '') {
|
||||||
|
return '""';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/[:#\-\{\}\[\],&\*!|>\'%@`"]|\s$/', $stringValue) === 1 || str_contains($stringValue, "\n")) {
|
||||||
|
return '"' . addcslashes($stringValue, "\\\"") . '"';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $stringValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count the leading spaces for a line.
|
||||||
|
*
|
||||||
|
* @param string $line Input line.
|
||||||
|
*
|
||||||
|
* @return int
|
||||||
|
*/
|
||||||
|
private static function indent(string $line): int
|
||||||
|
{
|
||||||
|
return strlen($line) - strlen(ltrim($line, ' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether an array is a list.
|
||||||
|
*
|
||||||
|
* @param array<int|string, mixed> $value Array value.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
private static function isList(array $value): bool
|
||||||
|
{
|
||||||
|
return $value === [] || array_keys($value) === range(0, count($value) - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsed front matter document value object.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace IronKanban\Support;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds parsed front matter metadata and body content.
|
||||||
|
*/
|
||||||
|
final class FrontMatterDocument
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Create a parsed document container.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $meta Parsed metadata.
|
||||||
|
* @param string $body Markdown body content.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public array $meta,
|
||||||
|
public string $body
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP helper support.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace IronKanban\Support;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small helpers for JSON HTTP responses and request validation.
|
||||||
|
*/
|
||||||
|
final class Http
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Send a JSON response and end execution.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $payload Response payload.
|
||||||
|
* @param int $status HTTP status code.
|
||||||
|
*
|
||||||
|
* @return never
|
||||||
|
*/
|
||||||
|
public static function json(array $payload, int $status = 200): never
|
||||||
|
{
|
||||||
|
http_response_code($status);
|
||||||
|
|
||||||
|
if (!(defined('IKB_TEST_MODE') && IKB_TEST_MODE)) {
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
|
if (defined('IKB_TEST_MODE') && IKB_TEST_MODE) {
|
||||||
|
throw new HttpHalt();
|
||||||
|
}
|
||||||
|
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an error response and end execution.
|
||||||
|
*
|
||||||
|
* @param string $message Error message.
|
||||||
|
* @param int $status HTTP status code.
|
||||||
|
*
|
||||||
|
* @return never
|
||||||
|
*/
|
||||||
|
public static function error(string $message, int $status = 400): never
|
||||||
|
{
|
||||||
|
self::json(
|
||||||
|
[
|
||||||
|
'success' => false,
|
||||||
|
'error' => $message,
|
||||||
|
], $status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse request input from JSON or form data.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function input(): array
|
||||||
|
{
|
||||||
|
$raw = file_get_contents('php://input');
|
||||||
|
|
||||||
|
if ($raw === false || $raw === '') {
|
||||||
|
return $_POST;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($raw, true);
|
||||||
|
|
||||||
|
return is_array($decoded) ? $decoded : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the current request method matches the expected method.
|
||||||
|
*
|
||||||
|
* @param string $method Expected HTTP method.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function requireMethod(string $method): void
|
||||||
|
{
|
||||||
|
if (strtoupper($_SERVER['REQUEST_METHOD'] ?? '') !== strtoupper($method)) {
|
||||||
|
self::error('method_not_allowed', 405);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify the CSRF token for the current request.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function verifyCsrf(): void
|
||||||
|
{
|
||||||
|
$token = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ($_POST['_token'] ?? '');
|
||||||
|
|
||||||
|
if (!is_string($token) || $token === '' || !hash_equals(csrf_token(), $token)) {
|
||||||
|
self::error('invalid_csrf', 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal test-only HTTP halt signal.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace IronKanban\Support;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signals that an HTTP response has been emitted and execution should stop.
|
||||||
|
*/
|
||||||
|
final class HttpHalt extends \RuntimeException
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path helper support.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace IronKanban\Support;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves and validates storage paths.
|
||||||
|
*/
|
||||||
|
final class Path
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Resolve a project's storage path.
|
||||||
|
*
|
||||||
|
* @param string $projectId Project identifier.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public static function project(string $projectId): string
|
||||||
|
{
|
||||||
|
self::assertSlug($projectId);
|
||||||
|
|
||||||
|
ensure_directory(project_root());
|
||||||
|
|
||||||
|
$path = project_root() . DIRECTORY_SEPARATOR . $projectId;
|
||||||
|
ensure_directory($path);
|
||||||
|
|
||||||
|
$resolved = realpath($path) ?: $path;
|
||||||
|
$root = realpath(project_root()) ?: project_root();
|
||||||
|
|
||||||
|
if (!str_starts_with($resolved, $root)) {
|
||||||
|
throw new \RuntimeException('Invalid project path.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert that a value is a valid slug.
|
||||||
|
*
|
||||||
|
* @param string $value Identifier candidate.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function assertSlug(string $value): void
|
||||||
|
{
|
||||||
|
if (preg_match('/^[a-z0-9][a-z0-9\-]*$/', $value) !== 1) {
|
||||||
|
throw new \InvalidArgumentException('Invalid identifier.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<?php
|
||||||
|
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global helper functions used by the application.
|
||||||
|
*/
|
||||||
|
// phpcs:disable PEAR.NamingConventions.ValidFunctionName
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a configuration value.
|
||||||
|
*
|
||||||
|
* @param string $key Configuration key.
|
||||||
|
* @param mixed $default Fallback value.
|
||||||
|
*
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
function config(string $key, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
global $appConfig;
|
||||||
|
|
||||||
|
return $appConfig[$key] ?? $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a path relative to the repository root.
|
||||||
|
*
|
||||||
|
* @param string $path Optional relative path.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
function base_path(string $path = ''): string
|
||||||
|
{
|
||||||
|
$basePath = (string) config('base_path');
|
||||||
|
|
||||||
|
if ($path === '') {
|
||||||
|
return $basePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $basePath . DIRECTORY_SEPARATOR . ltrim($path, DIRECTORY_SEPARATOR);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the project storage root.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
function project_root(): string
|
||||||
|
{
|
||||||
|
return (string) config('project_root');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape a string for HTML output.
|
||||||
|
*
|
||||||
|
* @param string $value Raw value.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
function e(string $value): string
|
||||||
|
{
|
||||||
|
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the current time in ISO-8601 format.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
function now_iso8601(): string
|
||||||
|
{
|
||||||
|
return (new DateTimeImmutable('now'))->format(DateTimeInterface::ATOM);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a string into a slug.
|
||||||
|
*
|
||||||
|
* @param string $value Raw input value.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
function slugify(string $value): string
|
||||||
|
{
|
||||||
|
$value = strtolower(trim($value));
|
||||||
|
$value = preg_replace('/[^a-z0-9]+/', '-', $value) ?? '';
|
||||||
|
$value = trim($value, '-');
|
||||||
|
|
||||||
|
return $value !== '' ? $value : 'item';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a timestamp-based identifier.
|
||||||
|
*
|
||||||
|
* @param string $prefix Identifier prefix.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
function generate_id(string $prefix): string
|
||||||
|
{
|
||||||
|
$timestamp = (new DateTimeImmutable('now'))->format('Ymd-His');
|
||||||
|
$random = substr(bin2hex(random_bytes(4)), 0, 6);
|
||||||
|
|
||||||
|
return $prefix . '-' . $timestamp . '-' . $random;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create the session CSRF token.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
function csrf_token(): string
|
||||||
|
{
|
||||||
|
if (!isset($_SESSION['csrf_token']) || !is_string($_SESSION['csrf_token'])) {
|
||||||
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $_SESSION['csrf_token'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a directory if it does not exist.
|
||||||
|
*
|
||||||
|
* @param string $path Directory path.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
function ensure_directory(string $path): void
|
||||||
|
{
|
||||||
|
if (!is_dir($path) && !mkdir($path, 0777, true) && !is_dir($path)) {
|
||||||
|
throw new RuntimeException('Unable to create directory: ' . $path);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
1775423621208
|
1775422119437
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ id: "note-20260405-161609-3d2504"
|
|||||||
title: New note
|
title: New note
|
||||||
project_id: "demo-project"
|
project_id: "demo-project"
|
||||||
created: "2026-04-05T16:16:09+00:00"
|
created: "2026-04-05T16:16:09+00:00"
|
||||||
updated: "2026-04-05T21:13:40+00:00"
|
updated: "2026-04-05T16:18:43+00:00"
|
||||||
---
|
---
|
||||||
|
|
||||||
what happens when I add another note?
|
what happens when I add another note?
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ completed: false
|
|||||||
priority: high
|
priority: high
|
||||||
is_active: true
|
is_active: true
|
||||||
created: "2026-04-04T18:00:00+00:00"
|
created: "2026-04-04T18:00:00+00:00"
|
||||||
updated: "2026-04-05T21:13:41+00:00"
|
updated: "2026-04-05T16:06:59+00:00"
|
||||||
tags:
|
tags:
|
||||||
- mvp
|
- mvp
|
||||||
- backend
|
- backend
|
||||||
@@ -19,4 +19,3 @@ Implement front matter parsing, atomic writes, and the repositories that read an
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"status": "passed",
|
||||||
|
"failedTests": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
<?php
|
||||||
|
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API integration tests.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Api;
|
||||||
|
|
||||||
|
use IronKanban\Service\BoardService;
|
||||||
|
use Tests\Support\IntegrationTestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Covers JSON endpoint behavior and request validation.
|
||||||
|
*/
|
||||||
|
final class BoardApiTest extends IntegrationTestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Ensure the board-state endpoint returns project and board data.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testBoardStateEndpointReturnsProjectState(): void
|
||||||
|
{
|
||||||
|
$projectId = $this->createProject();
|
||||||
|
|
||||||
|
$response = $this->callApi(
|
||||||
|
'public/api/board-state.php',
|
||||||
|
'GET',
|
||||||
|
['project' => $projectId]
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(200, $response['status']);
|
||||||
|
self::assertSame('Demo Project', $response['payload']['project']['title']);
|
||||||
|
self::assertContains('trash', array_column($response['payload']['columns'], 'id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure state-changing endpoints reject missing CSRF tokens.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testCreateTaskEndpointRejectsMissingCsrfToken(): void
|
||||||
|
{
|
||||||
|
$projectId = $this->createProject();
|
||||||
|
|
||||||
|
$response = $this->callApi(
|
||||||
|
'public/api/create-task.php',
|
||||||
|
'POST',
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
'projectId' => $projectId,
|
||||||
|
'title' => 'Write docs',
|
||||||
|
'column' => 'backlog',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(403, $response['status']);
|
||||||
|
self::assertFalse($response['payload']['success']);
|
||||||
|
self::assertSame('invalid_csrf', $response['payload']['error']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure create-task returns a successful JSON state payload.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testCreateTaskEndpointCreatesTaskWithValidCsrfToken(): void
|
||||||
|
{
|
||||||
|
$projectId = $this->createProject();
|
||||||
|
$token = csrf_token();
|
||||||
|
|
||||||
|
$response = $this->callApi(
|
||||||
|
'public/api/create-task.php',
|
||||||
|
'POST',
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
'projectId' => $projectId,
|
||||||
|
'title' => 'Write docs',
|
||||||
|
'column' => 'backlog',
|
||||||
|
'body' => 'Add the release checklist.',
|
||||||
|
'_token' => $token,
|
||||||
|
],
|
||||||
|
['HTTP_X_CSRF_TOKEN' => $token]
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(200, $response['status']);
|
||||||
|
self::assertTrue($response['payload']['success']);
|
||||||
|
self::assertSame('Write docs', $response['payload']['state']['task']['title']);
|
||||||
|
self::assertSame('backlog', $response['payload']['state']['task']['column']);
|
||||||
|
self::assertCount(1, $response['payload']['state']['tasks']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure stale revisions return a conflict response.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testCreateTaskEndpointReturnsConflictForStaleRevision(): void
|
||||||
|
{
|
||||||
|
$projectId = $this->createProject();
|
||||||
|
$service = new BoardService();
|
||||||
|
$current = $service->getBoardState($projectId);
|
||||||
|
$token = csrf_token();
|
||||||
|
|
||||||
|
$service->createTask($projectId, 'Existing task', 'backlog');
|
||||||
|
|
||||||
|
$response = $this->callApi(
|
||||||
|
'public/api/create-task.php',
|
||||||
|
'POST',
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
'projectId' => $projectId,
|
||||||
|
'title' => 'Conflicting task',
|
||||||
|
'column' => 'ready',
|
||||||
|
'revision' => (string) $current['revision'],
|
||||||
|
'_token' => $token,
|
||||||
|
],
|
||||||
|
['HTTP_X_CSRF_TOKEN' => $token]
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(409, $response['status']);
|
||||||
|
self::assertFalse($response['payload']['success']);
|
||||||
|
self::assertSame('conflict', $response['payload']['error']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure move-task updates the task column and preserves a success response.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testMoveTaskEndpointMovesTaskToRequestedColumn(): void
|
||||||
|
{
|
||||||
|
$projectId = $this->createProject();
|
||||||
|
$service = new BoardService();
|
||||||
|
$created = $service->createTask($projectId, 'Move me', 'backlog');
|
||||||
|
$token = csrf_token();
|
||||||
|
|
||||||
|
$response = $this->callApi(
|
||||||
|
'public/api/move-task.php',
|
||||||
|
'POST',
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
'projectId' => $projectId,
|
||||||
|
'taskId' => $created['task']['id'],
|
||||||
|
'column' => 'done',
|
||||||
|
'index' => 0,
|
||||||
|
'revision' => (string) $created['revision'],
|
||||||
|
'_token' => $token,
|
||||||
|
],
|
||||||
|
['HTTP_X_CSRF_TOKEN' => $token]
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(200, $response['status']);
|
||||||
|
self::assertTrue($response['payload']['success']);
|
||||||
|
self::assertSame('done', $response['payload']['state']['tasks'][0]['column']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure delete-task trashes a task by default.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testDeleteTaskEndpointMovesTaskToTrash(): void
|
||||||
|
{
|
||||||
|
$projectId = $this->createProject();
|
||||||
|
$service = new BoardService();
|
||||||
|
$created = $service->createTask($projectId, 'Archive me', 'review');
|
||||||
|
$token = csrf_token();
|
||||||
|
|
||||||
|
$response = $this->callApi(
|
||||||
|
'public/api/delete-task.php',
|
||||||
|
'POST',
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
'projectId' => $projectId,
|
||||||
|
'taskId' => $created['task']['id'],
|
||||||
|
'revision' => (string) $created['revision'],
|
||||||
|
'_token' => $token,
|
||||||
|
],
|
||||||
|
['HTTP_X_CSRF_TOKEN' => $token]
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(200, $response['status']);
|
||||||
|
self::assertTrue($response['payload']['success']);
|
||||||
|
self::assertSame('trash', $response['payload']['state']['tasks'][0]['column']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure save-note creates a note and returns it in the response state.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testSaveNoteEndpointCreatesNote(): void
|
||||||
|
{
|
||||||
|
$projectId = $this->createProject();
|
||||||
|
$service = new BoardService();
|
||||||
|
$state = $service->getBoardState($projectId);
|
||||||
|
$token = csrf_token();
|
||||||
|
|
||||||
|
$response = $this->callApi(
|
||||||
|
'public/api/save-note.php',
|
||||||
|
'POST',
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
'projectId' => $projectId,
|
||||||
|
'title' => 'Sprint Notes',
|
||||||
|
'body' => 'Capture follow-up items.',
|
||||||
|
'revision' => (string) $state['revision'],
|
||||||
|
'_token' => $token,
|
||||||
|
],
|
||||||
|
['HTTP_X_CSRF_TOKEN' => $token]
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(200, $response['status']);
|
||||||
|
self::assertTrue($response['payload']['success']);
|
||||||
|
self::assertSame('Sprint Notes', $response['payload']['state']['note']['title']);
|
||||||
|
self::assertCount(1, $response['payload']['state']['notes']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure create-column appends a new column to the board.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testCreateColumnEndpointCreatesNewColumn(): void
|
||||||
|
{
|
||||||
|
$projectId = $this->createProject();
|
||||||
|
$service = new BoardService();
|
||||||
|
$state = $service->getBoardState($projectId);
|
||||||
|
$token = csrf_token();
|
||||||
|
|
||||||
|
$response = $this->callApi(
|
||||||
|
'public/api/create-column.php',
|
||||||
|
'POST',
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
'projectId' => $projectId,
|
||||||
|
'label' => 'Blocked',
|
||||||
|
'revision' => (string) $state['revision'],
|
||||||
|
'_token' => $token,
|
||||||
|
],
|
||||||
|
['HTTP_X_CSRF_TOKEN' => $token]
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(200, $response['status']);
|
||||||
|
self::assertTrue($response['payload']['success']);
|
||||||
|
self::assertContains('blocked', array_column($response['payload']['state']['columns'], 'id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure save-task updates persisted task fields.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testSaveTaskEndpointUpdatesTaskFields(): void
|
||||||
|
{
|
||||||
|
$projectId = $this->createProject();
|
||||||
|
$service = new BoardService();
|
||||||
|
$created = $service->createTask($projectId, 'Initial title', 'backlog');
|
||||||
|
$token = csrf_token();
|
||||||
|
|
||||||
|
$response = $this->callApi(
|
||||||
|
'public/api/save-task.php',
|
||||||
|
'POST',
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
'projectId' => $projectId,
|
||||||
|
'taskId' => $created['task']['id'],
|
||||||
|
'title' => 'Updated title',
|
||||||
|
'body' => 'Updated body',
|
||||||
|
'priority' => 'urgent',
|
||||||
|
'completed' => true,
|
||||||
|
'revision' => (string) $created['revision'],
|
||||||
|
'_token' => $token,
|
||||||
|
],
|
||||||
|
['HTTP_X_CSRF_TOKEN' => $token]
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(200, $response['status']);
|
||||||
|
self::assertTrue($response['payload']['success']);
|
||||||
|
self::assertSame('Updated title', $response['payload']['state']['tasks'][0]['title']);
|
||||||
|
self::assertSame("Updated body\n", $response['payload']['state']['tasks'][0]['body']);
|
||||||
|
self::assertSame('urgent', $response['payload']['state']['tasks'][0]['priority']);
|
||||||
|
self::assertTrue($response['payload']['state']['tasks'][0]['completed']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure update-board accepts column replacements and keeps trash available.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testUpdateBoardEndpointReplacesColumnDefinitions(): void
|
||||||
|
{
|
||||||
|
$projectId = $this->createProject();
|
||||||
|
$service = new BoardService();
|
||||||
|
$state = $service->getBoardState($projectId);
|
||||||
|
$token = csrf_token();
|
||||||
|
|
||||||
|
$response = $this->callApi(
|
||||||
|
'public/api/update-board.php',
|
||||||
|
'POST',
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
'projectId' => $projectId,
|
||||||
|
'columns' => [
|
||||||
|
['id' => 'ideas', 'label' => 'Ideas'],
|
||||||
|
['id' => 'doing', 'label' => 'Doing'],
|
||||||
|
],
|
||||||
|
'revision' => (string) $state['revision'],
|
||||||
|
'_token' => $token,
|
||||||
|
],
|
||||||
|
['HTTP_X_CSRF_TOKEN' => $token]
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(200, $response['status']);
|
||||||
|
self::assertTrue($response['payload']['success']);
|
||||||
|
self::assertSame(['ideas', 'doing', 'trash'], array_column($response['payload']['state']['columns'], 'id'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Board domain tests.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Domain;
|
||||||
|
|
||||||
|
use IronKanban\Domain\Board;
|
||||||
|
use IronKanban\Domain\Column;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Covers board serialization behavior.
|
||||||
|
*/
|
||||||
|
final class BoardTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Ensure a board exposes its columns and metadata as arrays.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testToArrayIncludesSerializedColumnsAndMeta(): void
|
||||||
|
{
|
||||||
|
$board = new Board(
|
||||||
|
'demo-project',
|
||||||
|
[
|
||||||
|
new Column('backlog', 'Backlog', 100),
|
||||||
|
new Column('done', 'Done', 200),
|
||||||
|
],
|
||||||
|
['wip_limit' => 3]
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
[
|
||||||
|
'project_id' => 'demo-project',
|
||||||
|
'columns' => [
|
||||||
|
['id' => 'backlog', 'label' => 'Backlog', 'order' => 100],
|
||||||
|
['id' => 'done', 'label' => 'Done', 'order' => 200],
|
||||||
|
],
|
||||||
|
'meta' => ['wip_limit' => 3],
|
||||||
|
],
|
||||||
|
$board->toArray()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Board service integration tests.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Service;
|
||||||
|
|
||||||
|
use IronKanban\Service\BoardService;
|
||||||
|
use Tests\Support\IntegrationTestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Covers core board and task workflows against markdown storage.
|
||||||
|
*/
|
||||||
|
final class BoardServiceTest extends IntegrationTestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Ensure a new project gets the default kanban columns.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testGetBoardStateBuildsDefaultColumnsForNewProject(): void
|
||||||
|
{
|
||||||
|
$projectId = $this->createProject();
|
||||||
|
$service = new BoardService();
|
||||||
|
|
||||||
|
$state = $service->getBoardState($projectId);
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
['backlog', 'ready', 'in-progress', 'review', 'done', 'trash'],
|
||||||
|
array_column($state['columns'], 'id')
|
||||||
|
);
|
||||||
|
self::assertSame([], $state['tasks']);
|
||||||
|
self::assertSame([], $state['notes']);
|
||||||
|
self::assertSame('Demo Project', $state['project']['title']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure a created task is persisted and returned in board state.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testCreateTaskPersistsTaskInRequestedColumn(): void
|
||||||
|
{
|
||||||
|
$projectId = $this->createProject();
|
||||||
|
$service = new BoardService();
|
||||||
|
|
||||||
|
$state = $service->createTask($projectId, 'Ship first milestone', 'ready', 'Write release notes');
|
||||||
|
|
||||||
|
self::assertSame('Ship first milestone', $state['task']['title']);
|
||||||
|
self::assertSame('ready', $state['task']['column']);
|
||||||
|
self::assertSame('Write release notes', $state['task']['body']);
|
||||||
|
self::assertCount(1, $state['tasks']);
|
||||||
|
self::assertSame($state['task']['id'], $state['tasks'][0]['id']);
|
||||||
|
self::assertNotSame('0', $state['revision']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure deleting a task without force moves it into trash.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testDeleteTaskMovesTaskToTrashByDefault(): void
|
||||||
|
{
|
||||||
|
$projectId = $this->createProject();
|
||||||
|
$service = new BoardService();
|
||||||
|
$created = $service->createTask($projectId, 'Clean up copy', 'in-progress');
|
||||||
|
|
||||||
|
$state = $service->deleteTask($projectId, $created['task']['id']);
|
||||||
|
|
||||||
|
self::assertCount(1, $state['tasks']);
|
||||||
|
self::assertSame('trash', $state['tasks'][0]['column']);
|
||||||
|
self::assertSame($created['task']['id'], $state['tasks'][0]['id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure removing a column moves its tasks into trash and keeps trash available.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testUpdateBoardMovesDeletedColumnTasksIntoTrash(): void
|
||||||
|
{
|
||||||
|
$projectId = $this->createProject();
|
||||||
|
$service = new BoardService();
|
||||||
|
|
||||||
|
$service->createTask($projectId, 'Review architecture', 'review');
|
||||||
|
|
||||||
|
$state = $service->updateBoard(
|
||||||
|
$projectId,
|
||||||
|
[
|
||||||
|
['id' => 'backlog', 'label' => 'Backlog'],
|
||||||
|
['id' => 'ready', 'label' => 'Ready'],
|
||||||
|
['id' => 'in-progress', 'label' => 'In Progress'],
|
||||||
|
['id' => 'done', 'label' => 'Done'],
|
||||||
|
['id' => 'trash', 'label' => 'Trash'],
|
||||||
|
],
|
||||||
|
'review'
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertContains('trash', array_column($state['columns'], 'id'));
|
||||||
|
self::assertCount(1, $state['tasks']);
|
||||||
|
self::assertSame('trash', $state['tasks'][0]['column']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Front matter support tests.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Support;
|
||||||
|
|
||||||
|
use IronKanban\Support\FrontMatter;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Covers front matter parsing and dumping.
|
||||||
|
*/
|
||||||
|
final class FrontMatterTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Ensure metadata survives a dump and parse cycle.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testDumpAndParseRoundTrip(): void
|
||||||
|
{
|
||||||
|
$meta = [
|
||||||
|
'title' => 'Plan launch',
|
||||||
|
'completed' => false,
|
||||||
|
'order' => 200,
|
||||||
|
'tags' => ['alpha', 'beta'],
|
||||||
|
'assignee' => [
|
||||||
|
'name' => 'Casey',
|
||||||
|
'role' => 'owner',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
$body = "Ship the first milestone.\n\nKeep the checklist updated.";
|
||||||
|
|
||||||
|
$document = FrontMatter::parse(FrontMatter::dump($meta, $body));
|
||||||
|
|
||||||
|
self::assertSame($meta, $document->meta);
|
||||||
|
self::assertSame($body . "\n", $document->body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure plain markdown without front matter is preserved.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testParseWithoutFrontMatterReturnsBodyOnly(): void
|
||||||
|
{
|
||||||
|
$document = FrontMatter::parse("Just markdown\n\n- item");
|
||||||
|
|
||||||
|
self::assertSame([], $document->meta);
|
||||||
|
self::assertSame("Just markdown\n\n- item", $document->body);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
<?php
|
||||||
|
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared integration test support.
|
||||||
|
*/
|
||||||
|
// phpcs:disable PEAR.NamingConventions.ValidFunctionName.PrivateNoUnderscore
|
||||||
|
// phpcs:disable PEAR.NamingConventions.ValidVariableName.PrivateNoUnderscore
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Support;
|
||||||
|
|
||||||
|
use IronKanban\Support\HttpHalt;
|
||||||
|
use IronKanban\Support\FrontMatter;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use RecursiveDirectoryIterator;
|
||||||
|
use RecursiveIteratorIterator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides an isolated markdown project workspace for integration tests.
|
||||||
|
*/
|
||||||
|
abstract class IntegrationTestCase extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Temporary application root for the current test.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected string $tempRoot;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Temporary project storage root for the current test.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected string $projectRoot;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saved application config to restore after the test.
|
||||||
|
*
|
||||||
|
* @var array<string, mixed>
|
||||||
|
*/
|
||||||
|
private array $_originalAppConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository root used to resolve actual application scripts.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
private string $_repoRoot;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepare an isolated workspace for each test.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
global $appConfig;
|
||||||
|
|
||||||
|
$this->_originalAppConfig = is_array($appConfig) ? $appConfig : [];
|
||||||
|
$this->_repoRoot = (string) ($this->_originalAppConfig['base_path'] ?? dirname(__DIR__, 2));
|
||||||
|
$this->tempRoot = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'ikb-tests-' . bin2hex(random_bytes(6));
|
||||||
|
$this->projectRoot = $this->tempRoot . DIRECTORY_SEPARATOR . 'storage' . DIRECTORY_SEPARATOR . 'projects';
|
||||||
|
|
||||||
|
mkdir($this->projectRoot, 0777, true);
|
||||||
|
|
||||||
|
$appConfig['base_path'] = $this->tempRoot;
|
||||||
|
$appConfig['project_root'] = $this->projectRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore the original workspace after the test.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
global $appConfig;
|
||||||
|
|
||||||
|
$appConfig = $this->_originalAppConfig;
|
||||||
|
$this->_removeDirectory($this->tempRoot);
|
||||||
|
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a markdown-backed project for tests.
|
||||||
|
*
|
||||||
|
* @param string $projectId Project identifier.
|
||||||
|
* @param string $title Project title.
|
||||||
|
* @param string $body Project body content.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected function createProject(string $projectId = 'demo-project', string $title = 'Demo Project', string $body = 'Project body'): string
|
||||||
|
{
|
||||||
|
$projectPath = $this->projectRoot . DIRECTORY_SEPARATOR . $projectId;
|
||||||
|
mkdir($projectPath, 0777, true);
|
||||||
|
|
||||||
|
file_put_contents(
|
||||||
|
$projectPath . DIRECTORY_SEPARATOR . 'index.md',
|
||||||
|
FrontMatter::dump(
|
||||||
|
[
|
||||||
|
'type' => 'project',
|
||||||
|
'id' => $projectId,
|
||||||
|
'title' => $title,
|
||||||
|
],
|
||||||
|
$body
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return $projectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoke an API endpoint script with request globals.
|
||||||
|
*
|
||||||
|
* @param string $script Relative script path from repo root.
|
||||||
|
* @param string $method Request method.
|
||||||
|
* @param array<string, mixed> $get Query parameters.
|
||||||
|
* @param array<string, mixed> $post Form payload.
|
||||||
|
* @param array<string, string> $server Additional server variables.
|
||||||
|
*
|
||||||
|
* @return array{status:int, payload:array<string, mixed>, raw:string}
|
||||||
|
*/
|
||||||
|
protected function callApi(
|
||||||
|
string $script,
|
||||||
|
string $method = 'GET',
|
||||||
|
array $get = [],
|
||||||
|
array $post = [],
|
||||||
|
array $server = []
|
||||||
|
): array {
|
||||||
|
$_GET = $get;
|
||||||
|
$_POST = $post;
|
||||||
|
$_SERVER = array_merge(
|
||||||
|
[
|
||||||
|
'REQUEST_METHOD' => strtoupper($method),
|
||||||
|
],
|
||||||
|
$server
|
||||||
|
);
|
||||||
|
|
||||||
|
http_response_code(200);
|
||||||
|
|
||||||
|
if (function_exists('header_remove')) {
|
||||||
|
header_remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
|
||||||
|
try {
|
||||||
|
include $this->_repoRoot . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $script);
|
||||||
|
} catch (HttpHalt $halt) {
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = (string) ob_get_clean();
|
||||||
|
$decoded = json_decode($raw, true);
|
||||||
|
|
||||||
|
self::assertIsArray($decoded, 'Expected a JSON object response from ' . $script . '.');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => http_response_code(),
|
||||||
|
'payload' => $decoded,
|
||||||
|
'raw' => $raw,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively remove a directory tree.
|
||||||
|
*
|
||||||
|
* @param string $path Directory path.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private function _removeDirectory(string $path): void
|
||||||
|
{
|
||||||
|
if ($path === '' || !is_dir($path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$iterator = new RecursiveIteratorIterator(
|
||||||
|
new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS),
|
||||||
|
RecursiveIteratorIterator::CHILD_FIRST
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($iterator as $item) {
|
||||||
|
if ($item->isDir()) {
|
||||||
|
rmdir($item->getPathname());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
unlink($item->getPathname());
|
||||||
|
}
|
||||||
|
|
||||||
|
rmdir($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PHPUnit bootstrap.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
define('IKB_TEST_MODE', true);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/bootstrap.php';
|
||||||
|
|
||||||
|
spl_autoload_register(
|
||||||
|
static function (string $class): void {
|
||||||
|
$prefix = 'Tests\\';
|
||||||
|
|
||||||
|
if (!str_starts_with($class, $prefix)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$relativeClass = substr($class, strlen($prefix));
|
||||||
|
$file = __DIR__ . '/' . str_replace('\\', '/', $relativeClass) . '.php';
|
||||||
|
|
||||||
|
if (is_file($file)) {
|
||||||
|
include_once $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
test.describe('IronKanban smoke', () => {
|
||||||
|
test('loads the demo board and core UI sections', async ({ page }) => {
|
||||||
|
await page.goto('/?project=demo-project');
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'IronKanban' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Demo Project' })).toBeVisible();
|
||||||
|
await expect(page.locator('#board-columns .column')).toHaveCount(4);
|
||||||
|
await expect(page.locator('#trash-panel')).toBeVisible();
|
||||||
|
await expect(page.locator('#notes-panel')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggles notes and opens existing note and task dialogs', async ({ page }) => {
|
||||||
|
await page.goto('/?project=demo-project');
|
||||||
|
|
||||||
|
const notesToggle = page.getByRole('button', { name: /Show Notes \(\d+\)/ });
|
||||||
|
await notesToggle.click();
|
||||||
|
await expect(page.getByRole('button', { name: /Hide Notes \(\d+\)/ })).toBeVisible();
|
||||||
|
await expect(page.locator('#notes-list .note-card')).toHaveCount(2);
|
||||||
|
|
||||||
|
await page.locator('#notes-list .note-card').first().click();
|
||||||
|
await expect(page.locator('#note-dialog')).toBeVisible();
|
||||||
|
await expect(page.locator('#note-form input[name="title"]')).not.toHaveValue('');
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(page.locator('#note-dialog')).not.toBeVisible();
|
||||||
|
|
||||||
|
await page.locator('#board-columns .task-card').first().click();
|
||||||
|
await expect(page.locator('#task-dialog')).toBeVisible();
|
||||||
|
await expect(page.locator('#task-form input[name="title"]')).not.toHaveValue('');
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(page.locator('#task-dialog')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggles the trash section and shows trashed tasks', async ({ page }) => {
|
||||||
|
await page.goto('/?project=demo-project');
|
||||||
|
|
||||||
|
const trashToggle = page.getByRole('button', { name: /Show Trash \(\d+\)/ });
|
||||||
|
await trashToggle.click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('button', { name: /Hide Trash \(\d+\)/ })).toBeVisible();
|
||||||
|
await expect(page.locator('#trash-columns .task-card')).toHaveCount(1);
|
||||||
|
await expect(page.locator('#trash-dropzone-meta')).toContainText('1 task');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user