Compare commits
29
Commits
92e78e28e6
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
239a7eff64 | ||
|
|
812e5c2f2a | ||
|
|
3af0b9cd0f | ||
|
|
8f02d66f05 | ||
|
|
c594fd421c | ||
|
|
66b2685aff | ||
|
|
550a96a4c3 | ||
|
|
69a705a59f | ||
|
|
fdb9ede115 | ||
|
|
2ca048efad | ||
|
|
ea0bf3ded5 | ||
|
|
2ec56541bb | ||
|
|
7bc44a8664 | ||
|
|
be96113c8c | ||
|
|
ea69c43152 | ||
|
|
27d56754f5 | ||
|
|
ff75bf65b5 | ||
|
|
a7112ca538 | ||
|
|
b21cce7835 | ||
|
|
b8e6120bae | ||
|
|
82be1a8dfb | ||
|
|
c954cda39c | ||
|
|
f9b563edc4 | ||
|
|
11b4e8e638 | ||
|
|
7c73da7e37 | ||
|
|
da5d35793e | ||
|
|
84f1f11da7 | ||
|
|
867b9cdf6e | ||
|
|
da3e117e2d |
@@ -1 +1,8 @@
|
|||||||
|
/node_modules/
|
||||||
|
/vendor/
|
||||||
|
/composer.lock
|
||||||
|
/storage/projects/*/.task.lock
|
||||||
|
/storage/projects/*/.board.lock
|
||||||
|
/storage/projects/*/.notes.lock
|
||||||
|
/storage/projects/*/.revision.lock
|
||||||
phpcs-results.txt
|
phpcs-results.txt
|
||||||
|
|||||||
@@ -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
+12
-13
@@ -1,17 +1,16 @@
|
|||||||
{
|
{
|
||||||
"workbench.colorCustomizations": {
|
"workbench.colorCustomizations": {
|
||||||
"tree.indentGuidesStroke": "#3d92ec",
|
"tree.indentGuidesStroke": "#3d92ec",
|
||||||
"activityBar.background": "#053241",
|
"activityBar.background": "#4C0A64",
|
||||||
"titleBar.activeBackground": "#07465B",
|
"titleBar.activeBackground": "#6A0E8C",
|
||||||
"titleBar.activeForeground": "#F3FBFE",
|
"titleBar.activeForeground": "#FDFAFE",
|
||||||
"titleBar.inactiveBackground": "#053241",
|
"titleBar.inactiveBackground": "#4C0A64",
|
||||||
"titleBar.inactiveForeground": "#F3FBFE",
|
"titleBar.inactiveForeground": "#FDFAFE",
|
||||||
"statusBar.background": "#053241",
|
"statusBar.background": "#4C0A64",
|
||||||
"statusBar.foreground": "#F3FBFE",
|
"statusBar.foreground": "#FDFAFE",
|
||||||
"statusBar.debuggingBackground": "#053241",
|
"statusBar.debuggingBackground": "#4C0A64",
|
||||||
"statusBar.debuggingForeground": "#F3FBFE",
|
"statusBar.debuggingForeground": "#FDFAFE",
|
||||||
"statusBar.noFolderBackground": "#053241",
|
"statusBar.noFolderBackground": "#4C0A64",
|
||||||
"statusBar.noFolderForeground": "#F3FBFE"
|
"statusBar.noFolderForeground": "#FDFAFE"
|
||||||
},
|
}
|
||||||
"phpcs.standard": ".\\phpcs.xml",
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# 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 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
|
|
||||||
```
|
|
||||||
|
|
||||||
I have uploaded a sample project for reference (filenames above are based on the uploaded project). 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.
|
|
||||||
@@ -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';
|
||||||
+26
-23
@@ -1,26 +1,29 @@
|
|||||||
{
|
{
|
||||||
"name": "keith/ironkanban",
|
"name": "ksolo/ironkanban",
|
||||||
"type": "project",
|
"description": "Git-backed markdown kanban board in PHP and vanilla JS.",
|
||||||
"require": {
|
"type": "project",
|
||||||
"php": "^8.2",
|
"require": {
|
||||||
"symfony/yaml": "^7.0"
|
"php": "^8.1"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"phpunit/phpunit": "^11.0"
|
"dealerdirect/phpcodesniffer-composer-installer": "^1.0",
|
||||||
},
|
"phpstan/phpstan": "^1.12",
|
||||||
"autoload": {
|
"phpunit/phpunit": "^11.5",
|
||||||
"psr-4": {
|
"squizlabs/php_codesniffer": "^3.10"
|
||||||
"IronKanban\\": "src/"
|
},
|
||||||
}
|
"autoload": {
|
||||||
},
|
"psr-4": {
|
||||||
"autoload-dev": {
|
"IronKanban\\": "src/"
|
||||||
"psr-4": {
|
|
||||||
"IronKanban\\Tests\\": "tests/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"test": "phpunit",
|
|
||||||
"lint": "phpcs --standard=phpcs.xml --report=full --report-file=phpcs-results.txt .",
|
|
||||||
"lint:fix": "phpcbf --standard=phpcs.xml ."
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"lint": "phpcs",
|
||||||
|
"stan": "phpstan analyse src public --level=6",
|
||||||
|
"test": "phpunit"
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"allow-plugins": {
|
||||||
|
"dealerdirect/phpcodesniffer-composer-installer": true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
-2029
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
<exclude-pattern>node_modules/</exclude-pattern>
|
<exclude-pattern>node_modules/</exclude-pattern>
|
||||||
|
|
||||||
<rule ref="PEAR">
|
<rule ref="PEAR">
|
||||||
|
<exclude name="Generic.Files.LineLength.TooLong"/>
|
||||||
<exclude name="PEAR.Classes.ClassDeclaration"/>
|
<exclude name="PEAR.Classes.ClassDeclaration"/>
|
||||||
<exclude name="PEAR.Functions.FunctionDeclaration"/>
|
<exclude name="PEAR.Functions.FunctionDeclaration"/>
|
||||||
<exclude name="Generic.WhiteSpace.DisallowSpaceIndent.SpacesUsed"/>
|
<exclude name="Generic.WhiteSpace.DisallowSpaceIndent.SpacesUsed"/>
|
||||||
|
|||||||
+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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
-33
@@ -1,16 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
|
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Task Domain Model
|
* Task domain model.
|
||||||
*
|
|
||||||
* PHP version 8.2+
|
|
||||||
*
|
|
||||||
* Represents a task entity with all its properties.
|
|
||||||
*
|
|
||||||
* @category DomainModel
|
|
||||||
* @package IronKanban\Domain
|
|
||||||
* @author Keith Solomon <keith@keithsolomon.net>
|
|
||||||
* @license Unlicense https://unlicense.org
|
|
||||||
* @link https://git.keithsolomon.net/keith/IronKanban
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
@@ -18,29 +10,23 @@ declare(strict_types=1);
|
|||||||
namespace IronKanban\Domain;
|
namespace IronKanban\Domain;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Task class represents a domain model for a task
|
* Represents a task on the board.
|
||||||
*
|
|
||||||
* @category DomainModel
|
|
||||||
* @package IronKanban\Domain
|
|
||||||
* @author Keith Solomon <keith@keithsolomon.net>
|
|
||||||
* @license Unlicense https://unlicense.org
|
|
||||||
* @link https://git.keithsolomon.net/keith/IronKanban
|
|
||||||
*/
|
*/
|
||||||
class Task {
|
final class Task
|
||||||
|
{
|
||||||
/**
|
/**
|
||||||
* Constructor for Task
|
* Create a task instance.
|
||||||
*
|
*
|
||||||
* @param string $id The task identifier.
|
* @param string $id Task identifier.
|
||||||
* @param string $title The task title.
|
* @param string $title Task title.
|
||||||
* @param string $projectId The project identifier.
|
* @param string $projectId Owning project identifier.
|
||||||
* @param string $column The column the task belongs to.
|
* @param string $column Column identifier.
|
||||||
* @param int $order The order of the task.
|
* @param int $order Sort order within the column.
|
||||||
* @param bool $completed Whether the task is completed.
|
* @param bool $completed Completion flag.
|
||||||
* @param string $priority The priority level of the task.
|
* @param string $priority Priority label.
|
||||||
* @param bool $isActive Whether the task is active.
|
* @param bool $isActive Active state flag.
|
||||||
* @param string $body The task description.
|
* @param string $body Markdown body.
|
||||||
* @param string $fileName The task file name.
|
* @param array<string, mixed> $meta Additional task metadata.
|
||||||
* @param array $meta Additional metadata (optional).
|
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public string $id,
|
public string $id,
|
||||||
@@ -52,8 +38,28 @@ class Task {
|
|||||||
public string $priority,
|
public string $priority,
|
||||||
public bool $isActive,
|
public bool $isActive,
|
||||||
public string $body,
|
public string $body,
|
||||||
public string $fileName,
|
public array $meta = []
|
||||||
public array $meta = array()
|
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Front Matter Document
|
|
||||||
*
|
|
||||||
* Represents a parsed markdown document with YAML front matter.
|
|
||||||
*
|
|
||||||
* PHP version 8.2+
|
|
||||||
*
|
|
||||||
* @category Markdown
|
|
||||||
* @package IronKanban\Markdown
|
|
||||||
* @author Keith Solomon <keith@keithsolomon.net>
|
|
||||||
* @license Unlicense https://unlicense.org
|
|
||||||
* @link https://git.keithsolomon.net/keith/IronKanban
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace IronKanban\Markdown;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* FrontMatterDocument represents a markdown document with YAML front matter
|
|
||||||
*
|
|
||||||
* @category Markdown
|
|
||||||
* @package IronKanban\Markdown
|
|
||||||
* @author Keith Solomon <keith@keithsolomon.net>
|
|
||||||
* @license Unlicense https://unlicense.org
|
|
||||||
* @link https://git.keithsolomon.net/keith/IronKanban
|
|
||||||
*/
|
|
||||||
class FrontMatterDocument {
|
|
||||||
/**
|
|
||||||
* Constructor for FrontMatterDocument
|
|
||||||
*
|
|
||||||
* @param array $meta The front matter metadata.
|
|
||||||
* @param string $body The document body content.
|
|
||||||
*/
|
|
||||||
public function __construct( public array $meta, public string $body ) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Front Matter Parser
|
|
||||||
*
|
|
||||||
* Parses markdown files with YAML front matter sections.
|
|
||||||
*
|
|
||||||
* PHP version 8.2+
|
|
||||||
*
|
|
||||||
* @category Markdown
|
|
||||||
* @package IronKanban\Markdown
|
|
||||||
* @author Keith Solomon <keith@keithsolomon.net>
|
|
||||||
* @license Unlicense https://unlicense.org
|
|
||||||
* @link https://git.keithsolomon.net/keith/IronKanban
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare( strict_types=1 );
|
|
||||||
|
|
||||||
namespace IronKanban\Markdown;
|
|
||||||
|
|
||||||
use RuntimeException;
|
|
||||||
use Symfony\Component\Yaml\Exception\ParseException;
|
|
||||||
use Symfony\Component\Yaml\Yaml;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* FrontMatterParser parses markdown files with YAML front matter
|
|
||||||
*
|
|
||||||
* @category Markdown
|
|
||||||
* @package IronKanban\Markdown
|
|
||||||
* @author Keith Solomon <keith@keithsolomon.net>
|
|
||||||
* @license Unlicense https://unlicense.org
|
|
||||||
* @link https://git.keithsolomon.net/keith/IronKanban
|
|
||||||
*/
|
|
||||||
class FrontMatterParser {
|
|
||||||
/**
|
|
||||||
* Parse a markdown file and extract front matter
|
|
||||||
*
|
|
||||||
* @param string $filePath Path to the markdown file.
|
|
||||||
*
|
|
||||||
* @return FrontMatterDocument The parsed document.
|
|
||||||
*
|
|
||||||
* @throws RuntimeException If file cannot be read.
|
|
||||||
*/
|
|
||||||
public function parseFile( string $filePath ): FrontMatterDocument {
|
|
||||||
if (!is_file($filePath)) {
|
|
||||||
throw new RuntimeException("Markdown file not found: {$filePath}");
|
|
||||||
}
|
|
||||||
|
|
||||||
$contents = file_get_contents($filePath);
|
|
||||||
|
|
||||||
if ($contents === false) {
|
|
||||||
throw new RuntimeException("Unable to read markdown file: {$filePath}");
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->parseString($contents);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a markdown string and extract front matter
|
|
||||||
*
|
|
||||||
* @param string $contents The markdown content to parse.
|
|
||||||
*
|
|
||||||
* @return FrontMatterDocument The parsed document.
|
|
||||||
*
|
|
||||||
* @throws RuntimeException If YAML parsing fails.
|
|
||||||
*/
|
|
||||||
public function parseString( string $contents ): FrontMatterDocument {
|
|
||||||
$contents = str_replace(["\r\n", "\r"], "\n", $contents);
|
|
||||||
|
|
||||||
if (!str_starts_with($contents, "---\n")) {
|
|
||||||
return new FrontMatterDocument([], ltrim($contents, "\n"));
|
|
||||||
}
|
|
||||||
|
|
||||||
$closingPos = strpos($contents, "\n---\n", 4);
|
|
||||||
|
|
||||||
if ($closingPos === false) {
|
|
||||||
return new FrontMatterDocument([], ltrim($contents, "\n"));
|
|
||||||
}
|
|
||||||
|
|
||||||
$yamlStart = 4;
|
|
||||||
$yamlLength = $closingPos - $yamlStart;
|
|
||||||
$yamlText = substr($contents, $yamlStart, $yamlLength);
|
|
||||||
$bodyStart = $closingPos + 5;
|
|
||||||
$body = substr($contents, $bodyStart);
|
|
||||||
|
|
||||||
if ($body === false) {
|
|
||||||
$body = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$meta = Yaml::parse($yamlText);
|
|
||||||
} catch ( ParseException $e ) {
|
|
||||||
throw new RuntimeException(
|
|
||||||
'Failed to parse YAML front matter: ' . $e->getMessage(),
|
|
||||||
0,
|
|
||||||
$e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!is_array($meta)) {
|
|
||||||
$meta = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return new FrontMatterDocument($meta, ltrim($body, "\n"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dump a document to markdown string with front matter
|
|
||||||
*
|
|
||||||
* @param FrontMatterDocument $document The document to dump.
|
|
||||||
*
|
|
||||||
* @return string The markdown content with front matter.
|
|
||||||
*/
|
|
||||||
public function dump( FrontMatterDocument $document ): string {
|
|
||||||
$yaml = Yaml::dump(
|
|
||||||
$document->meta,
|
|
||||||
4,
|
|
||||||
2,
|
|
||||||
Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK
|
|
||||||
);
|
|
||||||
|
|
||||||
$body = str_replace(["\r\n", "\r"], "\n", $document->body);
|
|
||||||
$body = ltrim($body, "\n");
|
|
||||||
|
|
||||||
return "---\n{$yaml}---\n\n{$body}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+140
-314
@@ -1,95 +1,53 @@
|
|||||||
<?php
|
<?php
|
||||||
|
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Task Repository
|
* Task persistence repository.
|
||||||
*
|
|
||||||
* Manages persisting and retrieving tasks from markdown files.
|
|
||||||
*
|
|
||||||
* PHP version 8.2+
|
|
||||||
*
|
|
||||||
* @category Repository
|
|
||||||
* @package IronKanban\Repository
|
|
||||||
* @author Keith Solomon <keith@keithsolomon.net>
|
|
||||||
* @license Unlicense https://unlicense.org
|
|
||||||
* @link https://git.keithsolomon.net/keith/IronKanban
|
|
||||||
*/
|
*/
|
||||||
|
// phpcs:disable PEAR.NamingConventions.ValidFunctionName.PrivateNoUnderscore
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace IronKanban\Repository;
|
namespace IronKanban\Repository;
|
||||||
|
|
||||||
use IronKanban\Domain\Task;
|
use IronKanban\Domain\Task;
|
||||||
use IronKanban\Markdown\FrontMatterParser;
|
use IronKanban\Support\AtomicWriter;
|
||||||
use IronKanban\Support\FileLock;
|
use IronKanban\Support\FileLock;
|
||||||
use IronKanban\Support\IdGenerator;
|
use IronKanban\Support\FrontMatter;
|
||||||
use IronKanban\Support\Paths;
|
use IronKanban\Support\FrontMatterDocument;
|
||||||
use IronKanban\Support\TaskMapper;
|
use IronKanban\Support\Path;
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TaskRepository manages task persistence and retrieval
|
* Loads and stores project tasks.
|
||||||
*
|
|
||||||
* @category Repository
|
|
||||||
* @package IronKanban\Repository
|
|
||||||
* @author Keith Solomon <keith@keithsolomon.net>
|
|
||||||
* @license Unlicense https://unlicense.org
|
|
||||||
* @link https://git.keithsolomon.net/keith/IronKanban
|
|
||||||
*/
|
*/
|
||||||
class TaskRepository {
|
final class TaskRepository
|
||||||
|
{
|
||||||
/**
|
/**
|
||||||
* Constructor for TaskRepository
|
* Load all tasks for a project.
|
||||||
*
|
*
|
||||||
* @param Paths $paths The paths helper
|
* @param string $projectId Project identifier.
|
||||||
* @param FrontMatterParser $frontMatterParser The front matter parser
|
*
|
||||||
* @param TaskMapper $taskMapper The task mapper
|
* @return Task[]
|
||||||
* @param IdGenerator $idGenerator The ID generator
|
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function getAll(string $projectId): array
|
||||||
private readonly Paths $paths,
|
{
|
||||||
private readonly FrontMatterParser $frontMatterParser,
|
$tasksPath = $this->tasksPath($projectId);
|
||||||
private readonly TaskMapper $taskMapper,
|
ensure_directory($tasksPath);
|
||||||
private readonly IdGenerator $idGenerator
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all tasks for a project
|
|
||||||
*
|
|
||||||
* @param string $projectId The project identifier
|
|
||||||
*
|
|
||||||
* @return Task[] Array of tasks
|
|
||||||
*/
|
|
||||||
public function getAll(string $projectId): array {
|
|
||||||
$tasksPath = $this->paths->getTasksPath($projectId);
|
|
||||||
$files = glob($tasksPath . DIRECTORY_SEPARATOR . '*.md');
|
|
||||||
|
|
||||||
if ($files === false) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$tasks = [];
|
$tasks = [];
|
||||||
|
$files = glob($tasksPath . DIRECTORY_SEPARATOR . '*.md') ?: [];
|
||||||
|
|
||||||
foreach ($files as $filePath) {
|
foreach ($files as $file) {
|
||||||
if (!is_file($filePath)) {
|
$tasks[] = $this->hydrate(FrontMatter::parseFile($file));
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$fileName = basename($filePath);
|
|
||||||
$document = $this->frontMatterParser->parseFile($filePath);
|
|
||||||
|
|
||||||
$tasks[] = $this->taskMapper->fromDocument(
|
|
||||||
$projectId,
|
|
||||||
$fileName,
|
|
||||||
$document
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
usort(
|
usort(
|
||||||
$tasks, function (Task $a, Task $b): int {
|
$tasks, static function (Task $left, Task $right): int {
|
||||||
if ($a->column === $b->column) {
|
if ($left->column === $right->column) {
|
||||||
return $a->order <=> $b->order;
|
return $left->order <=> $right->order ?: strcasecmp($left->title, $right->title);
|
||||||
}
|
}
|
||||||
|
|
||||||
return strcmp($a->column, $b->column);
|
return strcmp($left->column, $right->column);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -97,113 +55,96 @@ class TaskRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a specific task by ID
|
* Load a single task by identifier.
|
||||||
*
|
*
|
||||||
* @param string $projectId The project identifier
|
* @param string $projectId Project identifier.
|
||||||
* @param string $taskId The task identifier
|
* @param string $taskId Task identifier.
|
||||||
*
|
*
|
||||||
* @return Task The task
|
* @return Task
|
||||||
*
|
|
||||||
* @throws RuntimeException If task not found
|
|
||||||
*/
|
*/
|
||||||
public function get(string $projectId, string $taskId): Task {
|
public function get(string $projectId, string $taskId): Task
|
||||||
foreach ($this->getAll($projectId) as $task) {
|
{
|
||||||
if ($task->id === $taskId) {
|
Path::assertSlug($taskId);
|
||||||
return $task;
|
$path = $this->taskFile($projectId, $taskId);
|
||||||
}
|
|
||||||
|
if (!is_file($path)) {
|
||||||
|
throw new \RuntimeException('Task not found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new RuntimeException("Task not found: {$taskId}");
|
return $this->hydrate(FrontMatter::parseFile($path));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save a task
|
* Persist a task.
|
||||||
*
|
*
|
||||||
* @param Task $task The task to save
|
* @param Task $task Task instance.
|
||||||
*
|
*
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function save(Task $task): void {
|
public function save(Task $task): void
|
||||||
$this->paths->ensureTasksPath($task->projectId);
|
{
|
||||||
|
$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();
|
||||||
|
|
||||||
$filePath = $this->paths->getTaskFilePath($task->projectId, $task->fileName);
|
$meta = [
|
||||||
$lockPath = $filePath . '.lock';
|
'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(
|
FileLock::run(
|
||||||
$lockPath,
|
$this->lockFile($task->projectId), static function () use ($path, $meta, $task): void {
|
||||||
function () use ($task, $filePath): void {
|
AtomicWriter::write($path, FrontMatter::dump($meta, $task->body));
|
||||||
$document = $this->taskMapper->toDocument($task);
|
|
||||||
$contents = $this->frontMatterParser->dump($document);
|
|
||||||
|
|
||||||
$this->_atomicWrite($filePath, $contents);
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new task
|
* Create and persist a new task from raw data.
|
||||||
*
|
*
|
||||||
* @param array $data The task data
|
* @param array<string, mixed> $data Task seed data.
|
||||||
*
|
*
|
||||||
* @return Task The created task
|
* @return Task
|
||||||
*
|
|
||||||
* @throws RuntimeException If required fields are missing
|
|
||||||
*/
|
*/
|
||||||
public function create(array $data): Task {
|
public function create(array $data): Task
|
||||||
$projectId = $this->_requireString($data, 'projectId');
|
{
|
||||||
$title = $this->_requireString($data, 'title');
|
|
||||||
$body = (string) ($data['body'] ?? '');
|
|
||||||
$column = $this->taskMapper->normalizeColumn(
|
|
||||||
(string) ($data['column'] ?? 'backlog')
|
|
||||||
);
|
|
||||||
$priority = (string) ($data['priority'] ?? 'normal');
|
|
||||||
$completed = (bool) ($data['completed'] ?? false);
|
|
||||||
$isActive = (bool) ($data['isActive'] ?? true);
|
|
||||||
$order = isset($data['order']) ? (int) $data['order'] : 100;
|
|
||||||
$now = gmdate('c');
|
|
||||||
|
|
||||||
$taskId = $this->idGenerator->generateTaskId();
|
|
||||||
$fileName = $this->idGenerator->generateTaskFileName($taskId);
|
|
||||||
|
|
||||||
$meta = [
|
|
||||||
'id' => $taskId,
|
|
||||||
'type' => 'task',
|
|
||||||
'title' => $title,
|
|
||||||
'project_id' => $projectId,
|
|
||||||
'column' => $column,
|
|
||||||
'order' => $order,
|
|
||||||
'completed' => $completed,
|
|
||||||
'priority' => $priority,
|
|
||||||
'is_active' => $isActive,
|
|
||||||
'created' => $now,
|
|
||||||
'updated' => $now,
|
|
||||||
'tags' => [],
|
|
||||||
];
|
|
||||||
|
|
||||||
if (array_key_exists('status', $data)) {
|
|
||||||
$meta['status'] = $data['status'];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (array_key_exists('statusNote', $data)) {
|
|
||||||
$meta['status_note'] = $data['statusNote'];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (array_key_exists('section', $data)) {
|
|
||||||
$meta['section'] = $data['section'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$task = new Task(
|
$task = new Task(
|
||||||
id: $taskId,
|
(string) ($data['id'] ?? generate_id('task')),
|
||||||
title: $title,
|
trim((string) ($data['title'] ?? 'Untitled Task')),
|
||||||
projectId: $projectId,
|
(string) $data['projectId'],
|
||||||
column: $column,
|
(string) ($data['column'] ?? 'backlog'),
|
||||||
order: $order,
|
(int) ($data['order'] ?? 100),
|
||||||
completed: $completed,
|
(bool) ($data['completed'] ?? false),
|
||||||
priority: $priority,
|
(string) ($data['priority'] ?? 'normal'),
|
||||||
isActive: $isActive,
|
(bool) ($data['is_active'] ?? true),
|
||||||
body: $body,
|
(string) ($data['body'] ?? ''),
|
||||||
fileName: $fileName,
|
[
|
||||||
meta: $meta
|
'created' => now_iso8601(),
|
||||||
|
'updated' => now_iso8601(),
|
||||||
|
'tags' => [],
|
||||||
|
] + (array) ($data['meta'] ?? [])
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->save($task);
|
$this->save($task);
|
||||||
@@ -212,204 +153,89 @@ class TaskRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a task
|
* Delete a persisted task if it exists.
|
||||||
*
|
*
|
||||||
* @param string $projectId The project identifier
|
* @param string $projectId Project identifier.
|
||||||
* @param string $taskId The task identifier
|
* @param string $taskId Task identifier.
|
||||||
*
|
*
|
||||||
* @return void
|
* @return void
|
||||||
*
|
|
||||||
* @throws RuntimeException If task not found
|
|
||||||
*/
|
*/
|
||||||
public function delete(string $projectId, string $taskId): void {
|
public function delete(string $projectId, string $taskId): void
|
||||||
$task = $this->get($projectId, $taskId);
|
{
|
||||||
$filePath = $this->paths->getTaskFilePath($projectId, $task->fileName);
|
$path = $this->taskFile($projectId, $taskId);
|
||||||
$lockPath = $filePath . '.lock';
|
|
||||||
|
if (!is_file($path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
FileLock::run(
|
FileLock::run(
|
||||||
$lockPath,
|
$this->lockFile($projectId), static function () use ($path): void {
|
||||||
function () use ($filePath): void {
|
if (!unlink($path)) {
|
||||||
if (!is_file($filePath)) {
|
throw new \RuntimeException('Unable to delete task.');
|
||||||
throw new RuntimeException("Task file not found: {$filePath}");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!unlink($filePath)) {
|
|
||||||
throw new RuntimeException(
|
|
||||||
"Unable to delete task file: {$filePath}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map a markdown file to a task
|
* Hydrate a task from a parsed markdown document.
|
||||||
*
|
*
|
||||||
* @param string $projectId The project identifier
|
* @param FrontMatterDocument $document Parsed document.
|
||||||
* @param string $filePath The file path to the task
|
|
||||||
*
|
*
|
||||||
* @return Task The mapped task
|
* @return Task
|
||||||
*/
|
*/
|
||||||
private function _mapFileToTask(string $projectId, string $filePath): Task {
|
private function hydrate(FrontMatterDocument $document): Task
|
||||||
$document = $this->frontMatterParser->parseFile($filePath);
|
{
|
||||||
$meta = $document->meta;
|
$meta = $document->meta;
|
||||||
|
|
||||||
$id = (string) ($meta['id'] ?? pathinfo($filePath, PATHINFO_FILENAME));
|
|
||||||
$title = (string) ($meta['title'] ?? $id);
|
|
||||||
|
|
||||||
$column = isset($meta['column'])
|
|
||||||
? $this->_normalizeColumn((string) $meta['column'])
|
|
||||||
: $this->_mapLegacySectionToColumn((string) ($meta['section'] ?? 'backlog'));
|
|
||||||
|
|
||||||
$order = isset($meta['order']) ? (int) $meta['order'] : 100;
|
|
||||||
$completed = (bool) ($meta['completed'] ?? false);
|
|
||||||
$priority = (string) ($meta['priority'] ?? 'normal');
|
|
||||||
$isActive = (bool) ($meta['is_active'] ?? true);
|
|
||||||
|
|
||||||
return new Task(
|
return new Task(
|
||||||
id: $id,
|
(string) ($meta['id'] ?? ''),
|
||||||
title: $title,
|
(string) ($meta['title'] ?? 'Untitled Task'),
|
||||||
projectId: (string) ($meta['project_id'] ?? $projectId),
|
(string) ($meta['project_id'] ?? ''),
|
||||||
column: $column,
|
(string) ($meta['column'] ?? ($meta['section'] ?? 'backlog')),
|
||||||
order: $order,
|
(int) ($meta['order'] ?? 100),
|
||||||
completed: $completed,
|
(bool) ($meta['completed'] ?? false),
|
||||||
priority: $priority,
|
(string) ($meta['priority'] ?? 'normal'),
|
||||||
isActive: $isActive,
|
(bool) ($meta['is_active'] ?? true),
|
||||||
body: $document->body,
|
$document->body,
|
||||||
meta: $meta
|
$meta
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map a task to a front matter document
|
* Resolve the tasks directory path.
|
||||||
*
|
*
|
||||||
* @param Task $task The task to map
|
* @param string $projectId Project identifier.
|
||||||
*
|
*
|
||||||
* @return FrontMatterDocument The mapped document
|
* @return string
|
||||||
*/
|
*/
|
||||||
private function _mapTaskToDocument(Task $task): FrontMatterDocument {
|
private function tasksPath(string $projectId): string
|
||||||
$meta = $task->meta;
|
{
|
||||||
|
return Path::project($projectId) . DIRECTORY_SEPARATOR . 'tasks';
|
||||||
$meta['id'] = $task->id;
|
|
||||||
$meta['type'] = 'task';
|
|
||||||
$meta['title'] = $task->title;
|
|
||||||
$meta['project_id'] = $task->projectId;
|
|
||||||
$meta['column'] = $task->column;
|
|
||||||
$meta['order'] = $task->order;
|
|
||||||
$meta['completed'] = $task->completed;
|
|
||||||
$meta['priority'] = $task->priority;
|
|
||||||
$meta['is_active'] = $task->isActive;
|
|
||||||
|
|
||||||
if (!isset($meta['created']) || (string) $meta['created'] === '') {
|
|
||||||
$meta['created'] = gmdate('c');
|
|
||||||
}
|
|
||||||
|
|
||||||
$meta['updated'] = gmdate('c');
|
|
||||||
|
|
||||||
return new FrontMatterDocument($meta, $task->body);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Atomically write contents to a file
|
* Resolve a task file path.
|
||||||
*
|
*
|
||||||
* @param string $filePath The file path
|
* @param string $projectId Project identifier.
|
||||||
* @param string $contents The contents to write
|
* @param string $taskId Task identifier.
|
||||||
*
|
*
|
||||||
* @return void
|
* @return string
|
||||||
*
|
|
||||||
* @throws RuntimeException If write operation fails
|
|
||||||
*/
|
*/
|
||||||
private function _atomicWrite(string $filePath, string $contents): void {
|
private function taskFile(string $projectId, string $taskId): string
|
||||||
$directory = dirname($filePath);
|
{
|
||||||
$tempPath = tempnam($directory, 'ikb_');
|
return $this->tasksPath($projectId) . DIRECTORY_SEPARATOR . $taskId . '.md';
|
||||||
|
|
||||||
if ($tempPath === false) {
|
|
||||||
throw new RuntimeException(
|
|
||||||
"Unable to create temp file in: {$directory}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$bytesWritten = file_put_contents($tempPath, $contents);
|
|
||||||
|
|
||||||
if ($bytesWritten === false) {
|
|
||||||
throw new RuntimeException(
|
|
||||||
"Unable to write temp file: {$tempPath}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!rename($tempPath, $filePath)) {
|
|
||||||
throw new RuntimeException(
|
|
||||||
"Unable to move temp file into place: {$filePath}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (is_file($tempPath)) {
|
|
||||||
@unlink($tempPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a unique task ID
|
* Resolve the task lock file path.
|
||||||
*
|
*
|
||||||
* @return string The generated task ID
|
* @param string $projectId Project identifier.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
*/
|
*/
|
||||||
private function _generateTaskId(): string {
|
private function lockFile(string $projectId): string
|
||||||
$randID = substr(bin2hex(random_bytes(3)), 0, 6);
|
{
|
||||||
$taskID = 'task-' . gmdate('Ymd-His') . '-' . $randID;
|
return Path::project($projectId) . DIRECTORY_SEPARATOR . '.task.lock';
|
||||||
|
|
||||||
return $taskID;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Require a string field from data array
|
|
||||||
*
|
|
||||||
* @param array $data The data array
|
|
||||||
* @param string $key The field key
|
|
||||||
*
|
|
||||||
* @return string The string value
|
|
||||||
*
|
|
||||||
* @throws RuntimeException If field is missing or empty
|
|
||||||
*/
|
|
||||||
private function _requireString(array $data, string $key): string {
|
|
||||||
$value = trim((string) ($data[$key] ?? ''));
|
|
||||||
|
|
||||||
if ($value === '') {
|
|
||||||
throw new RuntimeException("Missing required field: {$key}");
|
|
||||||
}
|
|
||||||
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize a column name
|
|
||||||
*
|
|
||||||
* @param string $column The column name to normalize
|
|
||||||
*
|
|
||||||
* @return string The normalized column name
|
|
||||||
*/
|
|
||||||
private function _normalizeColumn(string $column): string {
|
|
||||||
$column = trim(strtolower($column));
|
|
||||||
|
|
||||||
if ($column === '') {
|
|
||||||
return 'backlog';
|
|
||||||
}
|
|
||||||
|
|
||||||
$column = preg_replace('/[^a-z0-9]+/', '-', $column) ?? 'backlog';
|
|
||||||
$column = trim($column, '-');
|
|
||||||
|
|
||||||
return $column !== '' ? $column : 'backlog';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map legacy section to column name
|
|
||||||
*
|
|
||||||
* @param string $section The legacy section name
|
|
||||||
*
|
|
||||||
* @return string The modern column name
|
|
||||||
*/
|
|
||||||
private function _mapLegacySectionToColumn(string $section): string {
|
|
||||||
return $this->_normalizeColumn($section);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
-31
@@ -1,54 +1,39 @@
|
|||||||
<?php
|
<?php
|
||||||
|
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* File Lock
|
* File locking support.
|
||||||
*
|
|
||||||
* Provides file-based locking mechanism for concurrent access control.
|
|
||||||
*
|
|
||||||
* PHP version 8.2+
|
|
||||||
*
|
|
||||||
* @category Support
|
|
||||||
* @package IronKanban\Support
|
|
||||||
* @author Keith Solomon <keith@keithsolomon.net>
|
|
||||||
* @license Unlicense https://unlicense.org
|
|
||||||
* @link https://git.keithsolomon.net/keith/IronKanban
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace IronKanban\Support;
|
namespace IronKanban\Support;
|
||||||
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FileLock provides file-based locking mechanism
|
* Runs callbacks while holding an exclusive file lock.
|
||||||
*
|
|
||||||
* @category Support
|
|
||||||
* @package IronKanban\Support
|
|
||||||
* @author Keith Solomon <keith@keithsolomon.net>
|
|
||||||
* @license Unlicense https://unlicense.org
|
|
||||||
* @link https://git.keithsolomon.net/keith/IronKanban
|
|
||||||
*/
|
*/
|
||||||
class FileLock {
|
final class FileLock
|
||||||
|
{
|
||||||
/**
|
/**
|
||||||
* Run a callback with a file lock
|
* Execute a callback under an exclusive lock.
|
||||||
*
|
*
|
||||||
* @param string $lockPath The path to the lock file
|
* @param string $path Lock file path.
|
||||||
* @param callable $callback The callback to execute
|
* @param callable $callback Callback to execute.
|
||||||
*
|
*
|
||||||
* @return mixed The result of the callback
|
* @return mixed
|
||||||
*
|
|
||||||
* @throws RuntimeException If lock cannot be acquired
|
|
||||||
*/
|
*/
|
||||||
public static function run(string $lockPath, callable $callback): mixed {
|
public static function run(string $path, callable $callback): mixed
|
||||||
$handle = fopen($lockPath, 'c+');
|
{
|
||||||
|
ensure_directory(dirname($path));
|
||||||
|
$handle = fopen($path, 'c+');
|
||||||
|
|
||||||
if ($handle === false) {
|
if ($handle === false) {
|
||||||
throw new RuntimeException("Unable to open lock file: {$lockPath}");
|
throw new \RuntimeException('Unable to open lock file: ' . $path);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!flock($handle, LOCK_EX)) {
|
if (!flock($handle, LOCK_EX)) {
|
||||||
throw new RuntimeException("Unable to acquire lock: {$lockPath}");
|
throw new \RuntimeException('Unable to lock file: ' . $path);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $callback();
|
return $callback();
|
||||||
|
|||||||
@@ -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
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* ID Generator
|
|
||||||
*
|
|
||||||
* PHP version 8.2+
|
|
||||||
*
|
|
||||||
* Generates unique identifiers for tasks and related files.
|
|
||||||
*
|
|
||||||
* @category Support
|
|
||||||
* @package IronKanban\Support
|
|
||||||
* @author Keith Solomon <keith@keithsolomon.net>
|
|
||||||
* @license Unlicense https://unlicense.org
|
|
||||||
* @link https://git.keithsolomon.net/keith/IronKanban
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace IronKanban\Support;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* IdGenerator generates unique identifiers
|
|
||||||
*
|
|
||||||
* @category Support
|
|
||||||
* @package IronKanban\Support
|
|
||||||
* @author Keith Solomon <keith@keithsolomon.net>
|
|
||||||
* @license Unlicense https://unlicense.org
|
|
||||||
* @link https://git.keithsolomon.net/keith/IronKanban
|
|
||||||
*/
|
|
||||||
class IdGenerator {
|
|
||||||
/**
|
|
||||||
* Generate a unique task ID
|
|
||||||
*
|
|
||||||
* @return string The generated task ID
|
|
||||||
*/
|
|
||||||
public function generateTaskId(): string {
|
|
||||||
return 'task-' . gmdate('Ymd-His') . '-' .
|
|
||||||
substr(bin2hex(random_bytes(3)), 0, 6);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a task file name
|
|
||||||
*
|
|
||||||
* @param string $taskId The task identifier
|
|
||||||
*
|
|
||||||
* @return string The generated file name
|
|
||||||
*/
|
|
||||||
public function generateTaskFileName(string $taskId): string {
|
|
||||||
return $taskId . '.md';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Paths Helper
|
|
||||||
*
|
|
||||||
* Manages path construction and validation for projects and tasks.
|
|
||||||
*
|
|
||||||
* PHP version 8.2+
|
|
||||||
*
|
|
||||||
* @category Support
|
|
||||||
* @package IronKanban\Support
|
|
||||||
* @author Keith Solomon <keith@keithsolomon.net>
|
|
||||||
* @license Unlicense https://unlicense.org
|
|
||||||
* @link https://git.keithsolomon.net/keith/IronKanban
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace IronKanban\Support;
|
|
||||||
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Paths manages path construction and validation
|
|
||||||
*
|
|
||||||
* @category Support
|
|
||||||
* @package IronKanban\Support
|
|
||||||
* @author Keith Solomon <keith@keithsolomon.net>
|
|
||||||
* @license Unlicense https://unlicense.org
|
|
||||||
* @link https://git.keithsolomon.net/keith/IronKanban
|
|
||||||
*/
|
|
||||||
class Paths {
|
|
||||||
/**
|
|
||||||
* Constructor for Paths
|
|
||||||
*
|
|
||||||
* @param string $projectRoot The root directory of the project
|
|
||||||
*
|
|
||||||
* @throws RuntimeException If the project root is invalid
|
|
||||||
*/
|
|
||||||
public function __construct( private string $projectRoot ) {
|
|
||||||
$realRoot = realpath($projectRoot);
|
|
||||||
|
|
||||||
if ($realRoot === false || !is_dir($realRoot)) {
|
|
||||||
throw new RuntimeException("Invalid project root: {$projectRoot}");
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->projectRoot = $realRoot;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the project root directory
|
|
||||||
*
|
|
||||||
* @return string The project root path
|
|
||||||
*/
|
|
||||||
public function getProjectRoot(): string {
|
|
||||||
return $this->projectRoot;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the path for a specific project
|
|
||||||
*
|
|
||||||
* @param string $projectId The project identifier
|
|
||||||
*
|
|
||||||
* @return string The project path
|
|
||||||
*
|
|
||||||
* @throws RuntimeException If project not found
|
|
||||||
*/
|
|
||||||
public function getProjectPath(string $projectId): string {
|
|
||||||
$safeProjectId = $this->_sanitizeSegment($projectId);
|
|
||||||
$path = $this->projectRoot . DIRECTORY_SEPARATOR . $safeProjectId;
|
|
||||||
|
|
||||||
if (!is_dir($path)) {
|
|
||||||
throw new RuntimeException("Project not found: {$projectId}");
|
|
||||||
}
|
|
||||||
|
|
||||||
return $path;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the tasks directory path for a project
|
|
||||||
*
|
|
||||||
* @param string $projectId The project identifier
|
|
||||||
*
|
|
||||||
* @return string The tasks directory path
|
|
||||||
*
|
|
||||||
* @throws RuntimeException If tasks directory not found
|
|
||||||
*/
|
|
||||||
public function getTasksPath(string $projectId): string {
|
|
||||||
$path = $this->getProjectPath($projectId) . DIRECTORY_SEPARATOR . 'tasks';
|
|
||||||
|
|
||||||
if (!is_dir($path)) {
|
|
||||||
throw new RuntimeException(
|
|
||||||
"Tasks directory not found for project: {$projectId}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $path;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the file path for a specific task
|
|
||||||
*
|
|
||||||
* @param string $projectId The project identifier
|
|
||||||
* @param string $taskId The task identifier
|
|
||||||
*
|
|
||||||
* @return string The task file path
|
|
||||||
*/
|
|
||||||
public function getTaskFilePath(string $projectId, string $taskId): string {
|
|
||||||
$tasksPath = $this->getTasksPath($projectId);
|
|
||||||
$safeTaskId = $this->_sanitizeSegment($taskId);
|
|
||||||
|
|
||||||
return $tasksPath . DIRECTORY_SEPARATOR . $safeTaskId . '.md';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure tasks directory exists
|
|
||||||
*
|
|
||||||
* @param string $projectId The project identifier
|
|
||||||
*
|
|
||||||
* @return string The tasks directory path
|
|
||||||
*
|
|
||||||
* @throws RuntimeException If directory cannot be created
|
|
||||||
*/
|
|
||||||
public function ensureTasksPath(string $projectId): string {
|
|
||||||
$path = $this->getProjectPath($projectId) . DIRECTORY_SEPARATOR . 'tasks';
|
|
||||||
|
|
||||||
if (!is_dir($path) && !mkdir($path, 0775, true) && !is_dir($path)) {
|
|
||||||
throw new RuntimeException("Unable to create tasks directory: {$path}");
|
|
||||||
}
|
|
||||||
|
|
||||||
return $path;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitize a path segment
|
|
||||||
*
|
|
||||||
* @param string $value The path segment to sanitize
|
|
||||||
*
|
|
||||||
* @return string The sanitized segment
|
|
||||||
*
|
|
||||||
* @throws RuntimeException If segment is invalid
|
|
||||||
*/
|
|
||||||
private function _sanitizeSegment(string $value): string {
|
|
||||||
$value = trim($value);
|
|
||||||
|
|
||||||
if ($value === '') {
|
|
||||||
throw new RuntimeException('Path segment cannot be empty.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!preg_match('/^[a-zA-Z0-9._-]+$/', $value)) {
|
|
||||||
throw new RuntimeException("Invalid path segment: {$value}");
|
|
||||||
}
|
|
||||||
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Task Mapper
|
|
||||||
*
|
|
||||||
* PHP version 8.2+
|
|
||||||
*
|
|
||||||
* Maps between Task domain objects and FrontMatterDocument representations.
|
|
||||||
*
|
|
||||||
* @category Support
|
|
||||||
* @package IronKanban\Support
|
|
||||||
* @author Keith Solomon <keith@keithsolomon.net>
|
|
||||||
* @license Unlicense https://unlicense.org
|
|
||||||
* @link https://git.keithsolomon.net/keith/IronKanban
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace IronKanban\Support;
|
|
||||||
|
|
||||||
use IronKanban\Domain\Task;
|
|
||||||
use IronKanban\Markdown\FrontMatterDocument;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TaskMapper maps Task objects and FrontMatterDocument representations
|
|
||||||
*
|
|
||||||
* @category Support
|
|
||||||
* @package IronKanban\Support
|
|
||||||
* @author Keith Solomon <keith@keithsolomon.net>
|
|
||||||
* @license Unlicense https://unlicense.org
|
|
||||||
* @link https://git.keithsolomon.net/keith/IronKanban
|
|
||||||
*/
|
|
||||||
class TaskMapper {
|
|
||||||
/**
|
|
||||||
* Convert a FrontMatterDocument to a Task domain object
|
|
||||||
*
|
|
||||||
* @param string $projectId The project identifier
|
|
||||||
* @param string $fileName The task file name
|
|
||||||
* @param FrontMatterDocument $document The front matter document
|
|
||||||
*
|
|
||||||
* @return Task The mapped task
|
|
||||||
*/
|
|
||||||
public function fromDocument(
|
|
||||||
string $projectId,
|
|
||||||
string $fileName,
|
|
||||||
FrontMatterDocument $document
|
|
||||||
): Task {
|
|
||||||
$meta = $document->meta;
|
|
||||||
|
|
||||||
$id = (string) ($meta['id'] ?? pathinfo($fileName, PATHINFO_FILENAME));
|
|
||||||
$title = (string) ($meta['title'] ?? $id);
|
|
||||||
|
|
||||||
$column = isset($meta['column'])
|
|
||||||
? $this->normalizeColumn((string) $meta['column'])
|
|
||||||
: $this->mapLegacySectionToColumn(
|
|
||||||
(string) ($meta['section'] ?? 'backlog')
|
|
||||||
);
|
|
||||||
|
|
||||||
$order = isset($meta['order']) ? (int) $meta['order'] : 100;
|
|
||||||
$completed = (bool) ($meta['completed'] ?? false);
|
|
||||||
$priority = (string) ($meta['priority'] ?? 'normal');
|
|
||||||
$isActive = (bool) ($meta['is_active'] ?? true);
|
|
||||||
|
|
||||||
return new Task(
|
|
||||||
id: $id,
|
|
||||||
title: $title,
|
|
||||||
projectId: (string) ($meta['project_id'] ?? $projectId),
|
|
||||||
column: $column,
|
|
||||||
order: $order,
|
|
||||||
completed: $completed,
|
|
||||||
priority: $priority,
|
|
||||||
isActive: $isActive,
|
|
||||||
body: $document->body,
|
|
||||||
fileName: $fileName,
|
|
||||||
meta: $meta
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a Task domain object to a FrontMatterDocument
|
|
||||||
*
|
|
||||||
* @param Task $task The task to convert
|
|
||||||
*
|
|
||||||
* @return FrontMatterDocument The mapped document
|
|
||||||
*/
|
|
||||||
public function toDocument(Task $task): FrontMatterDocument {
|
|
||||||
$meta = $task->meta;
|
|
||||||
|
|
||||||
$meta['id'] = $task->id;
|
|
||||||
$meta['type'] = 'task';
|
|
||||||
$meta['title'] = $task->title;
|
|
||||||
$meta['project_id'] = $task->projectId;
|
|
||||||
$meta['column'] = $task->column;
|
|
||||||
$meta['order'] = $task->order;
|
|
||||||
$meta['completed'] = $task->completed;
|
|
||||||
$meta['priority'] = $task->priority;
|
|
||||||
$meta['is_active'] = $task->isActive;
|
|
||||||
|
|
||||||
if (!isset($meta['created']) || trim((string) $meta['created']) === '') {
|
|
||||||
$meta['created'] = gmdate('c');
|
|
||||||
}
|
|
||||||
|
|
||||||
$meta['updated'] = gmdate('c');
|
|
||||||
|
|
||||||
return new FrontMatterDocument($meta, $task->body);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize a column name
|
|
||||||
*
|
|
||||||
* @param string $column The column name to normalize
|
|
||||||
*
|
|
||||||
* @return string The normalized column name
|
|
||||||
*/
|
|
||||||
public function normalizeColumn(string $column): string {
|
|
||||||
$column = trim(strtolower($column));
|
|
||||||
|
|
||||||
if ($column === '') {
|
|
||||||
return 'backlog';
|
|
||||||
}
|
|
||||||
|
|
||||||
$column = preg_replace('/[^a-z0-9]+/', '-', $column) ?? 'backlog';
|
|
||||||
$column = trim($column, '-');
|
|
||||||
|
|
||||||
return $column !== '' ? $column : 'backlog';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map a legacy section name to a modern column name
|
|
||||||
*
|
|
||||||
* @param string $section The legacy section name
|
|
||||||
*
|
|
||||||
* @return string The normalized column name
|
|
||||||
*/
|
|
||||||
public function mapLegacySectionToColumn(string $section): string {
|
|
||||||
return $this->normalizeColumn($section);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
1775422119437
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
type: board
|
||||||
|
project_id: "demo-project"
|
||||||
|
columns:
|
||||||
|
- id: backlog
|
||||||
|
label: Backlog
|
||||||
|
order: 100
|
||||||
|
- id: "in-progress"
|
||||||
|
label: In Progress
|
||||||
|
order: 200
|
||||||
|
- id: review
|
||||||
|
label: Review
|
||||||
|
order: 300
|
||||||
|
- id: done
|
||||||
|
label: Done
|
||||||
|
order: 400
|
||||||
|
- id: trash
|
||||||
|
label: Trash
|
||||||
|
order: 500
|
||||||
|
updated: "2026-04-05T15:59:35+00:00"
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
type: project
|
||||||
|
id: demo-project
|
||||||
|
title: Demo Project
|
||||||
|
created: 2026-04-04T18:00:00+00:00
|
||||||
|
updated: 2026-04-04T18:00:00+00:00
|
||||||
|
---
|
||||||
|
|
||||||
|
This sample project shows the markdown layout IronKanban expects. You can move cards around, edit them, create notes, and watch the underlying files change.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
type: note
|
||||||
|
id: note-20260404-180300
|
||||||
|
title: Demo Note
|
||||||
|
project_id: demo-project
|
||||||
|
created: 2026-04-04T18:03:00+00:00
|
||||||
|
updated: 2026-04-04T18:03:00+00:00
|
||||||
|
---
|
||||||
|
|
||||||
|
Notes stay as markdown too, so project context can live beside the board instead of being buried in the UI.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
type: note
|
||||||
|
id: "note-20260405-161609-3d2504"
|
||||||
|
title: New note
|
||||||
|
project_id: "demo-project"
|
||||||
|
created: "2026-04-05T16:16:09+00:00"
|
||||||
|
updated: "2026-04-05T16:18:43+00:00"
|
||||||
|
---
|
||||||
|
|
||||||
|
what happens when I add another note?
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
type: task
|
||||||
|
id: "task-20260404-180000-a1b2c3"
|
||||||
|
title: Build the markdown parser and repositories
|
||||||
|
project_id: "demo-project"
|
||||||
|
column: backlog
|
||||||
|
order: 100
|
||||||
|
completed: false
|
||||||
|
priority: high
|
||||||
|
is_active: true
|
||||||
|
created: "2026-04-04T18:00:00+00:00"
|
||||||
|
updated: "2026-04-05T16:06:59+00:00"
|
||||||
|
tags:
|
||||||
|
- mvp
|
||||||
|
- backend
|
||||||
|
---
|
||||||
|
|
||||||
|
Implement front matter parsing, atomic writes, and the repositories that read and write project, board, task, and note files.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
type: task
|
||||||
|
id: "task-20260404-180100-d4e5f6"
|
||||||
|
title: Wire drag and drop interactions
|
||||||
|
project_id: "demo-project"
|
||||||
|
column: review
|
||||||
|
order: 100
|
||||||
|
completed: false
|
||||||
|
priority: normal
|
||||||
|
is_active: true
|
||||||
|
created: "2026-04-04T18:01:00+00:00"
|
||||||
|
updated: "2026-04-05T20:48:39+00:00"
|
||||||
|
tags:
|
||||||
|
- frontend
|
||||||
|
---
|
||||||
|
|
||||||
|
Use SortableJS to reorder columns and tasks, then persist those changes through the JSON API.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
type: task
|
||||||
|
id: "task-20260404-180200-g7h8i9"
|
||||||
|
title: Add revision polling and note editing
|
||||||
|
project_id: "demo-project"
|
||||||
|
column: "in-progress"
|
||||||
|
order: 100
|
||||||
|
completed: false
|
||||||
|
priority: low
|
||||||
|
is_active: false
|
||||||
|
created: "2026-04-04T18:02:00+00:00"
|
||||||
|
updated: "2026-04-05T20:48:39+00:00"
|
||||||
|
tags:
|
||||||
|
- polish
|
||||||
|
---
|
||||||
|
|
||||||
|
Expose a project revision marker so the browser can poll for updates and reload whenever the board changes elsewhere.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
type: task
|
||||||
|
id: "task-20260405-160639-2b7a82"
|
||||||
|
title: Trash Testing
|
||||||
|
project_id: "demo-project"
|
||||||
|
column: trash
|
||||||
|
order: 100
|
||||||
|
completed: false
|
||||||
|
priority: normal
|
||||||
|
is_active: true
|
||||||
|
created: "2026-04-05T16:06:39+00:00"
|
||||||
|
updated: "2026-04-05T16:19:02+00:00"
|
||||||
|
tags: null
|
||||||
|
---
|
||||||
|
|
||||||
|
jhdaljhfsldkjhflkajshd
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
<?php
|
|
||||||
// phpcs:disable
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace IronKanban\Tests;
|
|
||||||
|
|
||||||
use IronKanban\Markdown\FrontMatterDocument;
|
|
||||||
use IronKanban\Markdown\FrontMatterParser;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
class FrontMatterParserTest extends TestCase {
|
|
||||||
public function testParsesFrontMatterAndBody(): void {
|
|
||||||
$contents = <<<MD
|
|
||||||
---
|
|
||||||
id: task-123
|
|
||||||
title: Test Task
|
|
||||||
completed: false
|
|
||||||
tags:
|
|
||||||
- alpha
|
|
||||||
- beta
|
|
||||||
---
|
|
||||||
|
|
||||||
# Hello
|
|
||||||
|
|
||||||
This is the body.
|
|
||||||
MD;
|
|
||||||
|
|
||||||
$parser = new FrontMatterParser();
|
|
||||||
$document = $parser->parseString($contents);
|
|
||||||
|
|
||||||
$this->assertSame('task-123', $document->meta['id']);
|
|
||||||
$this->assertSame('Test Task', $document->meta['title']);
|
|
||||||
$this->assertFalse($document->meta['completed']);
|
|
||||||
$this->assertSame(['alpha', 'beta'], $document->meta['tags']);
|
|
||||||
$this->assertStringContainsString('# Hello', $document->body);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testReturnsEmptyMetaWhenNoFrontMatterExists(): void {
|
|
||||||
$contents = <<<MD
|
|
||||||
# Plain Markdown
|
|
||||||
|
|
||||||
No front matter here.
|
|
||||||
MD;
|
|
||||||
|
|
||||||
$parser = new FrontMatterParser();
|
|
||||||
$document = $parser->parseString($contents);
|
|
||||||
|
|
||||||
$this->assertSame([], $document->meta);
|
|
||||||
$this->assertStringContainsString('# Plain Markdown', $document->body);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testRoundTripsUnknownMetaKeys(): void {
|
|
||||||
$document = new FrontMatterDocument(
|
|
||||||
meta: [
|
|
||||||
'id' => 'task-456',
|
|
||||||
'type' => 'task',
|
|
||||||
'custom_key' => 'custom value',
|
|
||||||
'nested' => [
|
|
||||||
'one' => 'two',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
body: "Test body\n\nMore text."
|
|
||||||
);
|
|
||||||
|
|
||||||
$parser = new FrontMatterParser();
|
|
||||||
$dumped = $parser->dump($document);
|
|
||||||
$parsed = $parser->parseString($dumped);
|
|
||||||
|
|
||||||
$this->assertSame('custom value', $parsed->meta['custom_key']);
|
|
||||||
$this->assertSame(['one' => 'two'], $parsed->meta['nested']);
|
|
||||||
$this->assertSame("Test body\n\nMore text.", $parsed->body);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// phpcs:enable
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
<?php
|
|
||||||
// phpcs:disable
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace IronKanban\Tests;
|
|
||||||
|
|
||||||
use IronKanban\Markdown\FrontMatterParser;
|
|
||||||
use IronKanban\Repository\TaskRepository;
|
|
||||||
use IronKanban\Support\IdGenerator;
|
|
||||||
use IronKanban\Support\Paths;
|
|
||||||
use IronKanban\Support\TaskMapper;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
class TaskRepositoryTest extends TestCase {
|
|
||||||
private string $tempRoot;
|
|
||||||
|
|
||||||
protected function setUp(): void {
|
|
||||||
parent::setUp();
|
|
||||||
|
|
||||||
$this->tempRoot = sys_get_temp_dir() . '/ironkanban-test-' . bin2hex(random_bytes(4));
|
|
||||||
mkdir($this->tempRoot, 0775, true);
|
|
||||||
mkdir($this->tempRoot . '/sample-project', 0775, true);
|
|
||||||
mkdir($this->tempRoot . '/sample-project/tasks', 0775, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function tearDown(): void {
|
|
||||||
$this->deleteDirectory($this->tempRoot);
|
|
||||||
parent::tearDown();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testCreateWritesTaskFileAndCanReadItBack(): void {
|
|
||||||
$repo = $this->makeRepository();
|
|
||||||
|
|
||||||
$task = $repo->create([
|
|
||||||
'projectId' => 'sample-project',
|
|
||||||
'title' => 'Build parser',
|
|
||||||
'body' => "Task body",
|
|
||||||
'column' => 'doing',
|
|
||||||
'priority' => 'high',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->assertSame('Build parser', $task->title);
|
|
||||||
$this->assertSame('doing', $task->column);
|
|
||||||
$this->assertSame('high', $task->priority);
|
|
||||||
$this->assertFileExists($this->tempRoot . '/sample-project/tasks/' . $task->fileName);
|
|
||||||
|
|
||||||
$loaded = $repo->get('sample-project', $task->id);
|
|
||||||
|
|
||||||
$this->assertSame($task->id, $loaded->id);
|
|
||||||
$this->assertSame('Build parser', $loaded->title);
|
|
||||||
$this->assertSame('Task body', $loaded->body);
|
|
||||||
$this->assertSame($task->fileName, $loaded->fileName);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testPreservesLegacyFilenameWhenReadingAndSaving(): void {
|
|
||||||
$contents = <<<MD
|
|
||||||
---
|
|
||||||
id: custom-task-id
|
|
||||||
type: task
|
|
||||||
title: Legacy Task
|
|
||||||
project_id: sample-project
|
|
||||||
section: Back Burner
|
|
||||||
priority: normal
|
|
||||||
completed: false
|
|
||||||
is_active: true
|
|
||||||
created: 2026-03-01T00:00:00+00:00
|
|
||||||
updated: 2026-03-01T00:00:00+00:00
|
|
||||||
legacy_key: keep-me
|
|
||||||
---
|
|
||||||
|
|
||||||
Legacy body
|
|
||||||
MD;
|
|
||||||
|
|
||||||
$legacyPath = $this->tempRoot . '/sample-project/tasks/task-legacy-import.md';
|
|
||||||
file_put_contents($legacyPath, $contents);
|
|
||||||
|
|
||||||
$repo = $this->makeRepository();
|
|
||||||
$task = $repo->get('sample-project', 'custom-task-id');
|
|
||||||
|
|
||||||
$this->assertSame('task-legacy-import.md', $task->fileName);
|
|
||||||
$this->assertSame('back-burner', $task->column);
|
|
||||||
$this->assertSame('keep-me', $task->meta['legacy_key']);
|
|
||||||
|
|
||||||
$task->title = 'Legacy Task Updated';
|
|
||||||
$repo->save($task);
|
|
||||||
|
|
||||||
$this->assertFileExists($legacyPath);
|
|
||||||
$this->assertStringContainsString('Legacy Task Updated', (string) file_get_contents($legacyPath));
|
|
||||||
$this->assertStringContainsString('legacy_key: keep-me', (string) file_get_contents($legacyPath));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testDeleteRemovesUnderlyingFile(): void {
|
|
||||||
$repo = $this->makeRepository();
|
|
||||||
|
|
||||||
$task = $repo->create([
|
|
||||||
'projectId' => 'sample-project',
|
|
||||||
'title' => 'Delete me',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$filePath = $this->tempRoot . '/sample-project/tasks/' . $task->fileName;
|
|
||||||
|
|
||||||
$this->assertFileExists($filePath);
|
|
||||||
|
|
||||||
$repo->delete('sample-project', $task->id);
|
|
||||||
|
|
||||||
$this->assertFileDoesNotExist($filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function makeRepository(): TaskRepository {
|
|
||||||
return new TaskRepository(
|
|
||||||
new Paths($this->tempRoot),
|
|
||||||
new FrontMatterParser(),
|
|
||||||
new TaskMapper(),
|
|
||||||
new IdGenerator()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function deleteDirectory(string $path): void {
|
|
||||||
if (!is_dir($path)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$items = scandir($path);
|
|
||||||
|
|
||||||
if ($items === false) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($items as $item) {
|
|
||||||
if ($item === '.' || $item === '..') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$itemPath = $path . DIRECTORY_SEPARATOR . $item;
|
|
||||||
|
|
||||||
if (is_dir($itemPath) && !is_link($itemPath)) {
|
|
||||||
$this->deleteDirectory($itemPath);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
@unlink($itemPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
@rmdir($path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// phpcs:enable
|
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Vendored
-22
@@ -1,22 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
// autoload.php @generated by Composer
|
|
||||||
|
|
||||||
if (PHP_VERSION_ID < 50600) {
|
|
||||||
if (!headers_sent()) {
|
|
||||||
header('HTTP/1.1 500 Internal Server Error');
|
|
||||||
}
|
|
||||||
$err = 'Composer 2.3.0 dropped support for autoloading on PHP <5.6 and you are running '.PHP_VERSION.', please upgrade PHP or use Composer 2.2 LTS via "composer self-update --2.2". Aborting.'.PHP_EOL;
|
|
||||||
if (!ini_get('display_errors')) {
|
|
||||||
if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
|
|
||||||
fwrite(STDERR, $err);
|
|
||||||
} elseif (!headers_sent()) {
|
|
||||||
echo $err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new RuntimeException($err);
|
|
||||||
}
|
|
||||||
|
|
||||||
require_once __DIR__ . '/composer/autoload_real.php';
|
|
||||||
|
|
||||||
return ComposerAutoloaderInitf25b5b4c093b1a5112f6e1a1bba70ad2::getLoader();
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
{"version":2,"defects":{"IronKanban\\Tests\\TaskRepositoryTest::testCreateWritesTaskFileAndCanReadItBack":7,"IronKanban\\Tests\\TaskRepositoryTest::testPreservesLegacyFilenameWhenReadingAndSaving":7,"IronKanban\\Tests\\TaskRepositoryTest::testDeleteRemovesUnderlyingFile":7},"times":{"IronKanban\\Tests\\FrontMatterParserTest::testParsesFrontMatterAndBody":0.702,"IronKanban\\Tests\\FrontMatterParserTest::testReturnsEmptyMetaWhenNoFrontMatterExists":0,"IronKanban\\Tests\\FrontMatterParserTest::testRoundTripsUnknownMetaKeys":0.081,"IronKanban\\Tests\\TaskRepositoryTest::testCreateWritesTaskFileAndCanReadItBack":0.879,"IronKanban\\Tests\\TaskRepositoryTest::testPreservesLegacyFilenameWhenReadingAndSaving":0.037,"IronKanban\\Tests\\TaskRepositoryTest::testDeleteRemovesUnderlyingFile":0.009}}
|
|
||||||
Vendored
-119
@@ -1,119 +0,0 @@
|
|||||||
#!/usr/bin/env php
|
|
||||||
<?php
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Proxy PHP file generated by Composer
|
|
||||||
*
|
|
||||||
* This file includes the referenced bin path (../nikic/php-parser/bin/php-parse)
|
|
||||||
* using a stream wrapper to prevent the shebang from being output on PHP<8
|
|
||||||
*
|
|
||||||
* @generated
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Composer;
|
|
||||||
|
|
||||||
$GLOBALS['_composer_bin_dir'] = __DIR__;
|
|
||||||
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
|
|
||||||
|
|
||||||
if (PHP_VERSION_ID < 80000) {
|
|
||||||
if (!class_exists('Composer\BinProxyWrapper')) {
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class BinProxyWrapper
|
|
||||||
{
|
|
||||||
private $handle;
|
|
||||||
private $position;
|
|
||||||
private $realpath;
|
|
||||||
|
|
||||||
public function stream_open($path, $mode, $options, &$opened_path)
|
|
||||||
{
|
|
||||||
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
|
|
||||||
$opened_path = substr($path, 17);
|
|
||||||
$this->realpath = realpath($opened_path) ?: $opened_path;
|
|
||||||
$opened_path = $this->realpath;
|
|
||||||
$this->handle = fopen($this->realpath, $mode);
|
|
||||||
$this->position = 0;
|
|
||||||
|
|
||||||
return (bool) $this->handle;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function stream_read($count)
|
|
||||||
{
|
|
||||||
$data = fread($this->handle, $count);
|
|
||||||
|
|
||||||
if ($this->position === 0) {
|
|
||||||
$data = preg_replace('{^#!.*\r?\n}', '', $data);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->position += strlen($data);
|
|
||||||
|
|
||||||
return $data;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function stream_cast($castAs)
|
|
||||||
{
|
|
||||||
return $this->handle;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function stream_close()
|
|
||||||
{
|
|
||||||
fclose($this->handle);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function stream_lock($operation)
|
|
||||||
{
|
|
||||||
return $operation ? flock($this->handle, $operation) : true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function stream_seek($offset, $whence)
|
|
||||||
{
|
|
||||||
if (0 === fseek($this->handle, $offset, $whence)) {
|
|
||||||
$this->position = ftell($this->handle);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function stream_tell()
|
|
||||||
{
|
|
||||||
return $this->position;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function stream_eof()
|
|
||||||
{
|
|
||||||
return feof($this->handle);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function stream_stat()
|
|
||||||
{
|
|
||||||
return array();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function stream_set_option($option, $arg1, $arg2)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function url_stat($path, $flags)
|
|
||||||
{
|
|
||||||
$path = substr($path, 17);
|
|
||||||
if (file_exists($path)) {
|
|
||||||
return stat($path);
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|
|
||||||
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
|
|
||||||
) {
|
|
||||||
return include("phpvfscomposer://" . __DIR__ . '/..'.'/nikic/php-parser/bin/php-parse');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return include __DIR__ . '/..'.'/nikic/php-parser/bin/php-parse';
|
|
||||||
Vendored
-5
@@ -1,5 +0,0 @@
|
|||||||
@ECHO OFF
|
|
||||||
setlocal DISABLEDELAYEDEXPANSION
|
|
||||||
SET BIN_TARGET=%~dp0/php-parse
|
|
||||||
SET COMPOSER_RUNTIME_BIN_DIR=%~dp0
|
|
||||||
php "%BIN_TARGET%" %*
|
|
||||||
Vendored
-122
@@ -1,122 +0,0 @@
|
|||||||
#!/usr/bin/env php
|
|
||||||
<?php
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Proxy PHP file generated by Composer
|
|
||||||
*
|
|
||||||
* This file includes the referenced bin path (../phpunit/phpunit/phpunit)
|
|
||||||
* using a stream wrapper to prevent the shebang from being output on PHP<8
|
|
||||||
*
|
|
||||||
* @generated
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Composer;
|
|
||||||
|
|
||||||
$GLOBALS['_composer_bin_dir'] = __DIR__;
|
|
||||||
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
|
|
||||||
$GLOBALS['__PHPUNIT_ISOLATION_EXCLUDE_LIST'] = $GLOBALS['__PHPUNIT_ISOLATION_BLACKLIST'] = array(realpath(__DIR__ . '/..'.'/phpunit/phpunit/phpunit'));
|
|
||||||
|
|
||||||
if (PHP_VERSION_ID < 80000) {
|
|
||||||
if (!class_exists('Composer\BinProxyWrapper')) {
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class BinProxyWrapper
|
|
||||||
{
|
|
||||||
private $handle;
|
|
||||||
private $position;
|
|
||||||
private $realpath;
|
|
||||||
|
|
||||||
public function stream_open($path, $mode, $options, &$opened_path)
|
|
||||||
{
|
|
||||||
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
|
|
||||||
$opened_path = substr($path, 17);
|
|
||||||
$this->realpath = realpath($opened_path) ?: $opened_path;
|
|
||||||
$opened_path = 'phpvfscomposer://'.$this->realpath;
|
|
||||||
$this->handle = fopen($this->realpath, $mode);
|
|
||||||
$this->position = 0;
|
|
||||||
|
|
||||||
return (bool) $this->handle;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function stream_read($count)
|
|
||||||
{
|
|
||||||
$data = fread($this->handle, $count);
|
|
||||||
|
|
||||||
if ($this->position === 0) {
|
|
||||||
$data = preg_replace('{^#!.*\r?\n}', '', $data);
|
|
||||||
}
|
|
||||||
$data = str_replace('__DIR__', var_export(dirname($this->realpath), true), $data);
|
|
||||||
$data = str_replace('__FILE__', var_export($this->realpath, true), $data);
|
|
||||||
|
|
||||||
$this->position += strlen($data);
|
|
||||||
|
|
||||||
return $data;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function stream_cast($castAs)
|
|
||||||
{
|
|
||||||
return $this->handle;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function stream_close()
|
|
||||||
{
|
|
||||||
fclose($this->handle);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function stream_lock($operation)
|
|
||||||
{
|
|
||||||
return $operation ? flock($this->handle, $operation) : true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function stream_seek($offset, $whence)
|
|
||||||
{
|
|
||||||
if (0 === fseek($this->handle, $offset, $whence)) {
|
|
||||||
$this->position = ftell($this->handle);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function stream_tell()
|
|
||||||
{
|
|
||||||
return $this->position;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function stream_eof()
|
|
||||||
{
|
|
||||||
return feof($this->handle);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function stream_stat()
|
|
||||||
{
|
|
||||||
return array();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function stream_set_option($option, $arg1, $arg2)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function url_stat($path, $flags)
|
|
||||||
{
|
|
||||||
$path = substr($path, 17);
|
|
||||||
if (file_exists($path)) {
|
|
||||||
return stat($path);
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|
|
||||||
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
|
|
||||||
) {
|
|
||||||
return include("phpvfscomposer://" . __DIR__ . '/..'.'/phpunit/phpunit/phpunit');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return include __DIR__ . '/..'.'/phpunit/phpunit/phpunit';
|
|
||||||
Vendored
-5
@@ -1,5 +0,0 @@
|
|||||||
@ECHO OFF
|
|
||||||
setlocal DISABLEDELAYEDEXPANSION
|
|
||||||
SET BIN_TARGET=%~dp0/phpunit
|
|
||||||
SET COMPOSER_RUNTIME_BIN_DIR=%~dp0
|
|
||||||
php "%BIN_TARGET%" %*
|
|
||||||
Vendored
-119
@@ -1,119 +0,0 @@
|
|||||||
#!/usr/bin/env php
|
|
||||||
<?php
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Proxy PHP file generated by Composer
|
|
||||||
*
|
|
||||||
* This file includes the referenced bin path (../symfony/yaml/Resources/bin/yaml-lint)
|
|
||||||
* using a stream wrapper to prevent the shebang from being output on PHP<8
|
|
||||||
*
|
|
||||||
* @generated
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Composer;
|
|
||||||
|
|
||||||
$GLOBALS['_composer_bin_dir'] = __DIR__;
|
|
||||||
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
|
|
||||||
|
|
||||||
if (PHP_VERSION_ID < 80000) {
|
|
||||||
if (!class_exists('Composer\BinProxyWrapper')) {
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class BinProxyWrapper
|
|
||||||
{
|
|
||||||
private $handle;
|
|
||||||
private $position;
|
|
||||||
private $realpath;
|
|
||||||
|
|
||||||
public function stream_open($path, $mode, $options, &$opened_path)
|
|
||||||
{
|
|
||||||
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
|
|
||||||
$opened_path = substr($path, 17);
|
|
||||||
$this->realpath = realpath($opened_path) ?: $opened_path;
|
|
||||||
$opened_path = $this->realpath;
|
|
||||||
$this->handle = fopen($this->realpath, $mode);
|
|
||||||
$this->position = 0;
|
|
||||||
|
|
||||||
return (bool) $this->handle;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function stream_read($count)
|
|
||||||
{
|
|
||||||
$data = fread($this->handle, $count);
|
|
||||||
|
|
||||||
if ($this->position === 0) {
|
|
||||||
$data = preg_replace('{^#!.*\r?\n}', '', $data);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->position += strlen($data);
|
|
||||||
|
|
||||||
return $data;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function stream_cast($castAs)
|
|
||||||
{
|
|
||||||
return $this->handle;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function stream_close()
|
|
||||||
{
|
|
||||||
fclose($this->handle);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function stream_lock($operation)
|
|
||||||
{
|
|
||||||
return $operation ? flock($this->handle, $operation) : true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function stream_seek($offset, $whence)
|
|
||||||
{
|
|
||||||
if (0 === fseek($this->handle, $offset, $whence)) {
|
|
||||||
$this->position = ftell($this->handle);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function stream_tell()
|
|
||||||
{
|
|
||||||
return $this->position;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function stream_eof()
|
|
||||||
{
|
|
||||||
return feof($this->handle);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function stream_stat()
|
|
||||||
{
|
|
||||||
return array();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function stream_set_option($option, $arg1, $arg2)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function url_stat($path, $flags)
|
|
||||||
{
|
|
||||||
$path = substr($path, 17);
|
|
||||||
if (file_exists($path)) {
|
|
||||||
return stat($path);
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|
|
||||||
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
|
|
||||||
) {
|
|
||||||
return include("phpvfscomposer://" . __DIR__ . '/..'.'/symfony/yaml/Resources/bin/yaml-lint');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return include __DIR__ . '/..'.'/symfony/yaml/Resources/bin/yaml-lint';
|
|
||||||
Vendored
-5
@@ -1,5 +0,0 @@
|
|||||||
@ECHO OFF
|
|
||||||
setlocal DISABLEDELAYEDEXPANSION
|
|
||||||
SET BIN_TARGET=%~dp0/yaml-lint
|
|
||||||
SET COMPOSER_RUNTIME_BIN_DIR=%~dp0
|
|
||||||
php "%BIN_TARGET%" %*
|
|
||||||
Vendored
-579
@@ -1,579 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
/*
|
|
||||||
* This file is part of Composer.
|
|
||||||
*
|
|
||||||
* (c) Nils Adermann <naderman@naderman.de>
|
|
||||||
* Jordi Boggiano <j.boggiano@seld.be>
|
|
||||||
*
|
|
||||||
* For the full copyright and license information, please view the LICENSE
|
|
||||||
* file that was distributed with this source code.
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Composer\Autoload;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ClassLoader implements a PSR-0, PSR-4 and classmap class loader.
|
|
||||||
*
|
|
||||||
* $loader = new \Composer\Autoload\ClassLoader();
|
|
||||||
*
|
|
||||||
* // register classes with namespaces
|
|
||||||
* $loader->add('Symfony\Component', __DIR__.'/component');
|
|
||||||
* $loader->add('Symfony', __DIR__.'/framework');
|
|
||||||
*
|
|
||||||
* // activate the autoloader
|
|
||||||
* $loader->register();
|
|
||||||
*
|
|
||||||
* // to enable searching the include path (eg. for PEAR packages)
|
|
||||||
* $loader->setUseIncludePath(true);
|
|
||||||
*
|
|
||||||
* In this example, if you try to use a class in the Symfony\Component
|
|
||||||
* namespace or one of its children (Symfony\Component\Console for instance),
|
|
||||||
* the autoloader will first look for the class under the component/
|
|
||||||
* directory, and it will then fallback to the framework/ directory if not
|
|
||||||
* found before giving up.
|
|
||||||
*
|
|
||||||
* This class is loosely based on the Symfony UniversalClassLoader.
|
|
||||||
*
|
|
||||||
* @author Fabien Potencier <fabien@symfony.com>
|
|
||||||
* @author Jordi Boggiano <j.boggiano@seld.be>
|
|
||||||
* @see https://www.php-fig.org/psr/psr-0/
|
|
||||||
* @see https://www.php-fig.org/psr/psr-4/
|
|
||||||
*/
|
|
||||||
class ClassLoader
|
|
||||||
{
|
|
||||||
/** @var \Closure(string):void */
|
|
||||||
private static $includeFile;
|
|
||||||
|
|
||||||
/** @var string|null */
|
|
||||||
private $vendorDir;
|
|
||||||
|
|
||||||
// PSR-4
|
|
||||||
/**
|
|
||||||
* @var array<string, array<string, int>>
|
|
||||||
*/
|
|
||||||
private $prefixLengthsPsr4 = array();
|
|
||||||
/**
|
|
||||||
* @var array<string, list<string>>
|
|
||||||
*/
|
|
||||||
private $prefixDirsPsr4 = array();
|
|
||||||
/**
|
|
||||||
* @var list<string>
|
|
||||||
*/
|
|
||||||
private $fallbackDirsPsr4 = array();
|
|
||||||
|
|
||||||
// PSR-0
|
|
||||||
/**
|
|
||||||
* List of PSR-0 prefixes
|
|
||||||
*
|
|
||||||
* Structured as array('F (first letter)' => array('Foo\Bar (full prefix)' => array('path', 'path2')))
|
|
||||||
*
|
|
||||||
* @var array<string, array<string, list<string>>>
|
|
||||||
*/
|
|
||||||
private $prefixesPsr0 = array();
|
|
||||||
/**
|
|
||||||
* @var list<string>
|
|
||||||
*/
|
|
||||||
private $fallbackDirsPsr0 = array();
|
|
||||||
|
|
||||||
/** @var bool */
|
|
||||||
private $useIncludePath = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array<string, string>
|
|
||||||
*/
|
|
||||||
private $classMap = array();
|
|
||||||
|
|
||||||
/** @var bool */
|
|
||||||
private $classMapAuthoritative = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array<string, bool>
|
|
||||||
*/
|
|
||||||
private $missingClasses = array();
|
|
||||||
|
|
||||||
/** @var string|null */
|
|
||||||
private $apcuPrefix;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array<string, self>
|
|
||||||
*/
|
|
||||||
private static $registeredLoaders = array();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param string|null $vendorDir
|
|
||||||
*/
|
|
||||||
public function __construct($vendorDir = null)
|
|
||||||
{
|
|
||||||
$this->vendorDir = $vendorDir;
|
|
||||||
self::initializeIncludeClosure();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, list<string>>
|
|
||||||
*/
|
|
||||||
public function getPrefixes()
|
|
||||||
{
|
|
||||||
if (!empty($this->prefixesPsr0)) {
|
|
||||||
return call_user_func_array('array_merge', array_values($this->prefixesPsr0));
|
|
||||||
}
|
|
||||||
|
|
||||||
return array();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, list<string>>
|
|
||||||
*/
|
|
||||||
public function getPrefixesPsr4()
|
|
||||||
{
|
|
||||||
return $this->prefixDirsPsr4;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
public function getFallbackDirs()
|
|
||||||
{
|
|
||||||
return $this->fallbackDirsPsr0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
public function getFallbackDirsPsr4()
|
|
||||||
{
|
|
||||||
return $this->fallbackDirsPsr4;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string> Array of classname => path
|
|
||||||
*/
|
|
||||||
public function getClassMap()
|
|
||||||
{
|
|
||||||
return $this->classMap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, string> $classMap Class to filename map
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function addClassMap(array $classMap)
|
|
||||||
{
|
|
||||||
if ($this->classMap) {
|
|
||||||
$this->classMap = array_merge($this->classMap, $classMap);
|
|
||||||
} else {
|
|
||||||
$this->classMap = $classMap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Registers a set of PSR-0 directories for a given prefix, either
|
|
||||||
* appending or prepending to the ones previously set for this prefix.
|
|
||||||
*
|
|
||||||
* @param string $prefix The prefix
|
|
||||||
* @param list<string>|string $paths The PSR-0 root directories
|
|
||||||
* @param bool $prepend Whether to prepend the directories
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function add($prefix, $paths, $prepend = false)
|
|
||||||
{
|
|
||||||
$paths = (array) $paths;
|
|
||||||
if (!$prefix) {
|
|
||||||
if ($prepend) {
|
|
||||||
$this->fallbackDirsPsr0 = array_merge(
|
|
||||||
$paths,
|
|
||||||
$this->fallbackDirsPsr0
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
$this->fallbackDirsPsr0 = array_merge(
|
|
||||||
$this->fallbackDirsPsr0,
|
|
||||||
$paths
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$first = $prefix[0];
|
|
||||||
if (!isset($this->prefixesPsr0[$first][$prefix])) {
|
|
||||||
$this->prefixesPsr0[$first][$prefix] = $paths;
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if ($prepend) {
|
|
||||||
$this->prefixesPsr0[$first][$prefix] = array_merge(
|
|
||||||
$paths,
|
|
||||||
$this->prefixesPsr0[$first][$prefix]
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
$this->prefixesPsr0[$first][$prefix] = array_merge(
|
|
||||||
$this->prefixesPsr0[$first][$prefix],
|
|
||||||
$paths
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Registers a set of PSR-4 directories for a given namespace, either
|
|
||||||
* appending or prepending to the ones previously set for this namespace.
|
|
||||||
*
|
|
||||||
* @param string $prefix The prefix/namespace, with trailing '\\'
|
|
||||||
* @param list<string>|string $paths The PSR-4 base directories
|
|
||||||
* @param bool $prepend Whether to prepend the directories
|
|
||||||
*
|
|
||||||
* @throws \InvalidArgumentException
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function addPsr4($prefix, $paths, $prepend = false)
|
|
||||||
{
|
|
||||||
$paths = (array) $paths;
|
|
||||||
if (!$prefix) {
|
|
||||||
// Register directories for the root namespace.
|
|
||||||
if ($prepend) {
|
|
||||||
$this->fallbackDirsPsr4 = array_merge(
|
|
||||||
$paths,
|
|
||||||
$this->fallbackDirsPsr4
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
$this->fallbackDirsPsr4 = array_merge(
|
|
||||||
$this->fallbackDirsPsr4,
|
|
||||||
$paths
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} elseif (!isset($this->prefixDirsPsr4[$prefix])) {
|
|
||||||
// Register directories for a new namespace.
|
|
||||||
$length = strlen($prefix);
|
|
||||||
if ('\\' !== $prefix[$length - 1]) {
|
|
||||||
throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
|
|
||||||
}
|
|
||||||
$this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
|
|
||||||
$this->prefixDirsPsr4[$prefix] = $paths;
|
|
||||||
} elseif ($prepend) {
|
|
||||||
// Prepend directories for an already registered namespace.
|
|
||||||
$this->prefixDirsPsr4[$prefix] = array_merge(
|
|
||||||
$paths,
|
|
||||||
$this->prefixDirsPsr4[$prefix]
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Append directories for an already registered namespace.
|
|
||||||
$this->prefixDirsPsr4[$prefix] = array_merge(
|
|
||||||
$this->prefixDirsPsr4[$prefix],
|
|
||||||
$paths
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Registers a set of PSR-0 directories for a given prefix,
|
|
||||||
* replacing any others previously set for this prefix.
|
|
||||||
*
|
|
||||||
* @param string $prefix The prefix
|
|
||||||
* @param list<string>|string $paths The PSR-0 base directories
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function set($prefix, $paths)
|
|
||||||
{
|
|
||||||
if (!$prefix) {
|
|
||||||
$this->fallbackDirsPsr0 = (array) $paths;
|
|
||||||
} else {
|
|
||||||
$this->prefixesPsr0[$prefix[0]][$prefix] = (array) $paths;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Registers a set of PSR-4 directories for a given namespace,
|
|
||||||
* replacing any others previously set for this namespace.
|
|
||||||
*
|
|
||||||
* @param string $prefix The prefix/namespace, with trailing '\\'
|
|
||||||
* @param list<string>|string $paths The PSR-4 base directories
|
|
||||||
*
|
|
||||||
* @throws \InvalidArgumentException
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function setPsr4($prefix, $paths)
|
|
||||||
{
|
|
||||||
if (!$prefix) {
|
|
||||||
$this->fallbackDirsPsr4 = (array) $paths;
|
|
||||||
} else {
|
|
||||||
$length = strlen($prefix);
|
|
||||||
if ('\\' !== $prefix[$length - 1]) {
|
|
||||||
throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
|
|
||||||
}
|
|
||||||
$this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
|
|
||||||
$this->prefixDirsPsr4[$prefix] = (array) $paths;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Turns on searching the include path for class files.
|
|
||||||
*
|
|
||||||
* @param bool $useIncludePath
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function setUseIncludePath($useIncludePath)
|
|
||||||
{
|
|
||||||
$this->useIncludePath = $useIncludePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Can be used to check if the autoloader uses the include path to check
|
|
||||||
* for classes.
|
|
||||||
*
|
|
||||||
* @return bool
|
|
||||||
*/
|
|
||||||
public function getUseIncludePath()
|
|
||||||
{
|
|
||||||
return $this->useIncludePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Turns off searching the prefix and fallback directories for classes
|
|
||||||
* that have not been registered with the class map.
|
|
||||||
*
|
|
||||||
* @param bool $classMapAuthoritative
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function setClassMapAuthoritative($classMapAuthoritative)
|
|
||||||
{
|
|
||||||
$this->classMapAuthoritative = $classMapAuthoritative;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Should class lookup fail if not found in the current class map?
|
|
||||||
*
|
|
||||||
* @return bool
|
|
||||||
*/
|
|
||||||
public function isClassMapAuthoritative()
|
|
||||||
{
|
|
||||||
return $this->classMapAuthoritative;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* APCu prefix to use to cache found/not-found classes, if the extension is enabled.
|
|
||||||
*
|
|
||||||
* @param string|null $apcuPrefix
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function setApcuPrefix($apcuPrefix)
|
|
||||||
{
|
|
||||||
$this->apcuPrefix = function_exists('apcu_fetch') && filter_var(ini_get('apc.enabled'), FILTER_VALIDATE_BOOLEAN) ? $apcuPrefix : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The APCu prefix in use, or null if APCu caching is not enabled.
|
|
||||||
*
|
|
||||||
* @return string|null
|
|
||||||
*/
|
|
||||||
public function getApcuPrefix()
|
|
||||||
{
|
|
||||||
return $this->apcuPrefix;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Registers this instance as an autoloader.
|
|
||||||
*
|
|
||||||
* @param bool $prepend Whether to prepend the autoloader or not
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function register($prepend = false)
|
|
||||||
{
|
|
||||||
spl_autoload_register(array($this, 'loadClass'), true, $prepend);
|
|
||||||
|
|
||||||
if (null === $this->vendorDir) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($prepend) {
|
|
||||||
self::$registeredLoaders = array($this->vendorDir => $this) + self::$registeredLoaders;
|
|
||||||
} else {
|
|
||||||
unset(self::$registeredLoaders[$this->vendorDir]);
|
|
||||||
self::$registeredLoaders[$this->vendorDir] = $this;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unregisters this instance as an autoloader.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function unregister()
|
|
||||||
{
|
|
||||||
spl_autoload_unregister(array($this, 'loadClass'));
|
|
||||||
|
|
||||||
if (null !== $this->vendorDir) {
|
|
||||||
unset(self::$registeredLoaders[$this->vendorDir]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads the given class or interface.
|
|
||||||
*
|
|
||||||
* @param string $class The name of the class
|
|
||||||
* @return true|null True if loaded, null otherwise
|
|
||||||
*/
|
|
||||||
public function loadClass($class)
|
|
||||||
{
|
|
||||||
if ($file = $this->findFile($class)) {
|
|
||||||
$includeFile = self::$includeFile;
|
|
||||||
$includeFile($file);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Finds the path to the file where the class is defined.
|
|
||||||
*
|
|
||||||
* @param string $class The name of the class
|
|
||||||
*
|
|
||||||
* @return string|false The path if found, false otherwise
|
|
||||||
*/
|
|
||||||
public function findFile($class)
|
|
||||||
{
|
|
||||||
// class map lookup
|
|
||||||
if (isset($this->classMap[$class])) {
|
|
||||||
return $this->classMap[$class];
|
|
||||||
}
|
|
||||||
if ($this->classMapAuthoritative || isset($this->missingClasses[$class])) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (null !== $this->apcuPrefix) {
|
|
||||||
$file = apcu_fetch($this->apcuPrefix.$class, $hit);
|
|
||||||
if ($hit) {
|
|
||||||
return $file;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$file = $this->findFileWithExtension($class, '.php');
|
|
||||||
|
|
||||||
// Search for Hack files if we are running on HHVM
|
|
||||||
if (false === $file && defined('HHVM_VERSION')) {
|
|
||||||
$file = $this->findFileWithExtension($class, '.hh');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (null !== $this->apcuPrefix) {
|
|
||||||
apcu_add($this->apcuPrefix.$class, $file);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (false === $file) {
|
|
||||||
// Remember that this class does not exist.
|
|
||||||
$this->missingClasses[$class] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $file;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the currently registered loaders keyed by their corresponding vendor directories.
|
|
||||||
*
|
|
||||||
* @return array<string, self>
|
|
||||||
*/
|
|
||||||
public static function getRegisteredLoaders()
|
|
||||||
{
|
|
||||||
return self::$registeredLoaders;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param string $class
|
|
||||||
* @param string $ext
|
|
||||||
* @return string|false
|
|
||||||
*/
|
|
||||||
private function findFileWithExtension($class, $ext)
|
|
||||||
{
|
|
||||||
// PSR-4 lookup
|
|
||||||
$logicalPathPsr4 = strtr($class, '\\', DIRECTORY_SEPARATOR) . $ext;
|
|
||||||
|
|
||||||
$first = $class[0];
|
|
||||||
if (isset($this->prefixLengthsPsr4[$first])) {
|
|
||||||
$subPath = $class;
|
|
||||||
while (false !== $lastPos = strrpos($subPath, '\\')) {
|
|
||||||
$subPath = substr($subPath, 0, $lastPos);
|
|
||||||
$search = $subPath . '\\';
|
|
||||||
if (isset($this->prefixDirsPsr4[$search])) {
|
|
||||||
$pathEnd = DIRECTORY_SEPARATOR . substr($logicalPathPsr4, $lastPos + 1);
|
|
||||||
foreach ($this->prefixDirsPsr4[$search] as $dir) {
|
|
||||||
if (file_exists($file = $dir . $pathEnd)) {
|
|
||||||
return $file;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PSR-4 fallback dirs
|
|
||||||
foreach ($this->fallbackDirsPsr4 as $dir) {
|
|
||||||
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr4)) {
|
|
||||||
return $file;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PSR-0 lookup
|
|
||||||
if (false !== $pos = strrpos($class, '\\')) {
|
|
||||||
// namespaced class name
|
|
||||||
$logicalPathPsr0 = substr($logicalPathPsr4, 0, $pos + 1)
|
|
||||||
. strtr(substr($logicalPathPsr4, $pos + 1), '_', DIRECTORY_SEPARATOR);
|
|
||||||
} else {
|
|
||||||
// PEAR-like class name
|
|
||||||
$logicalPathPsr0 = strtr($class, '_', DIRECTORY_SEPARATOR) . $ext;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isset($this->prefixesPsr0[$first])) {
|
|
||||||
foreach ($this->prefixesPsr0[$first] as $prefix => $dirs) {
|
|
||||||
if (0 === strpos($class, $prefix)) {
|
|
||||||
foreach ($dirs as $dir) {
|
|
||||||
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
|
|
||||||
return $file;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PSR-0 fallback dirs
|
|
||||||
foreach ($this->fallbackDirsPsr0 as $dir) {
|
|
||||||
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
|
|
||||||
return $file;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PSR-0 include paths.
|
|
||||||
if ($this->useIncludePath && $file = stream_resolve_include_path($logicalPathPsr0)) {
|
|
||||||
return $file;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
private static function initializeIncludeClosure()
|
|
||||||
{
|
|
||||||
if (self::$includeFile !== null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scope isolated include.
|
|
||||||
*
|
|
||||||
* Prevents access to $this/self from included files.
|
|
||||||
*
|
|
||||||
* @param string $file
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
self::$includeFile = \Closure::bind(static function($file) {
|
|
||||||
include $file;
|
|
||||||
}, null, null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-396
@@ -1,396 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
/*
|
|
||||||
* This file is part of Composer.
|
|
||||||
*
|
|
||||||
* (c) Nils Adermann <naderman@naderman.de>
|
|
||||||
* Jordi Boggiano <j.boggiano@seld.be>
|
|
||||||
*
|
|
||||||
* For the full copyright and license information, please view the LICENSE
|
|
||||||
* file that was distributed with this source code.
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Composer;
|
|
||||||
|
|
||||||
use Composer\Autoload\ClassLoader;
|
|
||||||
use Composer\Semver\VersionParser;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This class is copied in every Composer installed project and available to all
|
|
||||||
*
|
|
||||||
* See also https://getcomposer.org/doc/07-runtime.md#installed-versions
|
|
||||||
*
|
|
||||||
* To require its presence, you can require `composer-runtime-api ^2.0`
|
|
||||||
*
|
|
||||||
* @final
|
|
||||||
*/
|
|
||||||
class InstalledVersions
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @var string|null if set (by reflection by Composer), this should be set to the path where this class is being copied to
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
private static $selfDir = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var mixed[]|null
|
|
||||||
* @psalm-var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}|array{}|null
|
|
||||||
*/
|
|
||||||
private static $installed;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var bool
|
|
||||||
*/
|
|
||||||
private static $installedIsLocalDir;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var bool|null
|
|
||||||
*/
|
|
||||||
private static $canGetVendors;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array[]
|
|
||||||
* @psalm-var array<string, array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
|
|
||||||
*/
|
|
||||||
private static $installedByVendor = array();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a list of all package names which are present, either by being installed, replaced or provided
|
|
||||||
*
|
|
||||||
* @return string[]
|
|
||||||
* @psalm-return list<string>
|
|
||||||
*/
|
|
||||||
public static function getInstalledPackages()
|
|
||||||
{
|
|
||||||
$packages = array();
|
|
||||||
foreach (self::getInstalled() as $installed) {
|
|
||||||
$packages[] = array_keys($installed['versions']);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (1 === \count($packages)) {
|
|
||||||
return $packages[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_keys(array_flip(\call_user_func_array('array_merge', $packages)));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a list of all package names with a specific type e.g. 'library'
|
|
||||||
*
|
|
||||||
* @param string $type
|
|
||||||
* @return string[]
|
|
||||||
* @psalm-return list<string>
|
|
||||||
*/
|
|
||||||
public static function getInstalledPackagesByType($type)
|
|
||||||
{
|
|
||||||
$packagesByType = array();
|
|
||||||
|
|
||||||
foreach (self::getInstalled() as $installed) {
|
|
||||||
foreach ($installed['versions'] as $name => $package) {
|
|
||||||
if (isset($package['type']) && $package['type'] === $type) {
|
|
||||||
$packagesByType[] = $name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $packagesByType;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks whether the given package is installed
|
|
||||||
*
|
|
||||||
* This also returns true if the package name is provided or replaced by another package
|
|
||||||
*
|
|
||||||
* @param string $packageName
|
|
||||||
* @param bool $includeDevRequirements
|
|
||||||
* @return bool
|
|
||||||
*/
|
|
||||||
public static function isInstalled($packageName, $includeDevRequirements = true)
|
|
||||||
{
|
|
||||||
foreach (self::getInstalled() as $installed) {
|
|
||||||
if (isset($installed['versions'][$packageName])) {
|
|
||||||
return $includeDevRequirements || !isset($installed['versions'][$packageName]['dev_requirement']) || $installed['versions'][$packageName]['dev_requirement'] === false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks whether the given package satisfies a version constraint
|
|
||||||
*
|
|
||||||
* e.g. If you want to know whether version 2.3+ of package foo/bar is installed, you would call:
|
|
||||||
*
|
|
||||||
* Composer\InstalledVersions::satisfies(new VersionParser, 'foo/bar', '^2.3')
|
|
||||||
*
|
|
||||||
* @param VersionParser $parser Install composer/semver to have access to this class and functionality
|
|
||||||
* @param string $packageName
|
|
||||||
* @param string|null $constraint A version constraint to check for, if you pass one you have to make sure composer/semver is required by your package
|
|
||||||
* @return bool
|
|
||||||
*/
|
|
||||||
public static function satisfies(VersionParser $parser, $packageName, $constraint)
|
|
||||||
{
|
|
||||||
$constraint = $parser->parseConstraints((string) $constraint);
|
|
||||||
$provided = $parser->parseConstraints(self::getVersionRanges($packageName));
|
|
||||||
|
|
||||||
return $provided->matches($constraint);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a version constraint representing all the range(s) which are installed for a given package
|
|
||||||
*
|
|
||||||
* It is easier to use this via isInstalled() with the $constraint argument if you need to check
|
|
||||||
* whether a given version of a package is installed, and not just whether it exists
|
|
||||||
*
|
|
||||||
* @param string $packageName
|
|
||||||
* @return string Version constraint usable with composer/semver
|
|
||||||
*/
|
|
||||||
public static function getVersionRanges($packageName)
|
|
||||||
{
|
|
||||||
foreach (self::getInstalled() as $installed) {
|
|
||||||
if (!isset($installed['versions'][$packageName])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$ranges = array();
|
|
||||||
if (isset($installed['versions'][$packageName]['pretty_version'])) {
|
|
||||||
$ranges[] = $installed['versions'][$packageName]['pretty_version'];
|
|
||||||
}
|
|
||||||
if (array_key_exists('aliases', $installed['versions'][$packageName])) {
|
|
||||||
$ranges = array_merge($ranges, $installed['versions'][$packageName]['aliases']);
|
|
||||||
}
|
|
||||||
if (array_key_exists('replaced', $installed['versions'][$packageName])) {
|
|
||||||
$ranges = array_merge($ranges, $installed['versions'][$packageName]['replaced']);
|
|
||||||
}
|
|
||||||
if (array_key_exists('provided', $installed['versions'][$packageName])) {
|
|
||||||
$ranges = array_merge($ranges, $installed['versions'][$packageName]['provided']);
|
|
||||||
}
|
|
||||||
|
|
||||||
return implode(' || ', $ranges);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param string $packageName
|
|
||||||
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present
|
|
||||||
*/
|
|
||||||
public static function getVersion($packageName)
|
|
||||||
{
|
|
||||||
foreach (self::getInstalled() as $installed) {
|
|
||||||
if (!isset($installed['versions'][$packageName])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isset($installed['versions'][$packageName]['version'])) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $installed['versions'][$packageName]['version'];
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param string $packageName
|
|
||||||
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present
|
|
||||||
*/
|
|
||||||
public static function getPrettyVersion($packageName)
|
|
||||||
{
|
|
||||||
foreach (self::getInstalled() as $installed) {
|
|
||||||
if (!isset($installed['versions'][$packageName])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isset($installed['versions'][$packageName]['pretty_version'])) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $installed['versions'][$packageName]['pretty_version'];
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param string $packageName
|
|
||||||
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as reference
|
|
||||||
*/
|
|
||||||
public static function getReference($packageName)
|
|
||||||
{
|
|
||||||
foreach (self::getInstalled() as $installed) {
|
|
||||||
if (!isset($installed['versions'][$packageName])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isset($installed['versions'][$packageName]['reference'])) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $installed['versions'][$packageName]['reference'];
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param string $packageName
|
|
||||||
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as install path. Packages of type metapackages also have a null install path.
|
|
||||||
*/
|
|
||||||
public static function getInstallPath($packageName)
|
|
||||||
{
|
|
||||||
foreach (self::getInstalled() as $installed) {
|
|
||||||
if (!isset($installed['versions'][$packageName])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
return isset($installed['versions'][$packageName]['install_path']) ? $installed['versions'][$packageName]['install_path'] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array
|
|
||||||
* @psalm-return array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}
|
|
||||||
*/
|
|
||||||
public static function getRootPackage()
|
|
||||||
{
|
|
||||||
$installed = self::getInstalled();
|
|
||||||
|
|
||||||
return $installed[0]['root'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the raw installed.php data for custom implementations
|
|
||||||
*
|
|
||||||
* @deprecated Use getAllRawData() instead which returns all datasets for all autoloaders present in the process. getRawData only returns the first dataset loaded, which may not be what you expect.
|
|
||||||
* @return array[]
|
|
||||||
* @psalm-return array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}
|
|
||||||
*/
|
|
||||||
public static function getRawData()
|
|
||||||
{
|
|
||||||
@trigger_error('getRawData only returns the first dataset loaded, which may not be what you expect. Use getAllRawData() instead which returns all datasets for all autoloaders present in the process.', E_USER_DEPRECATED);
|
|
||||||
|
|
||||||
if (null === self::$installed) {
|
|
||||||
// only require the installed.php file if this file is loaded from its dumped location,
|
|
||||||
// and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
|
|
||||||
if (substr(__DIR__, -8, 1) !== 'C') {
|
|
||||||
self::$installed = include __DIR__ . '/installed.php';
|
|
||||||
} else {
|
|
||||||
self::$installed = array();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::$installed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the raw data of all installed.php which are currently loaded for custom implementations
|
|
||||||
*
|
|
||||||
* @return array[]
|
|
||||||
* @psalm-return list<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
|
|
||||||
*/
|
|
||||||
public static function getAllRawData()
|
|
||||||
{
|
|
||||||
return self::getInstalled();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lets you reload the static array from another file
|
|
||||||
*
|
|
||||||
* This is only useful for complex integrations in which a project needs to use
|
|
||||||
* this class but then also needs to execute another project's autoloader in process,
|
|
||||||
* and wants to ensure both projects have access to their version of installed.php.
|
|
||||||
*
|
|
||||||
* A typical case would be PHPUnit, where it would need to make sure it reads all
|
|
||||||
* the data it needs from this class, then call reload() with
|
|
||||||
* `require $CWD/vendor/composer/installed.php` (or similar) as input to make sure
|
|
||||||
* the project in which it runs can then also use this class safely, without
|
|
||||||
* interference between PHPUnit's dependencies and the project's dependencies.
|
|
||||||
*
|
|
||||||
* @param array[] $data A vendor/composer/installed.php data set
|
|
||||||
* @return void
|
|
||||||
*
|
|
||||||
* @psalm-param array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $data
|
|
||||||
*/
|
|
||||||
public static function reload($data)
|
|
||||||
{
|
|
||||||
self::$installed = $data;
|
|
||||||
self::$installedByVendor = array();
|
|
||||||
|
|
||||||
// when using reload, we disable the duplicate protection to ensure that self::$installed data is
|
|
||||||
// always returned, but we cannot know whether it comes from the installed.php in __DIR__ or not,
|
|
||||||
// so we have to assume it does not, and that may result in duplicate data being returned when listing
|
|
||||||
// all installed packages for example
|
|
||||||
self::$installedIsLocalDir = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
private static function getSelfDir()
|
|
||||||
{
|
|
||||||
if (self::$selfDir === null) {
|
|
||||||
self::$selfDir = strtr(__DIR__, '\\', '/');
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::$selfDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array[]
|
|
||||||
* @psalm-return list<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
|
|
||||||
*/
|
|
||||||
private static function getInstalled()
|
|
||||||
{
|
|
||||||
if (null === self::$canGetVendors) {
|
|
||||||
self::$canGetVendors = method_exists('Composer\Autoload\ClassLoader', 'getRegisteredLoaders');
|
|
||||||
}
|
|
||||||
|
|
||||||
$installed = array();
|
|
||||||
$copiedLocalDir = false;
|
|
||||||
|
|
||||||
if (self::$canGetVendors) {
|
|
||||||
$selfDir = self::getSelfDir();
|
|
||||||
foreach (ClassLoader::getRegisteredLoaders() as $vendorDir => $loader) {
|
|
||||||
$vendorDir = strtr($vendorDir, '\\', '/');
|
|
||||||
if (isset(self::$installedByVendor[$vendorDir])) {
|
|
||||||
$installed[] = self::$installedByVendor[$vendorDir];
|
|
||||||
} elseif (is_file($vendorDir.'/composer/installed.php')) {
|
|
||||||
/** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
|
|
||||||
$required = require $vendorDir.'/composer/installed.php';
|
|
||||||
self::$installedByVendor[$vendorDir] = $required;
|
|
||||||
$installed[] = $required;
|
|
||||||
if (self::$installed === null && $vendorDir.'/composer' === $selfDir) {
|
|
||||||
self::$installed = $required;
|
|
||||||
self::$installedIsLocalDir = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (self::$installedIsLocalDir && $vendorDir.'/composer' === $selfDir) {
|
|
||||||
$copiedLocalDir = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (null === self::$installed) {
|
|
||||||
// only require the installed.php file if this file is loaded from its dumped location,
|
|
||||||
// and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
|
|
||||||
if (substr(__DIR__, -8, 1) !== 'C') {
|
|
||||||
/** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
|
|
||||||
$required = require __DIR__ . '/installed.php';
|
|
||||||
self::$installed = $required;
|
|
||||||
} else {
|
|
||||||
self::$installed = array();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (self::$installed !== array() && !$copiedLocalDir) {
|
|
||||||
$installed[] = self::$installed;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $installed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Vendored
-21
@@ -1,21 +0,0 @@
|
|||||||
|
|
||||||
Copyright (c) Nils Adermann, Jordi Boggiano
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is furnished
|
|
||||||
to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
||||||
THE SOFTWARE.
|
|
||||||
|
|
||||||
-1235
File diff suppressed because it is too large
Load Diff
Vendored
-13
@@ -1,13 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
// autoload_files.php @generated by Composer
|
|
||||||
|
|
||||||
$vendorDir = dirname(__DIR__);
|
|
||||||
$baseDir = dirname($vendorDir);
|
|
||||||
|
|
||||||
return array(
|
|
||||||
'6124b4c8570aa390c21fafd04a26c69f' => $vendorDir . '/myclabs/deep-copy/src/DeepCopy/deep_copy.php',
|
|
||||||
'6e3fae29631ef280660b3cdad06f25a8' => $vendorDir . '/symfony/deprecation-contracts/function.php',
|
|
||||||
'320cde22f66dd4f5d3fd621d3e88b98f' => $vendorDir . '/symfony/polyfill-ctype/bootstrap.php',
|
|
||||||
'ec07570ca5a812141189b1fa81503674' => $vendorDir . '/phpunit/phpunit/src/Framework/Assert/Functions.php',
|
|
||||||
);
|
|
||||||
-9
@@ -1,9 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
// autoload_namespaces.php @generated by Composer
|
|
||||||
|
|
||||||
$vendorDir = dirname(__DIR__);
|
|
||||||
$baseDir = dirname($vendorDir);
|
|
||||||
|
|
||||||
return array(
|
|
||||||
);
|
|
||||||
Vendored
-15
@@ -1,15 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
// autoload_psr4.php @generated by Composer
|
|
||||||
|
|
||||||
$vendorDir = dirname(__DIR__);
|
|
||||||
$baseDir = dirname($vendorDir);
|
|
||||||
|
|
||||||
return array(
|
|
||||||
'Symfony\\Polyfill\\Ctype\\' => array($vendorDir . '/symfony/polyfill-ctype'),
|
|
||||||
'Symfony\\Component\\Yaml\\' => array($vendorDir . '/symfony/yaml'),
|
|
||||||
'PhpParser\\' => array($vendorDir . '/nikic/php-parser/lib/PhpParser'),
|
|
||||||
'IronKanban\\Tests\\' => array($baseDir . '/tests'),
|
|
||||||
'IronKanban\\' => array($baseDir . '/src'),
|
|
||||||
'DeepCopy\\' => array($vendorDir . '/myclabs/deep-copy/src/DeepCopy'),
|
|
||||||
);
|
|
||||||
Vendored
-50
@@ -1,50 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
// autoload_real.php @generated by Composer
|
|
||||||
|
|
||||||
class ComposerAutoloaderInitf25b5b4c093b1a5112f6e1a1bba70ad2
|
|
||||||
{
|
|
||||||
private static $loader;
|
|
||||||
|
|
||||||
public static function loadClassLoader($class)
|
|
||||||
{
|
|
||||||
if ('Composer\Autoload\ClassLoader' === $class) {
|
|
||||||
require __DIR__ . '/ClassLoader.php';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return \Composer\Autoload\ClassLoader
|
|
||||||
*/
|
|
||||||
public static function getLoader()
|
|
||||||
{
|
|
||||||
if (null !== self::$loader) {
|
|
||||||
return self::$loader;
|
|
||||||
}
|
|
||||||
|
|
||||||
require __DIR__ . '/platform_check.php';
|
|
||||||
|
|
||||||
spl_autoload_register(array('ComposerAutoloaderInitf25b5b4c093b1a5112f6e1a1bba70ad2', 'loadClassLoader'), true, true);
|
|
||||||
self::$loader = $loader = new \Composer\Autoload\ClassLoader(\dirname(__DIR__));
|
|
||||||
spl_autoload_unregister(array('ComposerAutoloaderInitf25b5b4c093b1a5112f6e1a1bba70ad2', 'loadClassLoader'));
|
|
||||||
|
|
||||||
require __DIR__ . '/autoload_static.php';
|
|
||||||
call_user_func(\Composer\Autoload\ComposerStaticInitf25b5b4c093b1a5112f6e1a1bba70ad2::getInitializer($loader));
|
|
||||||
|
|
||||||
$loader->register(true);
|
|
||||||
|
|
||||||
$filesToLoad = \Composer\Autoload\ComposerStaticInitf25b5b4c093b1a5112f6e1a1bba70ad2::$files;
|
|
||||||
$requireFile = \Closure::bind(static function ($fileIdentifier, $file) {
|
|
||||||
if (empty($GLOBALS['__composer_autoload_files'][$fileIdentifier])) {
|
|
||||||
$GLOBALS['__composer_autoload_files'][$fileIdentifier] = true;
|
|
||||||
|
|
||||||
require $file;
|
|
||||||
}
|
|
||||||
}, null, null);
|
|
||||||
foreach ($filesToLoad as $fileIdentifier => $file) {
|
|
||||||
$requireFile($fileIdentifier, $file);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $loader;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Vendored
-1302
File diff suppressed because it is too large
Load Diff
Vendored
-2131
File diff suppressed because it is too large
Load Diff
Vendored
-293
@@ -1,293 +0,0 @@
|
|||||||
<?php return array(
|
|
||||||
'root' => array(
|
|
||||||
'name' => 'keith/ironkanban',
|
|
||||||
'pretty_version' => 'dev-main',
|
|
||||||
'version' => 'dev-main',
|
|
||||||
'reference' => 'adabacc48d6d86d9a514c83113405be126de9ae9',
|
|
||||||
'type' => 'project',
|
|
||||||
'install_path' => __DIR__ . '/../../',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev' => true,
|
|
||||||
),
|
|
||||||
'versions' => array(
|
|
||||||
'keith/ironkanban' => array(
|
|
||||||
'pretty_version' => 'dev-main',
|
|
||||||
'version' => 'dev-main',
|
|
||||||
'reference' => 'adabacc48d6d86d9a514c83113405be126de9ae9',
|
|
||||||
'type' => 'project',
|
|
||||||
'install_path' => __DIR__ . '/../../',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => false,
|
|
||||||
),
|
|
||||||
'myclabs/deep-copy' => array(
|
|
||||||
'pretty_version' => '1.13.4',
|
|
||||||
'version' => '1.13.4.0',
|
|
||||||
'reference' => '07d290f0c47959fd5eed98c95ee5602db07e0b6a',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../myclabs/deep-copy',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => true,
|
|
||||||
),
|
|
||||||
'nikic/php-parser' => array(
|
|
||||||
'pretty_version' => 'v5.7.0',
|
|
||||||
'version' => '5.7.0.0',
|
|
||||||
'reference' => 'dca41cd15c2ac9d055ad70dbfd011130757d1f82',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../nikic/php-parser',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => true,
|
|
||||||
),
|
|
||||||
'phar-io/manifest' => array(
|
|
||||||
'pretty_version' => '2.0.4',
|
|
||||||
'version' => '2.0.4.0',
|
|
||||||
'reference' => '54750ef60c58e43759730615a392c31c80e23176',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../phar-io/manifest',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => true,
|
|
||||||
),
|
|
||||||
'phar-io/version' => array(
|
|
||||||
'pretty_version' => '3.2.1',
|
|
||||||
'version' => '3.2.1.0',
|
|
||||||
'reference' => '4f7fd7836c6f332bb2933569e566a0d6c4cbed74',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../phar-io/version',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => true,
|
|
||||||
),
|
|
||||||
'phpunit/php-code-coverage' => array(
|
|
||||||
'pretty_version' => '11.0.12',
|
|
||||||
'version' => '11.0.12.0',
|
|
||||||
'reference' => '2c1ed04922802c15e1de5d7447b4856de949cf56',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../phpunit/php-code-coverage',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => true,
|
|
||||||
),
|
|
||||||
'phpunit/php-file-iterator' => array(
|
|
||||||
'pretty_version' => '5.1.1',
|
|
||||||
'version' => '5.1.1.0',
|
|
||||||
'reference' => '2f3a64888c814fc235386b7387dd5b5ed92ad903',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../phpunit/php-file-iterator',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => true,
|
|
||||||
),
|
|
||||||
'phpunit/php-invoker' => array(
|
|
||||||
'pretty_version' => '5.0.1',
|
|
||||||
'version' => '5.0.1.0',
|
|
||||||
'reference' => 'c1ca3814734c07492b3d4c5f794f4b0995333da2',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../phpunit/php-invoker',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => true,
|
|
||||||
),
|
|
||||||
'phpunit/php-text-template' => array(
|
|
||||||
'pretty_version' => '4.0.1',
|
|
||||||
'version' => '4.0.1.0',
|
|
||||||
'reference' => '3e0404dc6b300e6bf56415467ebcb3fe4f33e964',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../phpunit/php-text-template',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => true,
|
|
||||||
),
|
|
||||||
'phpunit/php-timer' => array(
|
|
||||||
'pretty_version' => '7.0.1',
|
|
||||||
'version' => '7.0.1.0',
|
|
||||||
'reference' => '3b415def83fbcb41f991d9ebf16ae4ad8b7837b3',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../phpunit/php-timer',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => true,
|
|
||||||
),
|
|
||||||
'phpunit/phpunit' => array(
|
|
||||||
'pretty_version' => '11.5.55',
|
|
||||||
'version' => '11.5.55.0',
|
|
||||||
'reference' => 'adc7262fccc12de2b30f12a8aa0b33775d814f00',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../phpunit/phpunit',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => true,
|
|
||||||
),
|
|
||||||
'sebastian/cli-parser' => array(
|
|
||||||
'pretty_version' => '3.0.2',
|
|
||||||
'version' => '3.0.2.0',
|
|
||||||
'reference' => '15c5dd40dc4f38794d383bb95465193f5e0ae180',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../sebastian/cli-parser',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => true,
|
|
||||||
),
|
|
||||||
'sebastian/code-unit' => array(
|
|
||||||
'pretty_version' => '3.0.3',
|
|
||||||
'version' => '3.0.3.0',
|
|
||||||
'reference' => '54391c61e4af8078e5b276ab082b6d3c54c9ad64',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../sebastian/code-unit',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => true,
|
|
||||||
),
|
|
||||||
'sebastian/code-unit-reverse-lookup' => array(
|
|
||||||
'pretty_version' => '4.0.1',
|
|
||||||
'version' => '4.0.1.0',
|
|
||||||
'reference' => '183a9b2632194febd219bb9246eee421dad8d45e',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../sebastian/code-unit-reverse-lookup',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => true,
|
|
||||||
),
|
|
||||||
'sebastian/comparator' => array(
|
|
||||||
'pretty_version' => '6.3.3',
|
|
||||||
'version' => '6.3.3.0',
|
|
||||||
'reference' => '2c95e1e86cb8dd41beb8d502057d1081ccc8eca9',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../sebastian/comparator',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => true,
|
|
||||||
),
|
|
||||||
'sebastian/complexity' => array(
|
|
||||||
'pretty_version' => '4.0.1',
|
|
||||||
'version' => '4.0.1.0',
|
|
||||||
'reference' => 'ee41d384ab1906c68852636b6de493846e13e5a0',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../sebastian/complexity',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => true,
|
|
||||||
),
|
|
||||||
'sebastian/diff' => array(
|
|
||||||
'pretty_version' => '6.0.2',
|
|
||||||
'version' => '6.0.2.0',
|
|
||||||
'reference' => 'b4ccd857127db5d41a5b676f24b51371d76d8544',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../sebastian/diff',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => true,
|
|
||||||
),
|
|
||||||
'sebastian/environment' => array(
|
|
||||||
'pretty_version' => '7.2.1',
|
|
||||||
'version' => '7.2.1.0',
|
|
||||||
'reference' => 'a5c75038693ad2e8d4b6c15ba2403532647830c4',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../sebastian/environment',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => true,
|
|
||||||
),
|
|
||||||
'sebastian/exporter' => array(
|
|
||||||
'pretty_version' => '6.3.2',
|
|
||||||
'version' => '6.3.2.0',
|
|
||||||
'reference' => '70a298763b40b213ec087c51c739efcaa90bcd74',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../sebastian/exporter',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => true,
|
|
||||||
),
|
|
||||||
'sebastian/global-state' => array(
|
|
||||||
'pretty_version' => '7.0.2',
|
|
||||||
'version' => '7.0.2.0',
|
|
||||||
'reference' => '3be331570a721f9a4b5917f4209773de17f747d7',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../sebastian/global-state',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => true,
|
|
||||||
),
|
|
||||||
'sebastian/lines-of-code' => array(
|
|
||||||
'pretty_version' => '3.0.1',
|
|
||||||
'version' => '3.0.1.0',
|
|
||||||
'reference' => 'd36ad0d782e5756913e42ad87cb2890f4ffe467a',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../sebastian/lines-of-code',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => true,
|
|
||||||
),
|
|
||||||
'sebastian/object-enumerator' => array(
|
|
||||||
'pretty_version' => '6.0.1',
|
|
||||||
'version' => '6.0.1.0',
|
|
||||||
'reference' => 'f5b498e631a74204185071eb41f33f38d64608aa',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../sebastian/object-enumerator',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => true,
|
|
||||||
),
|
|
||||||
'sebastian/object-reflector' => array(
|
|
||||||
'pretty_version' => '4.0.1',
|
|
||||||
'version' => '4.0.1.0',
|
|
||||||
'reference' => '6e1a43b411b2ad34146dee7524cb13a068bb35f9',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../sebastian/object-reflector',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => true,
|
|
||||||
),
|
|
||||||
'sebastian/recursion-context' => array(
|
|
||||||
'pretty_version' => '6.0.3',
|
|
||||||
'version' => '6.0.3.0',
|
|
||||||
'reference' => 'f6458abbf32a6c8174f8f26261475dc133b3d9dc',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../sebastian/recursion-context',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => true,
|
|
||||||
),
|
|
||||||
'sebastian/type' => array(
|
|
||||||
'pretty_version' => '5.1.3',
|
|
||||||
'version' => '5.1.3.0',
|
|
||||||
'reference' => 'f77d2d4e78738c98d9a68d2596fe5e8fa380f449',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../sebastian/type',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => true,
|
|
||||||
),
|
|
||||||
'sebastian/version' => array(
|
|
||||||
'pretty_version' => '5.0.2',
|
|
||||||
'version' => '5.0.2.0',
|
|
||||||
'reference' => 'c687e3387b99f5b03b6caa64c74b63e2936ff874',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../sebastian/version',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => true,
|
|
||||||
),
|
|
||||||
'staabm/side-effects-detector' => array(
|
|
||||||
'pretty_version' => '1.0.5',
|
|
||||||
'version' => '1.0.5.0',
|
|
||||||
'reference' => 'd8334211a140ce329c13726d4a715adbddd0a163',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../staabm/side-effects-detector',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => true,
|
|
||||||
),
|
|
||||||
'symfony/deprecation-contracts' => array(
|
|
||||||
'pretty_version' => 'v3.6.0',
|
|
||||||
'version' => '3.6.0.0',
|
|
||||||
'reference' => '63afe740e99a13ba87ec199bb07bbdee937a5b62',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../symfony/deprecation-contracts',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => false,
|
|
||||||
),
|
|
||||||
'symfony/polyfill-ctype' => array(
|
|
||||||
'pretty_version' => 'v1.33.0',
|
|
||||||
'version' => '1.33.0.0',
|
|
||||||
'reference' => 'a3cc8b044a6ea513310cbd48ef7333b384945638',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../symfony/polyfill-ctype',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => false,
|
|
||||||
),
|
|
||||||
'symfony/yaml' => array(
|
|
||||||
'pretty_version' => 'v7.4.6',
|
|
||||||
'version' => '7.4.6.0',
|
|
||||||
'reference' => '58751048de17bae71c5aa0d13cb19d79bca26391',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../symfony/yaml',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => false,
|
|
||||||
),
|
|
||||||
'theseer/tokenizer' => array(
|
|
||||||
'pretty_version' => '1.3.1',
|
|
||||||
'version' => '1.3.1.0',
|
|
||||||
'reference' => 'b7489ce515e168639d17feec34b8847c326b0b3c',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../theseer/tokenizer',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => true,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
Vendored
-25
@@ -1,25 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
// platform_check.php @generated by Composer
|
|
||||||
|
|
||||||
$issues = array();
|
|
||||||
|
|
||||||
if (!(PHP_VERSION_ID >= 80200)) {
|
|
||||||
$issues[] = 'Your Composer dependencies require a PHP version ">= 8.2.0". You are running ' . PHP_VERSION . '.';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($issues) {
|
|
||||||
if (!headers_sent()) {
|
|
||||||
header('HTTP/1.1 500 Internal Server Error');
|
|
||||||
}
|
|
||||||
if (!ini_get('display_errors')) {
|
|
||||||
if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
|
|
||||||
fwrite(STDERR, 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . implode(PHP_EOL, $issues) . PHP_EOL.PHP_EOL);
|
|
||||||
} elseif (!headers_sent()) {
|
|
||||||
echo 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . str_replace('You are running '.PHP_VERSION.'.', '', implode(PHP_EOL, $issues)) . PHP_EOL.PHP_EOL;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new \RuntimeException(
|
|
||||||
'Composer detected issues in your platform: ' . implode(' ', $issues)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Vendored
-20
@@ -1,20 +0,0 @@
|
|||||||
The MIT License (MIT)
|
|
||||||
|
|
||||||
Copyright (c) 2013 My C-Sense
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
||||||
this software and associated documentation files (the "Software"), to deal in
|
|
||||||
the Software without restriction, including without limitation the rights to
|
|
||||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
|
||||||
the Software, and to permit persons to whom the Software is furnished to do so,
|
|
||||||
subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
||||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
||||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
||||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
||||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
Vendored
-406
@@ -1,406 +0,0 @@
|
|||||||
# DeepCopy
|
|
||||||
|
|
||||||
DeepCopy helps you create deep copies (clones) of your objects. It is designed to handle cycles in the association graph.
|
|
||||||
|
|
||||||
[](https://packagist.org/packages/myclabs/deep-copy)
|
|
||||||
[](https://github.com/myclabs/DeepCopy/actions/workflows/ci.yaml)
|
|
||||||
|
|
||||||
## Table of Contents
|
|
||||||
|
|
||||||
1. [How](#how)
|
|
||||||
1. [Why](#why)
|
|
||||||
1. [Using simply `clone`](#using-simply-clone)
|
|
||||||
1. [Overriding `__clone()`](#overriding-__clone)
|
|
||||||
1. [With `DeepCopy`](#with-deepcopy)
|
|
||||||
1. [How it works](#how-it-works)
|
|
||||||
1. [Going further](#going-further)
|
|
||||||
1. [Matchers](#matchers)
|
|
||||||
1. [Property name](#property-name)
|
|
||||||
1. [Specific property](#specific-property)
|
|
||||||
1. [Type](#type)
|
|
||||||
1. [Filters](#filters)
|
|
||||||
1. [`SetNullFilter`](#setnullfilter-filter)
|
|
||||||
1. [`KeepFilter`](#keepfilter-filter)
|
|
||||||
1. [`DoctrineCollectionFilter`](#doctrinecollectionfilter-filter)
|
|
||||||
1. [`DoctrineEmptyCollectionFilter`](#doctrineemptycollectionfilter-filter)
|
|
||||||
1. [`DoctrineProxyFilter`](#doctrineproxyfilter-filter)
|
|
||||||
1. [`ReplaceFilter`](#replacefilter-type-filter)
|
|
||||||
1. [`ShallowCopyFilter`](#shallowcopyfilter-type-filter)
|
|
||||||
1. [Edge cases](#edge-cases)
|
|
||||||
1. [Contributing](#contributing)
|
|
||||||
1. [Tests](#tests)
|
|
||||||
|
|
||||||
|
|
||||||
## How?
|
|
||||||
|
|
||||||
Install with Composer:
|
|
||||||
|
|
||||||
```
|
|
||||||
composer require myclabs/deep-copy
|
|
||||||
```
|
|
||||||
|
|
||||||
Use it:
|
|
||||||
|
|
||||||
```php
|
|
||||||
use DeepCopy\DeepCopy;
|
|
||||||
|
|
||||||
$copier = new DeepCopy();
|
|
||||||
$myCopy = $copier->copy($myObject);
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
## Why?
|
|
||||||
|
|
||||||
- How do you create copies of your objects?
|
|
||||||
|
|
||||||
```php
|
|
||||||
$myCopy = clone $myObject;
|
|
||||||
```
|
|
||||||
|
|
||||||
- How do you create **deep** copies of your objects (i.e. copying also all the objects referenced in the properties)?
|
|
||||||
|
|
||||||
You use [`__clone()`](http://www.php.net/manual/en/language.oop5.cloning.php#object.clone) and implement the behavior
|
|
||||||
yourself.
|
|
||||||
|
|
||||||
- But how do you handle **cycles** in the association graph?
|
|
||||||
|
|
||||||
Now you're in for a big mess :(
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
|
|
||||||
### Using simply `clone`
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
|
|
||||||
### Overriding `__clone()`
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
|
|
||||||
### With `DeepCopy`
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
|
|
||||||
## How it works
|
|
||||||
|
|
||||||
DeepCopy recursively traverses all the object's properties and clones them. To avoid cloning the same object twice it
|
|
||||||
keeps a hash map of all instances and thus preserves the object graph.
|
|
||||||
|
|
||||||
To use it:
|
|
||||||
|
|
||||||
```php
|
|
||||||
use function DeepCopy\deep_copy;
|
|
||||||
|
|
||||||
$copy = deep_copy($var);
|
|
||||||
```
|
|
||||||
|
|
||||||
Alternatively, you can create your own `DeepCopy` instance to configure it differently for example:
|
|
||||||
|
|
||||||
```php
|
|
||||||
use DeepCopy\DeepCopy;
|
|
||||||
|
|
||||||
$copier = new DeepCopy(true);
|
|
||||||
|
|
||||||
$copy = $copier->copy($var);
|
|
||||||
```
|
|
||||||
|
|
||||||
You may want to roll your own deep copy function:
|
|
||||||
|
|
||||||
```php
|
|
||||||
namespace Acme;
|
|
||||||
|
|
||||||
use DeepCopy\DeepCopy;
|
|
||||||
|
|
||||||
function deep_copy($var)
|
|
||||||
{
|
|
||||||
static $copier = null;
|
|
||||||
|
|
||||||
if (null === $copier) {
|
|
||||||
$copier = new DeepCopy(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $copier->copy($var);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
## Going further
|
|
||||||
|
|
||||||
You can add filters to customize the copy process.
|
|
||||||
|
|
||||||
The method to add a filter is `DeepCopy\DeepCopy::addFilter($filter, $matcher)`,
|
|
||||||
with `$filter` implementing `DeepCopy\Filter\Filter`
|
|
||||||
and `$matcher` implementing `DeepCopy\Matcher\Matcher`.
|
|
||||||
|
|
||||||
We provide some generic filters and matchers.
|
|
||||||
|
|
||||||
|
|
||||||
### Matchers
|
|
||||||
|
|
||||||
- `DeepCopy\Matcher` applies on a object attribute.
|
|
||||||
- `DeepCopy\TypeMatcher` applies on any element found in graph, including array elements.
|
|
||||||
|
|
||||||
|
|
||||||
#### Property name
|
|
||||||
|
|
||||||
The `PropertyNameMatcher` will match a property by its name:
|
|
||||||
|
|
||||||
```php
|
|
||||||
use DeepCopy\Matcher\PropertyNameMatcher;
|
|
||||||
|
|
||||||
// Will apply a filter to any property of any objects named "id"
|
|
||||||
$matcher = new PropertyNameMatcher('id');
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
#### Specific property
|
|
||||||
|
|
||||||
The `PropertyMatcher` will match a specific property of a specific class:
|
|
||||||
|
|
||||||
```php
|
|
||||||
use DeepCopy\Matcher\PropertyMatcher;
|
|
||||||
|
|
||||||
// Will apply a filter to the property "id" of any objects of the class "MyClass"
|
|
||||||
$matcher = new PropertyMatcher('MyClass', 'id');
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
#### Type
|
|
||||||
|
|
||||||
The `TypeMatcher` will match any element by its type (instance of a class or any value that could be parameter of
|
|
||||||
[gettype()](http://php.net/manual/en/function.gettype.php) function):
|
|
||||||
|
|
||||||
```php
|
|
||||||
use DeepCopy\TypeMatcher\TypeMatcher;
|
|
||||||
|
|
||||||
// Will apply a filter to any object that is an instance of Doctrine\Common\Collections\Collection
|
|
||||||
$matcher = new TypeMatcher('Doctrine\Common\Collections\Collection');
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
### Filters
|
|
||||||
|
|
||||||
- `DeepCopy\Filter` applies a transformation to the object attribute matched by `DeepCopy\Matcher`
|
|
||||||
- `DeepCopy\TypeFilter` applies a transformation to any element matched by `DeepCopy\TypeMatcher`
|
|
||||||
|
|
||||||
By design, matching a filter will stop the chain of filters (i.e. the next ones will not be applied).
|
|
||||||
Using the ([`ChainableFilter`](#chainablefilter-filter)) won't stop the chain of filters.
|
|
||||||
|
|
||||||
|
|
||||||
#### `SetNullFilter` (filter)
|
|
||||||
|
|
||||||
Let's say for example that you are copying a database record (or a Doctrine entity), so you want the copy not to have
|
|
||||||
any ID:
|
|
||||||
|
|
||||||
```php
|
|
||||||
use DeepCopy\DeepCopy;
|
|
||||||
use DeepCopy\Filter\SetNullFilter;
|
|
||||||
use DeepCopy\Matcher\PropertyNameMatcher;
|
|
||||||
|
|
||||||
$object = MyClass::load(123);
|
|
||||||
echo $object->id; // 123
|
|
||||||
|
|
||||||
$copier = new DeepCopy();
|
|
||||||
$copier->addFilter(new SetNullFilter(), new PropertyNameMatcher('id'));
|
|
||||||
|
|
||||||
$copy = $copier->copy($object);
|
|
||||||
|
|
||||||
echo $copy->id; // null
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
#### `KeepFilter` (filter)
|
|
||||||
|
|
||||||
If you want a property to remain untouched (for example, an association to an object):
|
|
||||||
|
|
||||||
```php
|
|
||||||
use DeepCopy\DeepCopy;
|
|
||||||
use DeepCopy\Filter\KeepFilter;
|
|
||||||
use DeepCopy\Matcher\PropertyMatcher;
|
|
||||||
|
|
||||||
$copier = new DeepCopy();
|
|
||||||
$copier->addFilter(new KeepFilter(), new PropertyMatcher('MyClass', 'category'));
|
|
||||||
|
|
||||||
$copy = $copier->copy($object);
|
|
||||||
// $copy->category has not been touched
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
#### `ChainableFilter` (filter)
|
|
||||||
|
|
||||||
If you use cloning on proxy classes, you might want to apply two filters for:
|
|
||||||
1. loading the data
|
|
||||||
2. applying a transformation
|
|
||||||
|
|
||||||
You can use the `ChainableFilter` as a decorator of the proxy loader filter, which won't stop the chain of filters (i.e.
|
|
||||||
the next ones may be applied).
|
|
||||||
|
|
||||||
|
|
||||||
```php
|
|
||||||
use DeepCopy\DeepCopy;
|
|
||||||
use DeepCopy\Filter\ChainableFilter;
|
|
||||||
use DeepCopy\Filter\Doctrine\DoctrineProxyFilter;
|
|
||||||
use DeepCopy\Filter\SetNullFilter;
|
|
||||||
use DeepCopy\Matcher\Doctrine\DoctrineProxyMatcher;
|
|
||||||
use DeepCopy\Matcher\PropertyNameMatcher;
|
|
||||||
|
|
||||||
$copier = new DeepCopy();
|
|
||||||
$copier->addFilter(new ChainableFilter(new DoctrineProxyFilter()), new DoctrineProxyMatcher());
|
|
||||||
$copier->addFilter(new SetNullFilter(), new PropertyNameMatcher('id'));
|
|
||||||
|
|
||||||
$copy = $copier->copy($object);
|
|
||||||
|
|
||||||
echo $copy->id; // null
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
#### `DoctrineCollectionFilter` (filter)
|
|
||||||
|
|
||||||
If you use Doctrine and want to copy an entity, you will need to use the `DoctrineCollectionFilter`:
|
|
||||||
|
|
||||||
```php
|
|
||||||
use DeepCopy\DeepCopy;
|
|
||||||
use DeepCopy\Filter\Doctrine\DoctrineCollectionFilter;
|
|
||||||
use DeepCopy\Matcher\PropertyTypeMatcher;
|
|
||||||
|
|
||||||
$copier = new DeepCopy();
|
|
||||||
$copier->addFilter(new DoctrineCollectionFilter(), new PropertyTypeMatcher('Doctrine\Common\Collections\Collection'));
|
|
||||||
|
|
||||||
$copy = $copier->copy($object);
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
#### `DoctrineEmptyCollectionFilter` (filter)
|
|
||||||
|
|
||||||
If you use Doctrine and want to copy an entity who contains a `Collection` that you want to be reset, you can use the
|
|
||||||
`DoctrineEmptyCollectionFilter`
|
|
||||||
|
|
||||||
```php
|
|
||||||
use DeepCopy\DeepCopy;
|
|
||||||
use DeepCopy\Filter\Doctrine\DoctrineEmptyCollectionFilter;
|
|
||||||
use DeepCopy\Matcher\PropertyMatcher;
|
|
||||||
|
|
||||||
$copier = new DeepCopy();
|
|
||||||
$copier->addFilter(new DoctrineEmptyCollectionFilter(), new PropertyMatcher('MyClass', 'myProperty'));
|
|
||||||
|
|
||||||
$copy = $copier->copy($object);
|
|
||||||
|
|
||||||
// $copy->myProperty will return an empty collection
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
#### `DoctrineProxyFilter` (filter)
|
|
||||||
|
|
||||||
If you use Doctrine and use cloning on lazy loaded entities, you might encounter errors mentioning missing fields on a
|
|
||||||
Doctrine proxy class (...\\\_\_CG\_\_\Proxy).
|
|
||||||
You can use the `DoctrineProxyFilter` to load the actual entity behind the Doctrine proxy class.
|
|
||||||
**Make sure, though, to put this as one of your very first filters in the filter chain so that the entity is loaded
|
|
||||||
before other filters are applied!**
|
|
||||||
We recommend to decorate the `DoctrineProxyFilter` with the `ChainableFilter` to allow applying other filters to the
|
|
||||||
cloned lazy loaded entities.
|
|
||||||
|
|
||||||
```php
|
|
||||||
use DeepCopy\DeepCopy;
|
|
||||||
use DeepCopy\Filter\Doctrine\DoctrineProxyFilter;
|
|
||||||
use DeepCopy\Matcher\Doctrine\DoctrineProxyMatcher;
|
|
||||||
|
|
||||||
$copier = new DeepCopy();
|
|
||||||
$copier->addFilter(new ChainableFilter(new DoctrineProxyFilter()), new DoctrineProxyMatcher());
|
|
||||||
|
|
||||||
$copy = $copier->copy($object);
|
|
||||||
|
|
||||||
// $copy should now contain a clone of all entities, including those that were not yet fully loaded.
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
#### `ReplaceFilter` (type filter)
|
|
||||||
|
|
||||||
1. If you want to replace the value of a property:
|
|
||||||
|
|
||||||
```php
|
|
||||||
use DeepCopy\DeepCopy;
|
|
||||||
use DeepCopy\Filter\ReplaceFilter;
|
|
||||||
use DeepCopy\Matcher\PropertyMatcher;
|
|
||||||
|
|
||||||
$copier = new DeepCopy();
|
|
||||||
$callback = function ($currentValue) {
|
|
||||||
return $currentValue . ' (copy)'
|
|
||||||
};
|
|
||||||
$copier->addFilter(new ReplaceFilter($callback), new PropertyMatcher('MyClass', 'title'));
|
|
||||||
|
|
||||||
$copy = $copier->copy($object);
|
|
||||||
|
|
||||||
// $copy->title will contain the data returned by the callback, e.g. 'The title (copy)'
|
|
||||||
```
|
|
||||||
|
|
||||||
2. If you want to replace whole element:
|
|
||||||
|
|
||||||
```php
|
|
||||||
use DeepCopy\DeepCopy;
|
|
||||||
use DeepCopy\TypeFilter\ReplaceFilter;
|
|
||||||
use DeepCopy\TypeMatcher\TypeMatcher;
|
|
||||||
|
|
||||||
$copier = new DeepCopy();
|
|
||||||
$callback = function (MyClass $myClass) {
|
|
||||||
return get_class($myClass);
|
|
||||||
};
|
|
||||||
$copier->addTypeFilter(new ReplaceFilter($callback), new TypeMatcher('MyClass'));
|
|
||||||
|
|
||||||
$copy = $copier->copy([new MyClass, 'some string', new MyClass]);
|
|
||||||
|
|
||||||
// $copy will contain ['MyClass', 'some string', 'MyClass']
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
The `$callback` parameter of the `ReplaceFilter` constructor accepts any PHP callable.
|
|
||||||
|
|
||||||
|
|
||||||
#### `ShallowCopyFilter` (type filter)
|
|
||||||
|
|
||||||
Stop *DeepCopy* from recursively copying element, using standard `clone` instead:
|
|
||||||
|
|
||||||
```php
|
|
||||||
use DeepCopy\DeepCopy;
|
|
||||||
use DeepCopy\TypeFilter\ShallowCopyFilter;
|
|
||||||
use DeepCopy\TypeMatcher\TypeMatcher;
|
|
||||||
use Mockery as m;
|
|
||||||
|
|
||||||
$this->deepCopy = new DeepCopy();
|
|
||||||
$this->deepCopy->addTypeFilter(
|
|
||||||
new ShallowCopyFilter,
|
|
||||||
new TypeMatcher(m\MockInterface::class)
|
|
||||||
);
|
|
||||||
|
|
||||||
$myServiceWithMocks = new MyService(m::mock(MyDependency1::class), m::mock(MyDependency2::class));
|
|
||||||
// All mocks will be just cloned, not deep copied
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
## Edge cases
|
|
||||||
|
|
||||||
The following structures cannot be deep-copied with PHP Reflection. As a result they are shallow cloned and filters are
|
|
||||||
not applied. There is two ways for you to handle them:
|
|
||||||
|
|
||||||
- Implement your own `__clone()` method
|
|
||||||
- Use a filter with a type matcher
|
|
||||||
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
DeepCopy is distributed under the MIT license.
|
|
||||||
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
Running the tests is simple:
|
|
||||||
|
|
||||||
```php
|
|
||||||
vendor/bin/phpunit
|
|
||||||
```
|
|
||||||
|
|
||||||
### Support
|
|
||||||
|
|
||||||
Get professional support via [the Tidelift Subscription](https://tidelift.com/subscription/pkg/packagist-myclabs-deep-copy?utm_source=packagist-myclabs-deep-copy&utm_medium=referral&utm_campaign=readme).
|
|
||||||
-43
@@ -1,43 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "myclabs/deep-copy",
|
|
||||||
"description": "Create deep copies (clones) of your objects",
|
|
||||||
"license": "MIT",
|
|
||||||
"type": "library",
|
|
||||||
"keywords": [
|
|
||||||
"clone",
|
|
||||||
"copy",
|
|
||||||
"duplicate",
|
|
||||||
"object",
|
|
||||||
"object graph"
|
|
||||||
],
|
|
||||||
"require": {
|
|
||||||
"php": "^7.1 || ^8.0"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"doctrine/collections": "^1.6.8",
|
|
||||||
"doctrine/common": "^2.13.3 || ^3.2.2",
|
|
||||||
"phpspec/prophecy": "^1.10",
|
|
||||||
"phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13"
|
|
||||||
},
|
|
||||||
"conflict": {
|
|
||||||
"doctrine/collections": "<1.6.8",
|
|
||||||
"doctrine/common": "<2.13.3 || >=3 <3.2.2"
|
|
||||||
},
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"DeepCopy\\": "src/DeepCopy/"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"src/DeepCopy/deep_copy.php"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"autoload-dev": {
|
|
||||||
"psr-4": {
|
|
||||||
"DeepCopyTest\\": "tests/DeepCopyTest/",
|
|
||||||
"DeepCopy\\": "fixtures/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"config": {
|
|
||||||
"sort-packages": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-328
@@ -1,328 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace DeepCopy;
|
|
||||||
|
|
||||||
use ArrayObject;
|
|
||||||
use DateInterval;
|
|
||||||
use DatePeriod;
|
|
||||||
use DateTimeInterface;
|
|
||||||
use DateTimeZone;
|
|
||||||
use DeepCopy\Exception\CloneException;
|
|
||||||
use DeepCopy\Filter\ChainableFilter;
|
|
||||||
use DeepCopy\Filter\Filter;
|
|
||||||
use DeepCopy\Matcher\Matcher;
|
|
||||||
use DeepCopy\Reflection\ReflectionHelper;
|
|
||||||
use DeepCopy\TypeFilter\Date\DateIntervalFilter;
|
|
||||||
use DeepCopy\TypeFilter\Date\DatePeriodFilter;
|
|
||||||
use DeepCopy\TypeFilter\Spl\ArrayObjectFilter;
|
|
||||||
use DeepCopy\TypeFilter\Spl\SplDoublyLinkedListFilter;
|
|
||||||
use DeepCopy\TypeFilter\TypeFilter;
|
|
||||||
use DeepCopy\TypeMatcher\TypeMatcher;
|
|
||||||
use ReflectionObject;
|
|
||||||
use ReflectionProperty;
|
|
||||||
use SplDoublyLinkedList;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @final
|
|
||||||
*/
|
|
||||||
class DeepCopy
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @var object[] List of objects copied.
|
|
||||||
*/
|
|
||||||
private $hashMap = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filters to apply.
|
|
||||||
*
|
|
||||||
* @var array Array of ['filter' => Filter, 'matcher' => Matcher] pairs.
|
|
||||||
*/
|
|
||||||
private $filters = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Type Filters to apply.
|
|
||||||
*
|
|
||||||
* @var array Array of ['filter' => Filter, 'matcher' => Matcher] pairs.
|
|
||||||
*/
|
|
||||||
private $typeFilters = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var bool
|
|
||||||
*/
|
|
||||||
private $skipUncloneable = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var bool
|
|
||||||
*/
|
|
||||||
private $useCloneMethod;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param bool $useCloneMethod If set to true, when an object implements the __clone() function, it will be used
|
|
||||||
* instead of the regular deep cloning.
|
|
||||||
*/
|
|
||||||
public function __construct($useCloneMethod = false)
|
|
||||||
{
|
|
||||||
$this->useCloneMethod = $useCloneMethod;
|
|
||||||
|
|
||||||
$this->addTypeFilter(new ArrayObjectFilter($this), new TypeMatcher(ArrayObject::class));
|
|
||||||
$this->addTypeFilter(new DateIntervalFilter(), new TypeMatcher(DateInterval::class));
|
|
||||||
$this->addTypeFilter(new DatePeriodFilter(), new TypeMatcher(DatePeriod::class));
|
|
||||||
$this->addTypeFilter(new SplDoublyLinkedListFilter($this), new TypeMatcher(SplDoublyLinkedList::class));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* If enabled, will not throw an exception when coming across an uncloneable property.
|
|
||||||
*
|
|
||||||
* @param $skipUncloneable
|
|
||||||
*
|
|
||||||
* @return $this
|
|
||||||
*/
|
|
||||||
public function skipUncloneable($skipUncloneable = true)
|
|
||||||
{
|
|
||||||
$this->skipUncloneable = $skipUncloneable;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deep copies the given object.
|
|
||||||
*
|
|
||||||
* @template TObject
|
|
||||||
*
|
|
||||||
* @param TObject $object
|
|
||||||
*
|
|
||||||
* @return TObject
|
|
||||||
*/
|
|
||||||
public function copy($object)
|
|
||||||
{
|
|
||||||
$this->hashMap = [];
|
|
||||||
|
|
||||||
return $this->recursiveCopy($object);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function addFilter(Filter $filter, Matcher $matcher)
|
|
||||||
{
|
|
||||||
$this->filters[] = [
|
|
||||||
'matcher' => $matcher,
|
|
||||||
'filter' => $filter,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function prependFilter(Filter $filter, Matcher $matcher)
|
|
||||||
{
|
|
||||||
array_unshift($this->filters, [
|
|
||||||
'matcher' => $matcher,
|
|
||||||
'filter' => $filter,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function addTypeFilter(TypeFilter $filter, TypeMatcher $matcher)
|
|
||||||
{
|
|
||||||
$this->typeFilters[] = [
|
|
||||||
'matcher' => $matcher,
|
|
||||||
'filter' => $filter,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function prependTypeFilter(TypeFilter $filter, TypeMatcher $matcher)
|
|
||||||
{
|
|
||||||
array_unshift($this->typeFilters, [
|
|
||||||
'matcher' => $matcher,
|
|
||||||
'filter' => $filter,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function recursiveCopy($var)
|
|
||||||
{
|
|
||||||
// Matches Type Filter
|
|
||||||
if ($filter = $this->getFirstMatchedTypeFilter($this->typeFilters, $var)) {
|
|
||||||
return $filter->apply($var);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resource
|
|
||||||
if (is_resource($var)) {
|
|
||||||
return $var;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Array
|
|
||||||
if (is_array($var)) {
|
|
||||||
return $this->copyArray($var);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scalar
|
|
||||||
if (! is_object($var)) {
|
|
||||||
return $var;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enum
|
|
||||||
if (PHP_VERSION_ID >= 80100 && enum_exists(get_class($var))) {
|
|
||||||
return $var;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Object
|
|
||||||
return $this->copyObject($var);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Copy an array
|
|
||||||
* @param array $array
|
|
||||||
* @return array
|
|
||||||
*/
|
|
||||||
private function copyArray(array $array)
|
|
||||||
{
|
|
||||||
foreach ($array as $key => $value) {
|
|
||||||
$array[$key] = $this->recursiveCopy($value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $array;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Copies an object.
|
|
||||||
*
|
|
||||||
* @param object $object
|
|
||||||
*
|
|
||||||
* @throws CloneException
|
|
||||||
*
|
|
||||||
* @return object
|
|
||||||
*/
|
|
||||||
private function copyObject($object)
|
|
||||||
{
|
|
||||||
$objectHash = spl_object_hash($object);
|
|
||||||
|
|
||||||
if (isset($this->hashMap[$objectHash])) {
|
|
||||||
return $this->hashMap[$objectHash];
|
|
||||||
}
|
|
||||||
|
|
||||||
$reflectedObject = new ReflectionObject($object);
|
|
||||||
$isCloneable = $reflectedObject->isCloneable();
|
|
||||||
|
|
||||||
if (false === $isCloneable) {
|
|
||||||
if ($this->skipUncloneable) {
|
|
||||||
$this->hashMap[$objectHash] = $object;
|
|
||||||
|
|
||||||
return $object;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new CloneException(
|
|
||||||
sprintf(
|
|
||||||
'The class "%s" is not cloneable.',
|
|
||||||
$reflectedObject->getName()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$newObject = clone $object;
|
|
||||||
$this->hashMap[$objectHash] = $newObject;
|
|
||||||
|
|
||||||
if ($this->useCloneMethod && $reflectedObject->hasMethod('__clone')) {
|
|
||||||
return $newObject;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($newObject instanceof DateTimeInterface || $newObject instanceof DateTimeZone) {
|
|
||||||
return $newObject;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (ReflectionHelper::getProperties($reflectedObject) as $property) {
|
|
||||||
$this->copyObjectProperty($newObject, $property);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $newObject;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function copyObjectProperty($object, ReflectionProperty $property)
|
|
||||||
{
|
|
||||||
// Ignore static properties
|
|
||||||
if ($property->isStatic()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ignore readonly properties
|
|
||||||
if (method_exists($property, 'isReadOnly') && $property->isReadOnly()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply the filters
|
|
||||||
foreach ($this->filters as $item) {
|
|
||||||
/** @var Matcher $matcher */
|
|
||||||
$matcher = $item['matcher'];
|
|
||||||
/** @var Filter $filter */
|
|
||||||
$filter = $item['filter'];
|
|
||||||
|
|
||||||
if ($matcher->matches($object, $property->getName())) {
|
|
||||||
$filter->apply(
|
|
||||||
$object,
|
|
||||||
$property->getName(),
|
|
||||||
function ($object) {
|
|
||||||
return $this->recursiveCopy($object);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($filter instanceof ChainableFilter) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If a filter matches, we stop processing this property
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (PHP_VERSION_ID < 80100) {
|
|
||||||
$property->setAccessible(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ignore uninitialized properties (for PHP >7.4)
|
|
||||||
if (method_exists($property, 'isInitialized') && !$property->isInitialized($object)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$propertyValue = $property->getValue($object);
|
|
||||||
|
|
||||||
// Copy the property
|
|
||||||
$property->setValue($object, $this->recursiveCopy($propertyValue));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns first filter that matches variable, `null` if no such filter found.
|
|
||||||
*
|
|
||||||
* @param array $filterRecords Associative array with 2 members: 'filter' with value of type {@see TypeFilter} and
|
|
||||||
* 'matcher' with value of type {@see TypeMatcher}
|
|
||||||
* @param mixed $var
|
|
||||||
*
|
|
||||||
* @return TypeFilter|null
|
|
||||||
*/
|
|
||||||
private function getFirstMatchedTypeFilter(array $filterRecords, $var)
|
|
||||||
{
|
|
||||||
$matched = $this->first(
|
|
||||||
$filterRecords,
|
|
||||||
function (array $record) use ($var) {
|
|
||||||
/* @var TypeMatcher $matcher */
|
|
||||||
$matcher = $record['matcher'];
|
|
||||||
|
|
||||||
return $matcher->matches($var);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return isset($matched) ? $matched['filter'] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns first element that matches predicate, `null` if no such element found.
|
|
||||||
*
|
|
||||||
* @param array $elements Array of ['filter' => Filter, 'matcher' => Matcher] pairs.
|
|
||||||
* @param callable $predicate Predicate arguments are: element.
|
|
||||||
*
|
|
||||||
* @return array|null Associative array with 2 members: 'filter' with value of type {@see TypeFilter} and 'matcher'
|
|
||||||
* with value of type {@see TypeMatcher} or `null`.
|
|
||||||
*/
|
|
||||||
private function first(array $elements, callable $predicate)
|
|
||||||
{
|
|
||||||
foreach ($elements as $element) {
|
|
||||||
if (call_user_func($predicate, $element)) {
|
|
||||||
return $element;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace DeepCopy\Exception;
|
|
||||||
|
|
||||||
use UnexpectedValueException;
|
|
||||||
|
|
||||||
class CloneException extends UnexpectedValueException
|
|
||||||
{
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace DeepCopy\Exception;
|
|
||||||
|
|
||||||
use ReflectionException;
|
|
||||||
|
|
||||||
class PropertyException extends ReflectionException
|
|
||||||
{
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user