yarlo-plugin-board 0.1.2 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/board-server.d.ts +29 -0
- package/dist/board-server.d.ts.map +1 -0
- package/dist/board-server.js +153 -0
- package/dist/board-server.js.map +1 -0
- package/dist/board-ui.html +1060 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +65 -4
- package/dist/index.js.map +1 -1
- package/dist/status-field.d.ts +9 -0
- package/dist/status-field.d.ts.map +1 -0
- package/dist/status-field.js +23 -0
- package/dist/status-field.js.map +1 -0
- package/package.json +6 -3
- package/source/board-server.test.ts +164 -0
- package/source/board-server.ts +199 -0
- package/source/board-ui.html +1060 -0
- package/source/index.ts +75 -4
- package/source/status-field.ts +28 -0
- package/tsconfig.json +2 -1
- package/vitest.config.ts +14 -0
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../source/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../source/index.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AA6C/C,QAAA,MAAM,MAAM,EAAE,WAwCb,CAAC;AAEF,eAAe,MAAM,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,14 +1,75 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { serve } from "@hono/node-server";
|
|
3
|
+
import { createBoardApp } from "./board-server.js";
|
|
4
|
+
const DEFAULT_PORT = 3847;
|
|
5
|
+
/**
|
|
6
|
+
* Parses `--port <n>` from plugin CLI args.
|
|
7
|
+
*
|
|
8
|
+
* @param args - Arguments after `yarlo board`
|
|
9
|
+
* @returns Valid TCP port
|
|
10
|
+
*/
|
|
11
|
+
function parsePort(args) {
|
|
12
|
+
const idx = args.indexOf("--port");
|
|
13
|
+
if (idx >= 0 && args[idx + 1]) {
|
|
14
|
+
const n = Number.parseInt(args[idx + 1], 10);
|
|
15
|
+
if (!Number.isNaN(n) && n > 0 && n < 65536) {
|
|
16
|
+
return n;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return DEFAULT_PORT;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Opens the given URL in the system default browser.
|
|
23
|
+
*
|
|
24
|
+
* @param url - HTTP URL to open
|
|
25
|
+
*/
|
|
26
|
+
function openBrowser(url) {
|
|
27
|
+
const platform = process.platform;
|
|
28
|
+
if (platform === "darwin") {
|
|
29
|
+
spawn("open", [url], { detached: true, stdio: "ignore" }).unref();
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (platform === "win32") {
|
|
33
|
+
spawn("cmd", ["/c", "start", "", url], {
|
|
34
|
+
detached: true,
|
|
35
|
+
stdio: "ignore",
|
|
36
|
+
windowsHide: true,
|
|
37
|
+
}).unref();
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
|
|
41
|
+
}
|
|
1
42
|
const plugin = {
|
|
2
43
|
name: "board",
|
|
3
|
-
version: "0.1
|
|
44
|
+
version: "0.2.1",
|
|
4
45
|
description: "Kanban board view for yarlo tasks",
|
|
5
46
|
commands: [
|
|
6
47
|
{
|
|
7
48
|
name: "board",
|
|
8
49
|
description: "Open the task board in your browser",
|
|
9
|
-
async run(
|
|
10
|
-
|
|
11
|
-
|
|
50
|
+
async run(args, ctx) {
|
|
51
|
+
const port = parsePort(args);
|
|
52
|
+
const app = createBoardApp(ctx);
|
|
53
|
+
const server = serve({
|
|
54
|
+
fetch: app.fetch,
|
|
55
|
+
port,
|
|
56
|
+
hostname: "127.0.0.1",
|
|
57
|
+
}, (info) => {
|
|
58
|
+
const url = `http://127.0.0.1:${info.port}/`;
|
|
59
|
+
console.log(`Board running at ${url}`);
|
|
60
|
+
console.log("Press Ctrl+C to stop.");
|
|
61
|
+
openBrowser(url);
|
|
62
|
+
});
|
|
63
|
+
const shutdown = () => {
|
|
64
|
+
server.close(() => {
|
|
65
|
+
process.exit(0);
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
process.once("SIGINT", shutdown);
|
|
69
|
+
process.once("SIGTERM", shutdown);
|
|
70
|
+
await new Promise(() => {
|
|
71
|
+
/* keep process alive until signals */
|
|
72
|
+
});
|
|
12
73
|
},
|
|
13
74
|
},
|
|
14
75
|
],
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../source/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../source/index.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { YarloConfig } from "yarlo-types";
|
|
2
|
+
/**
|
|
3
|
+
* Returns ordered status column keys for the Kanban board from `config.fields`.
|
|
4
|
+
*
|
|
5
|
+
* @param config - Loaded yarlo project configuration
|
|
6
|
+
* @returns Option list for the `status` single_select field, or sensible defaults
|
|
7
|
+
*/
|
|
8
|
+
export declare function getStatusColumnOptions(config: YarloConfig): string[];
|
|
9
|
+
//# sourceMappingURL=status-field.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"status-field.d.ts","sourceRoot":"","sources":["../source/status-field.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAU/C;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,WAAW,GAAG,MAAM,EAAE,CAWpE"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const DEFAULT_STATUS_OPTIONS = [
|
|
2
|
+
"backlog",
|
|
3
|
+
"todo",
|
|
4
|
+
"in_progress",
|
|
5
|
+
"done",
|
|
6
|
+
"cancelled",
|
|
7
|
+
];
|
|
8
|
+
/**
|
|
9
|
+
* Returns ordered status column keys for the Kanban board from `config.fields`.
|
|
10
|
+
*
|
|
11
|
+
* @param config - Loaded yarlo project configuration
|
|
12
|
+
* @returns Option list for the `status` single_select field, or sensible defaults
|
|
13
|
+
*/
|
|
14
|
+
export function getStatusColumnOptions(config) {
|
|
15
|
+
const def = config.fields.find((f) => f.name === "status");
|
|
16
|
+
if (def?.type === "single_select" &&
|
|
17
|
+
Array.isArray(def.options) &&
|
|
18
|
+
def.options.length > 0) {
|
|
19
|
+
return [...def.options];
|
|
20
|
+
}
|
|
21
|
+
return [...DEFAULT_STATUS_OPTIONS];
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=status-field.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"status-field.js","sourceRoot":"","sources":["../source/status-field.ts"],"names":[],"mappings":"AAEA,MAAM,sBAAsB,GAAG;IAC7B,SAAS;IACT,MAAM;IACN,aAAa;IACb,MAAM;IACN,WAAW;CACH,CAAC;AAEX;;;;;GAKG;AACH,MAAM,UAAU,sBAAsB,CAAC,MAAmB;IACxD,MAAM,GAAG,GAAG,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,QAAQ,CAAC,CAAC;IAC3D,IACE,GAAG,EAAE,IAAI,KAAK,eAAe;QAC7B,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC;QAC1B,GAAG,CAAC,OAAO,CAAC,MAAM,GAAG,CAAC,EACtB,CAAC;QACD,OAAO,CAAC,GAAG,GAAG,CAAC,OAAO,CAAC,CAAC;IAC1B,CAAC;IAED,OAAO,CAAC,GAAG,sBAAsB,CAAC,CAAC;AACrC,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yarlo-plugin-board",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -12,14 +12,17 @@
|
|
|
12
12
|
}
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
+
"@hono/node-server": "^1.13.0",
|
|
15
16
|
"hono": "^4.7.0",
|
|
16
|
-
"
|
|
17
|
+
"zod": "^3.24.0",
|
|
18
|
+
"yarlo-types": "0.1.3"
|
|
17
19
|
},
|
|
18
20
|
"devDependencies": {
|
|
21
|
+
"@types/node": "^22.0.0",
|
|
19
22
|
"typescript": "^5.7.0"
|
|
20
23
|
},
|
|
21
24
|
"scripts": {
|
|
22
|
-
"build": "tsc",
|
|
25
|
+
"build": "tsc && cp source/board-ui.html dist/board-ui.html",
|
|
23
26
|
"dev": "tsc --watch"
|
|
24
27
|
}
|
|
25
28
|
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type {
|
|
3
|
+
Task,
|
|
4
|
+
UpdateTaskInput,
|
|
5
|
+
YarloConfig,
|
|
6
|
+
YarloPluginContext,
|
|
7
|
+
} from "yarlo-types";
|
|
8
|
+
import { DEFAULT_CONFIG } from "yarlo-types";
|
|
9
|
+
import { createBoardApp } from "./board-server.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Minimal task fixture for API serialization tests.
|
|
13
|
+
*/
|
|
14
|
+
function baseTask(overrides: Partial<Task> = {}): Task {
|
|
15
|
+
return {
|
|
16
|
+
id: "t1",
|
|
17
|
+
title: "Task",
|
|
18
|
+
content: "Body",
|
|
19
|
+
fields: { status: "backlog", priority: "low" },
|
|
20
|
+
created_at: "2024-01-01T00:00:00.000Z",
|
|
21
|
+
updated_at: "2024-01-02T00:00:00.000Z",
|
|
22
|
+
file_path: "/tmp/t1.md",
|
|
23
|
+
...overrides,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Builds a plugin context with default mocked task operations.
|
|
29
|
+
*/
|
|
30
|
+
function mockContext(
|
|
31
|
+
overrides: Partial<YarloPluginContext> = {},
|
|
32
|
+
): YarloPluginContext {
|
|
33
|
+
const config: YarloConfig = {
|
|
34
|
+
...DEFAULT_CONFIG,
|
|
35
|
+
yarlo_version: "0.1.0",
|
|
36
|
+
};
|
|
37
|
+
const task = baseTask();
|
|
38
|
+
const ctx: YarloPluginContext = {
|
|
39
|
+
config,
|
|
40
|
+
configDir: "/c",
|
|
41
|
+
projectDir: "/p",
|
|
42
|
+
tasks: {
|
|
43
|
+
list: vi.fn(async () => [task]),
|
|
44
|
+
get: vi.fn(async (id: string) => {
|
|
45
|
+
if (id === "t1") return task;
|
|
46
|
+
throw new Error(`Task "${id}" not found`);
|
|
47
|
+
}),
|
|
48
|
+
create: vi.fn(),
|
|
49
|
+
update: vi.fn(async (_id: string, input: UpdateTaskInput) => ({
|
|
50
|
+
...task,
|
|
51
|
+
title: input.title ?? task.title,
|
|
52
|
+
content: input.content ?? task.content,
|
|
53
|
+
fields: { ...task.fields, ...(input.fields ?? {}) },
|
|
54
|
+
updated_at: "2024-01-03T00:00:00.000Z",
|
|
55
|
+
})),
|
|
56
|
+
delete: vi.fn(),
|
|
57
|
+
},
|
|
58
|
+
...overrides,
|
|
59
|
+
};
|
|
60
|
+
return ctx;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe("createBoardApp", () => {
|
|
64
|
+
it("GET /api/board returns columns, tasks, and fieldDefinitions", async () => {
|
|
65
|
+
const ctx = mockContext();
|
|
66
|
+
const app = createBoardApp(ctx);
|
|
67
|
+
|
|
68
|
+
const res = await app.request("http://localhost/api/board");
|
|
69
|
+
expect(res.status).toBe(200);
|
|
70
|
+
const json = (await res.json()) as {
|
|
71
|
+
columns: string[];
|
|
72
|
+
tasks: Array<{ id: string; title: string }>;
|
|
73
|
+
fieldDefinitions: unknown[];
|
|
74
|
+
};
|
|
75
|
+
expect(json.columns).toContain("backlog");
|
|
76
|
+
expect(json.tasks).toHaveLength(1);
|
|
77
|
+
expect(json.tasks[0]!.id).toBe("t1");
|
|
78
|
+
expect(json.fieldDefinitions).toEqual(ctx.config.fields);
|
|
79
|
+
expect(ctx.tasks.list).toHaveBeenCalledOnce();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("GET /api/tasks/:id returns 200 when task exists", async () => {
|
|
83
|
+
const ctx = mockContext();
|
|
84
|
+
const app = createBoardApp(ctx);
|
|
85
|
+
|
|
86
|
+
const res = await app.request("http://localhost/api/tasks/t1");
|
|
87
|
+
expect(res.status).toBe(200);
|
|
88
|
+
const json = (await res.json()) as { task: { id: string; title: string } };
|
|
89
|
+
expect(json.task.id).toBe("t1");
|
|
90
|
+
expect(json.task.title).toBe("Task");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("GET /api/tasks/:id returns 404 when task is missing", async () => {
|
|
94
|
+
const ctx = mockContext();
|
|
95
|
+
const app = createBoardApp(ctx);
|
|
96
|
+
|
|
97
|
+
const res = await app.request("http://localhost/api/tasks/missing");
|
|
98
|
+
expect(res.status).toBe(404);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("PATCH /api/tasks/:id returns 400 for invalid JSON", async () => {
|
|
102
|
+
const ctx = mockContext();
|
|
103
|
+
const app = createBoardApp(ctx);
|
|
104
|
+
|
|
105
|
+
const res = await app.request("http://localhost/api/tasks/t1", {
|
|
106
|
+
method: "PATCH",
|
|
107
|
+
headers: { "Content-Type": "application/json" },
|
|
108
|
+
body: "not-json{",
|
|
109
|
+
});
|
|
110
|
+
expect(res.status).toBe(400);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("PATCH /api/tasks/:id returns 400 when body has no updatable fields", async () => {
|
|
114
|
+
const ctx = mockContext();
|
|
115
|
+
const app = createBoardApp(ctx);
|
|
116
|
+
|
|
117
|
+
const res = await app.request("http://localhost/api/tasks/t1", {
|
|
118
|
+
method: "PATCH",
|
|
119
|
+
headers: { "Content-Type": "application/json" },
|
|
120
|
+
body: JSON.stringify({}),
|
|
121
|
+
});
|
|
122
|
+
expect(res.status).toBe(400);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("PATCH /api/tasks/:id returns 400 for invalid status value", async () => {
|
|
126
|
+
const ctx = mockContext();
|
|
127
|
+
const app = createBoardApp(ctx);
|
|
128
|
+
|
|
129
|
+
const res = await app.request("http://localhost/api/tasks/t1", {
|
|
130
|
+
method: "PATCH",
|
|
131
|
+
headers: { "Content-Type": "application/json" },
|
|
132
|
+
body: JSON.stringify({ status: "__not_a_column__" }),
|
|
133
|
+
});
|
|
134
|
+
expect(res.status).toBe(400);
|
|
135
|
+
const json = (await res.json()) as { error: string };
|
|
136
|
+
expect(json.error).toMatch(/Invalid status/i);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("PATCH /api/tasks/:id returns updated task when title is sent", async () => {
|
|
140
|
+
const ctx = mockContext();
|
|
141
|
+
const app = createBoardApp(ctx);
|
|
142
|
+
|
|
143
|
+
const res = await app.request("http://localhost/api/tasks/t1", {
|
|
144
|
+
method: "PATCH",
|
|
145
|
+
headers: { "Content-Type": "application/json" },
|
|
146
|
+
body: JSON.stringify({ title: "Renamed" }),
|
|
147
|
+
});
|
|
148
|
+
expect(res.status).toBe(200);
|
|
149
|
+
const json = (await res.json()) as { task: { title: string } };
|
|
150
|
+
expect(json.task.title).toBe("Renamed");
|
|
151
|
+
expect(ctx.tasks.update).toHaveBeenCalledWith("t1", { title: "Renamed" });
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("GET / serves board-ui.html", async () => {
|
|
155
|
+
const ctx = mockContext();
|
|
156
|
+
const app = createBoardApp(ctx);
|
|
157
|
+
|
|
158
|
+
const res = await app.request("http://localhost/");
|
|
159
|
+
expect(res.status).toBe(200);
|
|
160
|
+
const text = await res.text();
|
|
161
|
+
expect(text.length).toBeGreaterThan(100);
|
|
162
|
+
expect(text).toMatch(/html/i);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { Hono } from "hono";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import type { FieldDefinition, Task, UpdateTaskInput, YarloPluginContext } from "yarlo-types";
|
|
7
|
+
import { getStatusColumnOptions } from "./status-field.js";
|
|
8
|
+
|
|
9
|
+
const patchBodySchema = z
|
|
10
|
+
.object({
|
|
11
|
+
status: z.string().min(1).optional(),
|
|
12
|
+
title: z.string().optional(),
|
|
13
|
+
content: z.string().optional(),
|
|
14
|
+
fields: z.record(z.unknown()).optional(),
|
|
15
|
+
})
|
|
16
|
+
.refine(
|
|
17
|
+
(d) =>
|
|
18
|
+
d.status !== undefined ||
|
|
19
|
+
d.title !== undefined ||
|
|
20
|
+
d.content !== undefined ||
|
|
21
|
+
d.fields !== undefined,
|
|
22
|
+
{ message: "At least one of status, title, content, or fields is required" },
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
export type BoardTaskJson = {
|
|
26
|
+
id: string;
|
|
27
|
+
title: string;
|
|
28
|
+
status: unknown;
|
|
29
|
+
priority: unknown;
|
|
30
|
+
labels: unknown;
|
|
31
|
+
assignee: unknown;
|
|
32
|
+
due_date: unknown;
|
|
33
|
+
estimate: unknown;
|
|
34
|
+
updated_at: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type BoardTaskDetailJson = {
|
|
38
|
+
id: string;
|
|
39
|
+
title: string;
|
|
40
|
+
content: string;
|
|
41
|
+
fields: Record<string, unknown>;
|
|
42
|
+
created_at: string;
|
|
43
|
+
updated_at: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Maps a task to a JSON-safe shape for the board UI (no file paths).
|
|
48
|
+
*
|
|
49
|
+
* @param task - Task from yarlo storage
|
|
50
|
+
* @returns Serializable board card payload
|
|
51
|
+
*/
|
|
52
|
+
function serializeBoardTask(task: Task): BoardTaskJson {
|
|
53
|
+
const f = task.fields;
|
|
54
|
+
return {
|
|
55
|
+
id: task.id,
|
|
56
|
+
title: task.title,
|
|
57
|
+
status: f.status ?? null,
|
|
58
|
+
priority: f.priority ?? null,
|
|
59
|
+
labels: f.labels ?? null,
|
|
60
|
+
assignee: f.assignee ?? null,
|
|
61
|
+
due_date: f.due_date ?? null,
|
|
62
|
+
estimate: f.estimate ?? null,
|
|
63
|
+
updated_at: task.updated_at,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Full task payload for detail view and PATCH responses (no file_path).
|
|
69
|
+
*
|
|
70
|
+
* @param task - Task from yarlo storage
|
|
71
|
+
* @returns Serializable task detail
|
|
72
|
+
*/
|
|
73
|
+
function serializeBoardTaskDetail(task: Task): BoardTaskDetailJson {
|
|
74
|
+
return {
|
|
75
|
+
id: task.id,
|
|
76
|
+
title: task.title,
|
|
77
|
+
content: task.content,
|
|
78
|
+
fields: { ...task.fields },
|
|
79
|
+
created_at: task.created_at,
|
|
80
|
+
updated_at: task.updated_at,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isNotFoundMessage(msg: string): boolean {
|
|
85
|
+
return (
|
|
86
|
+
msg.includes("not found") ||
|
|
87
|
+
msg.includes("Ambiguous ID") ||
|
|
88
|
+
msg.includes("Ambiguous")
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Builds the Hono app for the local board (HTML UI + JSON API).
|
|
94
|
+
*
|
|
95
|
+
* @param ctx - Plugin context with task operations and config
|
|
96
|
+
* @returns Configured Hono instance
|
|
97
|
+
*/
|
|
98
|
+
export function createBoardApp(ctx: YarloPluginContext): Hono {
|
|
99
|
+
const app = new Hono();
|
|
100
|
+
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
101
|
+
|
|
102
|
+
app.get("/", async (c) => {
|
|
103
|
+
const htmlPath = path.join(moduleDir, "board-ui.html");
|
|
104
|
+
const html = await fs.readFile(htmlPath, "utf-8");
|
|
105
|
+
return c.html(html);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
app.get("/api/board", async (c) => {
|
|
109
|
+
const columns = getStatusColumnOptions(ctx.config);
|
|
110
|
+
const tasks = await ctx.tasks.list();
|
|
111
|
+
const fieldDefinitions: FieldDefinition[] = ctx.config.fields;
|
|
112
|
+
return c.json({
|
|
113
|
+
columns,
|
|
114
|
+
tasks: tasks.map(serializeBoardTask),
|
|
115
|
+
fieldDefinitions,
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
app.get("/api/tasks/:id", async (c) => {
|
|
120
|
+
const id = c.req.param("id");
|
|
121
|
+
try {
|
|
122
|
+
const task = await ctx.tasks.get(id);
|
|
123
|
+
return c.json({ task: serializeBoardTaskDetail(task) });
|
|
124
|
+
} catch (e) {
|
|
125
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
126
|
+
if (isNotFoundMessage(msg)) {
|
|
127
|
+
return c.json({ error: msg }, 404);
|
|
128
|
+
}
|
|
129
|
+
return c.json({ error: msg }, 500);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
app.patch("/api/tasks/:id", async (c) => {
|
|
134
|
+
const id = c.req.param("id");
|
|
135
|
+
let body: unknown;
|
|
136
|
+
try {
|
|
137
|
+
body = await c.req.json();
|
|
138
|
+
} catch {
|
|
139
|
+
return c.json({ error: "Invalid JSON body" }, 400);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const parsed = patchBodySchema.safeParse(body);
|
|
143
|
+
if (!parsed.success) {
|
|
144
|
+
const flat = parsed.error.flatten();
|
|
145
|
+
const refineMsg = parsed.error.errors.find((e) => e.code === "custom");
|
|
146
|
+
return c.json(
|
|
147
|
+
{
|
|
148
|
+
error: refineMsg?.message ?? "Validation failed",
|
|
149
|
+
details: flat,
|
|
150
|
+
},
|
|
151
|
+
400,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const columns = getStatusColumnOptions(ctx.config);
|
|
156
|
+
const fieldPatch: Record<string, unknown> = {};
|
|
157
|
+
if (parsed.data.fields) {
|
|
158
|
+
Object.assign(fieldPatch, parsed.data.fields);
|
|
159
|
+
}
|
|
160
|
+
if (parsed.data.status !== undefined) {
|
|
161
|
+
fieldPatch.status = parsed.data.status;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const statusValue =
|
|
165
|
+
typeof fieldPatch.status === "string" ? fieldPatch.status : undefined;
|
|
166
|
+
if (statusValue !== undefined && !columns.includes(statusValue)) {
|
|
167
|
+
return c.json(
|
|
168
|
+
{
|
|
169
|
+
error: `Invalid status: must be one of: ${columns.join(", ")}`,
|
|
170
|
+
},
|
|
171
|
+
400,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const updateInput: UpdateTaskInput = {};
|
|
176
|
+
if (parsed.data.title !== undefined) {
|
|
177
|
+
updateInput.title = parsed.data.title;
|
|
178
|
+
}
|
|
179
|
+
if (parsed.data.content !== undefined) {
|
|
180
|
+
updateInput.content = parsed.data.content;
|
|
181
|
+
}
|
|
182
|
+
if (Object.keys(fieldPatch).length > 0) {
|
|
183
|
+
updateInput.fields = fieldPatch;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
const task = await ctx.tasks.update(id, updateInput);
|
|
188
|
+
return c.json({ task: serializeBoardTaskDetail(task) });
|
|
189
|
+
} catch (e) {
|
|
190
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
191
|
+
if (isNotFoundMessage(msg)) {
|
|
192
|
+
return c.json({ error: msg }, 404);
|
|
193
|
+
}
|
|
194
|
+
return c.json({ error: msg }, 500);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
return app;
|
|
199
|
+
}
|