md-task-viewer 0.1.0

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.
@@ -0,0 +1 @@
1
+ :root{color-scheme:dark;--bg: #0a0e14;--bg-strong: #0d1117;--panel: #131920;--panel-border: #21262d;--text: #e6edf3;--muted: #7d8590;--accent: #3fb950;--accent-strong: #2ea043;--danger: #f85149;--shadow: 0 2px 8px rgba(0, 0, 0, .4);font-family:JetBrains Mono,IBM Plex Mono,Fira Code,SF Mono,Cascadia Code,monospace;line-height:1.6;color:var(--text);background:var(--bg)}*{box-sizing:border-box}body{margin:0;height:100vh;overflow:hidden}#root{height:100vh;overflow:hidden}button,input,select,textarea{font:inherit}button{cursor:pointer}.app-shell{display:flex;flex-direction:column;height:100vh;padding:12px 16px 16px;gap:12px;overflow:hidden}.app-header{display:flex;justify-content:space-between;align-items:center;gap:16px;flex-shrink:0;padding-bottom:12px;border-bottom:1px solid var(--panel-border)}.app-header-left{display:flex;align-items:baseline;gap:12px;min-width:0}.app-header h1{margin:0;font-family:inherit;font-size:1.1rem;font-weight:700;line-height:1.2;letter-spacing:-.02em;white-space:nowrap;color:var(--accent)}.app-header h1:before{content:"> ";color:var(--muted)}.eyebrow{margin:0;color:var(--muted);text-transform:uppercase;letter-spacing:.12em;font-size:.65rem;font-weight:600;white-space:nowrap}.layout-grid{display:grid;grid-template-columns:minmax(260px,320px) minmax(0,1fr);gap:1px;flex:1;min-height:0;background:var(--panel-border)}.panel{display:flex;flex-direction:column;background:var(--panel);border:none;border-radius:0;padding:14px 16px;min-height:0;overflow:hidden}.panel-header{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:12px;padding-bottom:8px;border-bottom:1px solid var(--panel-border);flex-shrink:0}.panel-header h2{margin:0;font-family:inherit;font-size:.85rem;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted)}.panel-header span{font-size:.75rem;color:var(--muted)}.panel-header-right{display:flex;align-items:center;gap:12px}.filter-toggle{display:flex;align-items:center;gap:4px;cursor:pointer;-webkit-user-select:none;user-select:none}.filter-toggle input[type=checkbox]{accent-color:var(--accent);cursor:pointer}.sidebar-scroll{flex:1;overflow-y:auto;min-height:0;margin:0 -4px;padding:0 4px}.sidebar-scroll::-webkit-scrollbar{width:4px}.sidebar-scroll::-webkit-scrollbar-track{background:transparent}.sidebar-scroll::-webkit-scrollbar-thumb{background:var(--panel-border);border-radius:0}.task-list{display:grid;gap:2px}.empty-list{margin:0;padding:14px;border-radius:0;background:#ffffff08;border:1px dashed var(--panel-border);color:var(--muted);font-size:.82rem}.task-row{display:grid;gap:3px;width:100%;text-align:left;padding:8px 10px;border-radius:0;border:1px solid transparent;border-left:2px solid transparent;background:#ffffff05;transition:background .1s ease,border-color .1s ease}.task-row:hover{background:#ffffff0d;border-left-color:var(--muted)}.task-row-selected{background:#3fb9500f;border-color:#3fb95026;border-left-color:var(--accent)}.task-row-badges{display:flex;gap:6px;align-items:center}.task-row strong{font-size:.82rem;font-weight:600;line-height:1.3}.task-row small{color:var(--muted);font-size:.72rem}.badge{display:inline-flex;justify-content:center;width:fit-content;padding:1px 6px;border-radius:0;font-size:.6rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase;border:1px solid}.badge-must{background:#f851491a;color:#f85149;border-color:#f851494d}.badge-want{background:#3fb9501a;color:#3fb950;border-color:#3fb9504d}.badge-todo{background:#58a6ff1a;color:#58a6ff;border-color:#58a6ff4d}.badge-wip{background:#d299221a;color:#d29922;border-color:#d299224d}.badge-done{background:#3fb95014;color:#2ea043;border-color:#2ea0434d}.editor-panel{overflow:hidden}.editor-scroll{flex:1;overflow-y:auto;min-height:0;margin:0 -4px;padding:0 4px}.editor-scroll::-webkit-scrollbar{width:4px}.editor-scroll::-webkit-scrollbar-track{background:transparent}.editor-scroll::-webkit-scrollbar-thumb{background:var(--panel-border);border-radius:0}.task-form{display:flex;flex-direction:column;gap:12px;height:100%}.task-form label{display:grid;gap:4px}.task-form span{font-size:.72rem;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.08em}.task-form input,.task-form select,.task-form textarea{width:100%;padding:7px 10px;border:1px solid var(--panel-border);border-radius:0;background:var(--bg);color:var(--text);font-size:.88rem;transition:border-color .1s ease}.task-form input:focus,.task-form select:focus,.task-form textarea:focus{outline:none;border-color:var(--accent)}.task-form textarea{resize:none;flex:1;min-height:120px}.editor-label{flex:1;display:flex!important;flex-direction:column;min-height:0}.field-row{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.field-row-top{padding-bottom:12px;border-bottom:1px solid var(--panel-border)}.meta-strip{display:flex;gap:16px;color:var(--muted);font-size:.72rem}.form-actions{display:flex;gap:8px;flex-shrink:0;padding-top:4px}.primary-button,.danger-button,.ghost-button{border:1px solid;border-radius:0;padding:6px 14px;font-size:.8rem;font-weight:700;letter-spacing:.03em;text-transform:uppercase;transition:background .1s ease,color .1s ease}.primary-button{color:var(--bg);background:var(--accent);border-color:var(--accent)}.primary-button:hover{background:var(--accent-strong);border-color:var(--accent-strong)}.danger-button{color:var(--danger);background:#f851491a;border-color:#f8514966}.danger-button:hover{background:#f8514933}.ghost-button{color:var(--muted);background:transparent;border-color:var(--panel-border)}.ghost-button:hover{color:var(--text);background:#ffffff0d;border-color:var(--muted)}.primary-button:active,.danger-button:active,.ghost-button:active{transform:scale(.98)}.primary-button:disabled,.danger-button:disabled{opacity:.4;cursor:not-allowed}.dirty-state{color:var(--accent);font-weight:700;font-size:.72rem;white-space:nowrap}.notice{margin:0;padding-top:6px;color:var(--accent);font-size:.8rem;flex-shrink:0}.empty-editor{display:grid;place-items:center;flex:1;color:var(--muted);font-size:.85rem}.empty-editor p{margin:0}.error-panel{margin-top:12px;padding:10px 12px;border-radius:0;background:#f8514914;border:1px solid rgba(248,81,73,.2);flex-shrink:0}.error-panel h3{margin:0;font-family:inherit;font-size:.82rem;color:var(--danger)}.error-panel p{margin:8px 0 0;display:grid;font-size:.78rem}.app-header-actions{display:flex;gap:8px;align-items:center}.settings-button{display:inline-flex;align-items:center;justify-content:center;padding:6px;color:var(--muted);border:1px solid transparent;background:transparent;transition:color .1s ease,border-color .1s ease}.settings-button:hover{color:var(--text);border-color:var(--panel-border)}.settings-overlay{position:fixed;top:0;right:0;bottom:0;left:0;z-index:100;display:grid;place-items:center;background:#0009}.settings-modal{background:var(--panel);border:1px solid var(--panel-border);border-radius:0;box-shadow:var(--shadow);padding:20px;width:90%;max-width:480px;display:flex;flex-direction:column;gap:16px}.settings-body{display:flex;flex-direction:column;gap:10px}.settings-label{font-size:.75rem;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.08em}.settings-hint{display:block;font-size:.72rem;color:var(--muted);margin-top:2px}.settings-dir-list{display:flex;flex-direction:column;gap:6px}.settings-dir-row{display:flex;gap:6px;align-items:center}.settings-dir-row input{flex:1;padding:7px 10px;border:1px solid var(--panel-border);border-radius:0;background:var(--bg);color:var(--text);font-size:.88rem;transition:border-color .1s ease}.settings-dir-row input:focus{outline:none;border-color:var(--accent)}.settings-remove-button{padding:6px!important;color:var(--muted);flex-shrink:0}.settings-remove-button:disabled{opacity:.3;cursor:not-allowed}@media(max-width:900px){.app-shell{padding:10px;height:auto;min-height:100vh;overflow:auto}body,#root{height:auto;overflow:auto}.layout-grid{grid-template-columns:1fr;flex:none}.panel{min-height:auto;max-height:none;overflow:visible}.sidebar-scroll,.editor-scroll{overflow:visible;max-height:none}.app-header{flex-direction:column;align-items:stretch;gap:10px}.app-header-left{flex-direction:column;gap:4px}.field-row{grid-template-columns:1fr}.task-form textarea{min-height:200px;flex:none}}
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Markdown Task Viewer</title>
7
+ <script type="module" crossorigin src="/assets/index-CJzl_cjb.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-D8W_VwBM.css">
9
+ </head>
10
+ <body>
11
+ <div id="root"></div>
12
+ </body>
13
+ </html>
package/dist/server.js ADDED
@@ -0,0 +1,579 @@
1
+ // src/server.ts
2
+ import Fastify from "fastify";
3
+ import fastifyStatic from "@fastify/static";
4
+ import chokidar from "chokidar";
5
+ import path2 from "path";
6
+ import { fileURLToPath } from "url";
7
+
8
+ // src/taskStore.ts
9
+ import matter from "gray-matter";
10
+ import picomatch from "picomatch";
11
+ import path from "path";
12
+ import { promises as fs } from "fs";
13
+
14
+ // src/types.ts
15
+ var CONFIG_FILE_NAME = ".md-task-viewer.json";
16
+
17
+ // src/taskStore.ts
18
+ var MARKDOWN_EXTENSIONS = /* @__PURE__ */ new Set([".md", ".markdown"]);
19
+ var REQUIRED_PRIORITY = ["MUST", "WANT"];
20
+ var REQUIRED_STATUS = ["TODO", "WIP", "DONE"];
21
+ var ConflictError = class extends Error {
22
+ };
23
+ var ValidationError = class extends Error {
24
+ };
25
+ function toPosixPath(filePath) {
26
+ return filePath.split(path.sep).join("/");
27
+ }
28
+ function normalizeRelativePath(candidate) {
29
+ const normalized = toPosixPath(path.posix.normalize(candidate.trim()));
30
+ if (!normalized || normalized === "." || normalized.startsWith("../") || normalized.includes("/../")) {
31
+ throw new ValidationError("Path must stay within the workspace root.");
32
+ }
33
+ return normalized.replace(/^\.\/+/, "");
34
+ }
35
+ function ensureMarkdownExtension(filePath) {
36
+ return path.posix.extname(filePath) ? filePath : `${filePath}.md`;
37
+ }
38
+ function asUtcISOString(date) {
39
+ return date.toISOString();
40
+ }
41
+ function slugify(value) {
42
+ const slug = value.toLowerCase().normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
43
+ return slug || "untitled-task";
44
+ }
45
+ function buildDefaults(filePath, stats) {
46
+ const basename = path.basename(filePath, path.extname(filePath));
47
+ const title = basename.replace(/[-_]+/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
48
+ return {
49
+ title,
50
+ priority: "WANT",
51
+ status: "TODO",
52
+ createdAt: asUtcISOString(stats.birthtime),
53
+ updatedAt: asUtcISOString(stats.mtime)
54
+ };
55
+ }
56
+ function splitFrontmatter(data, statsDefaults) {
57
+ const extraFrontmatter = {};
58
+ for (const [key, value] of Object.entries(data)) {
59
+ if (!["title", "priority", "status", "createdAt", "updatedAt"].includes(key)) {
60
+ extraFrontmatter[key] = value;
61
+ }
62
+ }
63
+ const title = typeof data.title === "string" && data.title.trim() ? data.title : statsDefaults.title;
64
+ const priority = REQUIRED_PRIORITY.includes(data.priority) ? data.priority : statsDefaults.priority;
65
+ const status = REQUIRED_STATUS.includes(data.status) ? data.status : statsDefaults.status;
66
+ const createdAt = typeof data.createdAt === "string" && !Number.isNaN(Date.parse(data.createdAt)) ? new Date(data.createdAt).toISOString() : statsDefaults.createdAt;
67
+ const updatedAt = typeof data.updatedAt === "string" && !Number.isNaN(Date.parse(data.updatedAt)) ? new Date(data.updatedAt).toISOString() : statsDefaults.updatedAt;
68
+ const normalized = title !== data.title || priority !== data.priority || status !== data.status || createdAt !== data.createdAt || updatedAt !== data.updatedAt;
69
+ return {
70
+ frontmatter: { title, priority, status, createdAt, updatedAt },
71
+ extraFrontmatter,
72
+ normalized
73
+ };
74
+ }
75
+ function serializeTask(record) {
76
+ const data = {
77
+ ...record.extraFrontmatter,
78
+ title: record.frontmatter.title,
79
+ priority: record.frontmatter.priority,
80
+ status: record.frontmatter.status,
81
+ createdAt: record.frontmatter.createdAt,
82
+ updatedAt: record.frontmatter.updatedAt
83
+ };
84
+ return matter.stringify(record.content, data);
85
+ }
86
+ async function readDirectoryRecursive(rootDir, currentDir, results) {
87
+ const entries = await fs.readdir(currentDir, { withFileTypes: true });
88
+ for (const entry of entries) {
89
+ if (entry.name === ".git" || entry.name === "node_modules") {
90
+ continue;
91
+ }
92
+ const absolutePath = path.join(currentDir, entry.name);
93
+ if (entry.isDirectory()) {
94
+ await readDirectoryRecursive(rootDir, absolutePath, results);
95
+ continue;
96
+ }
97
+ if (entry.name === CONFIG_FILE_NAME) {
98
+ continue;
99
+ }
100
+ if (!MARKDOWN_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
101
+ continue;
102
+ }
103
+ results.push(toPosixPath(path.relative(rootDir, absolutePath)));
104
+ }
105
+ }
106
+ async function listMarkdownFiles(rootDir, taskDirs, ignorePaths) {
107
+ const results = [];
108
+ const seen = /* @__PURE__ */ new Set();
109
+ const isIgnored = ignorePaths.length > 0 ? picomatch(ignorePaths) : null;
110
+ for (const taskDir of taskDirs) {
111
+ const scanDir = path.resolve(rootDir, taskDir);
112
+ try {
113
+ await fs.access(scanDir);
114
+ } catch {
115
+ continue;
116
+ }
117
+ const dirResults = [];
118
+ await readDirectoryRecursive(rootDir, scanDir, dirResults);
119
+ for (const filePath of dirResults) {
120
+ if (!seen.has(filePath)) {
121
+ seen.add(filePath);
122
+ if (isIgnored && isIgnored(filePath)) {
123
+ continue;
124
+ }
125
+ results.push(filePath);
126
+ }
127
+ }
128
+ }
129
+ return results.sort();
130
+ }
131
+ async function parseTask(rootDir, relativePath) {
132
+ const absolutePath = path.join(rootDir, relativePath);
133
+ const raw = await fs.readFile(absolutePath, "utf8");
134
+ const stats = await fs.stat(absolutePath);
135
+ const parsed = matter(raw);
136
+ const defaults = buildDefaults(relativePath, stats);
137
+ const { frontmatter, extraFrontmatter, normalized } = splitFrontmatter(parsed.data, defaults);
138
+ return {
139
+ path: toPosixPath(relativePath),
140
+ content: parsed.content,
141
+ frontmatter,
142
+ extraFrontmatter,
143
+ raw,
144
+ normalized
145
+ };
146
+ }
147
+ async function readConfig(rootDir) {
148
+ const configFilePath = path.join(rootDir, CONFIG_FILE_NAME);
149
+ try {
150
+ const raw = await fs.readFile(configFilePath, "utf8");
151
+ const parsed = JSON.parse(raw);
152
+ const taskDirs = Array.isArray(parsed.taskDirs) ? parsed.taskDirs.filter((item) => typeof item === "string") : ["."];
153
+ const ignorePaths = Array.isArray(parsed.ignorePaths) ? parsed.ignorePaths.filter((item) => typeof item === "string") : [];
154
+ const order = Array.isArray(parsed.order) ? parsed.order.filter((item) => typeof item === "string") : [];
155
+ return { version: parsed.version ?? 1, taskDirs, ignorePaths, order };
156
+ } catch (error) {
157
+ const maybeError = error;
158
+ if (maybeError.code !== "ENOENT") {
159
+ throw error;
160
+ }
161
+ return { version: 1, taskDirs: ["."], ignorePaths: [], order: [] };
162
+ }
163
+ }
164
+ async function reconcileOrder(rootDir, taskPaths) {
165
+ const config = await readConfig(rootDir);
166
+ const order = config.order;
167
+ const known = new Set(taskPaths);
168
+ const nextOrder = order.filter((item) => known.has(item));
169
+ for (const taskPath of taskPaths) {
170
+ if (!nextOrder.includes(taskPath)) {
171
+ nextOrder.push(taskPath);
172
+ }
173
+ }
174
+ const changed = nextOrder.length !== order.length || nextOrder.some((item, index) => item !== order[index]);
175
+ return { order: nextOrder, changed };
176
+ }
177
+ async function saveOrder(rootDir, order) {
178
+ const normalized = Array.from(
179
+ new Set(
180
+ order.map((item) => ensureMarkdownExtension(normalizeRelativePath(item)))
181
+ )
182
+ );
183
+ const existing = await readConfig(rootDir);
184
+ const payload = { version: 1, taskDirs: existing.taskDirs, ignorePaths: existing.ignorePaths, order: normalized };
185
+ await fs.writeFile(path.join(rootDir, CONFIG_FILE_NAME), `${JSON.stringify(payload, null, 2)}
186
+ `, "utf8");
187
+ }
188
+ async function saveConfig(rootDir, taskDirs, ignorePaths) {
189
+ const validated = taskDirs.map((dir) => {
190
+ const normalized = dir.trim().replace(/\\/g, "/").replace(/\/+$/, "") || ".";
191
+ if (normalized.startsWith("../") || normalized.includes("/../")) {
192
+ throw new ValidationError("taskDirs must stay within the workspace root.");
193
+ }
194
+ return normalized;
195
+ });
196
+ if (validated.length === 0) {
197
+ throw new ValidationError("taskDirs must contain at least one directory.");
198
+ }
199
+ const existing = await readConfig(rootDir);
200
+ const validatedIgnorePaths = ignorePaths ?? existing.ignorePaths;
201
+ const payload = { version: 1, taskDirs: validated, ignorePaths: validatedIgnorePaths, order: existing.order };
202
+ await fs.writeFile(path.join(rootDir, CONFIG_FILE_NAME), `${JSON.stringify(payload, null, 2)}
203
+ `, "utf8");
204
+ return payload;
205
+ }
206
+ async function listTasks(rootDir) {
207
+ const config = await readConfig(rootDir);
208
+ const files = await listMarkdownFiles(rootDir, config.taskDirs, config.ignorePaths);
209
+ const errors = [];
210
+ const tasks = await Promise.all(
211
+ files.map(async (relativePath) => {
212
+ try {
213
+ return await parseTask(rootDir, relativePath);
214
+ } catch (error) {
215
+ errors.push({
216
+ path: relativePath,
217
+ message: error instanceof Error ? error.message : "Unknown parse error"
218
+ });
219
+ return null;
220
+ }
221
+ })
222
+ );
223
+ const taskRecords = tasks.filter((task) => task !== null);
224
+ const { order, changed } = await reconcileOrder(
225
+ rootDir,
226
+ taskRecords.map((task) => task.path)
227
+ );
228
+ if (changed) {
229
+ await saveOrder(rootDir, order);
230
+ }
231
+ const orderIndex = new Map(order.map((item, index) => [item, index]));
232
+ taskRecords.sort((left, right) => {
233
+ const leftIndex = orderIndex.get(left.path) ?? Number.MAX_SAFE_INTEGER;
234
+ const rightIndex = orderIndex.get(right.path) ?? Number.MAX_SAFE_INTEGER;
235
+ return leftIndex - rightIndex || left.path.localeCompare(right.path);
236
+ });
237
+ return { tasks: taskRecords, errors };
238
+ }
239
+ async function ensureDirectoryForFile(rootDir, relativeFilePath) {
240
+ const normalized = ensureMarkdownExtension(normalizeRelativePath(relativeFilePath));
241
+ const absolutePath = path.join(rootDir, normalized);
242
+ const directory = path.dirname(absolutePath);
243
+ await fs.mkdir(directory, { recursive: true });
244
+ return normalized;
245
+ }
246
+ async function nextAvailablePath(rootDir, directory, title) {
247
+ const safeDirectory = directory ? normalizeRelativePath(directory) : "";
248
+ const slug = slugify(title);
249
+ const base = safeDirectory ? `${safeDirectory}/${slug}` : slug;
250
+ let attempt = 0;
251
+ while (true) {
252
+ const candidate = ensureMarkdownExtension(attempt === 0 ? base : `${base}-${attempt + 1}`);
253
+ try {
254
+ await fs.access(path.join(rootDir, candidate));
255
+ attempt += 1;
256
+ } catch {
257
+ return candidate;
258
+ }
259
+ }
260
+ }
261
+ async function createTask(rootDir, input) {
262
+ if (!input.title.trim()) {
263
+ throw new ValidationError("Title is required.");
264
+ }
265
+ const now = asUtcISOString(/* @__PURE__ */ new Date());
266
+ const relativePath = input.path?.trim() ? await ensureDirectoryForFile(rootDir, input.path) : await nextAvailablePath(rootDir, input.directory ?? "", input.title);
267
+ const absolutePath = path.join(rootDir, relativePath);
268
+ try {
269
+ await fs.access(absolutePath);
270
+ } catch (error) {
271
+ const maybeError = error;
272
+ if (maybeError.code === "ENOENT") {
273
+ } else if (maybeError.code) {
274
+ throw error;
275
+ } else {
276
+ throw new ValidationError("A task already exists at that path.");
277
+ }
278
+ }
279
+ const record = {
280
+ path: relativePath,
281
+ content: input.content ?? "",
282
+ raw: "",
283
+ normalized: false,
284
+ extraFrontmatter: input.extraFrontmatter ?? {},
285
+ frontmatter: {
286
+ title: input.title.trim(),
287
+ priority: input.priority ?? "MUST",
288
+ status: input.status ?? "TODO",
289
+ createdAt: now,
290
+ updatedAt: now
291
+ }
292
+ };
293
+ await fs.mkdir(path.dirname(absolutePath), { recursive: true });
294
+ await fs.writeFile(absolutePath, serializeTask(record), "utf8");
295
+ const current = await listTasks(rootDir);
296
+ await saveOrder(rootDir, current.tasks.map((task) => task.path).concat(relativePath));
297
+ return parseTask(rootDir, relativePath);
298
+ }
299
+ async function updateTask(rootDir, currentPath, input) {
300
+ const normalizedCurrentPath = ensureMarkdownExtension(normalizeRelativePath(currentPath));
301
+ const absoluteCurrentPath = path.join(rootDir, normalizedCurrentPath);
302
+ let existing;
303
+ try {
304
+ existing = await parseTask(rootDir, normalizedCurrentPath);
305
+ } catch (error) {
306
+ const maybeError = error;
307
+ if (maybeError.code === "ENOENT") {
308
+ throw new ConflictError("The task no longer exists.");
309
+ }
310
+ throw error;
311
+ }
312
+ if (input.baseUpdatedAt && existing.frontmatter.updatedAt !== input.baseUpdatedAt) {
313
+ throw new ConflictError("The task changed on disk. Reload before saving.");
314
+ }
315
+ const nextPath = input.path?.trim() ? await ensureDirectoryForFile(rootDir, input.path) : normalizedCurrentPath;
316
+ const absoluteNextPath = path.join(rootDir, nextPath);
317
+ if (nextPath !== normalizedCurrentPath) {
318
+ try {
319
+ await fs.access(absoluteNextPath);
320
+ } catch (error) {
321
+ const maybeError = error;
322
+ if (maybeError.code === "ENOENT") {
323
+ } else if (maybeError.code) {
324
+ throw error;
325
+ } else {
326
+ throw new ValidationError("A task already exists at the target path.");
327
+ }
328
+ }
329
+ }
330
+ const record = {
331
+ path: nextPath,
332
+ raw: existing.raw,
333
+ normalized: false,
334
+ content: input.content,
335
+ extraFrontmatter: input.extraFrontmatter ?? existing.extraFrontmatter,
336
+ frontmatter: {
337
+ title: input.title.trim(),
338
+ priority: input.priority,
339
+ status: input.status,
340
+ createdAt: existing.frontmatter.createdAt,
341
+ updatedAt: asUtcISOString(/* @__PURE__ */ new Date())
342
+ }
343
+ };
344
+ await fs.writeFile(absoluteCurrentPath, serializeTask(record), "utf8");
345
+ if (nextPath !== normalizedCurrentPath) {
346
+ await fs.mkdir(path.dirname(absoluteNextPath), { recursive: true });
347
+ await fs.rename(absoluteCurrentPath, absoluteNextPath);
348
+ }
349
+ const current = await listTasks(rootDir);
350
+ const updatedOrder = current.tasks.map((task) => task.path);
351
+ await saveOrder(rootDir, updatedOrder);
352
+ return parseTask(rootDir, nextPath);
353
+ }
354
+ async function deleteTask(rootDir, relativePath) {
355
+ const normalizedPath = ensureMarkdownExtension(normalizeRelativePath(relativePath));
356
+ const absolutePath = path.join(rootDir, normalizedPath);
357
+ try {
358
+ await fs.unlink(absolutePath);
359
+ } catch (error) {
360
+ const maybeError = error;
361
+ if (maybeError.code === "ENOENT") {
362
+ throw new ConflictError("The task no longer exists.");
363
+ }
364
+ throw error;
365
+ }
366
+ const current = await listTasks(rootDir);
367
+ await saveOrder(
368
+ rootDir,
369
+ current.tasks.map((task) => task.path)
370
+ );
371
+ }
372
+ async function patchTaskFields(rootDir, currentPath, input) {
373
+ const normalizedCurrentPath = ensureMarkdownExtension(normalizeRelativePath(currentPath));
374
+ const absoluteCurrentPath = path.join(rootDir, normalizedCurrentPath);
375
+ let existing;
376
+ try {
377
+ existing = await parseTask(rootDir, normalizedCurrentPath);
378
+ } catch (error) {
379
+ const maybeError = error;
380
+ if (maybeError.code === "ENOENT") {
381
+ throw new ConflictError("The task no longer exists.");
382
+ }
383
+ throw error;
384
+ }
385
+ const priority = input.priority && REQUIRED_PRIORITY.includes(input.priority) ? input.priority : existing.frontmatter.priority;
386
+ const status = input.status && REQUIRED_STATUS.includes(input.status) ? input.status : existing.frontmatter.status;
387
+ if (priority === existing.frontmatter.priority && status === existing.frontmatter.status) {
388
+ return existing;
389
+ }
390
+ const record = {
391
+ path: normalizedCurrentPath,
392
+ raw: existing.raw,
393
+ normalized: false,
394
+ content: existing.content,
395
+ extraFrontmatter: existing.extraFrontmatter,
396
+ frontmatter: {
397
+ ...existing.frontmatter,
398
+ priority,
399
+ status,
400
+ updatedAt: asUtcISOString(/* @__PURE__ */ new Date())
401
+ }
402
+ };
403
+ await fs.writeFile(absoluteCurrentPath, serializeTask(record), "utf8");
404
+ return parseTask(rootDir, normalizedCurrentPath);
405
+ }
406
+ function parseOrderPayload(input) {
407
+ if (!Array.isArray(input)) {
408
+ throw new ValidationError("Order payload must be an array.");
409
+ }
410
+ return input.map((item) => ensureMarkdownExtension(normalizeRelativePath(String(item))));
411
+ }
412
+
413
+ // src/server.ts
414
+ var __filename = fileURLToPath(import.meta.url);
415
+ var __dirname = path2.dirname(__filename);
416
+ function resolveClientDir(explicitClientDir) {
417
+ if (explicitClientDir === null) {
418
+ return null;
419
+ }
420
+ if (explicitClientDir) {
421
+ return explicitClientDir;
422
+ }
423
+ return path2.resolve(__dirname, "client");
424
+ }
425
+ function sendJsonError(reply, error) {
426
+ if (error instanceof ValidationError) {
427
+ reply.code(400).send({ error: error.message });
428
+ return;
429
+ }
430
+ if (error instanceof ConflictError) {
431
+ reply.code(409).send({ error: error.message });
432
+ return;
433
+ }
434
+ reply.code(500).send({ error: error instanceof Error ? error.message : "Internal server error" });
435
+ }
436
+ async function createServer(options) {
437
+ const app = Fastify({ logger: false });
438
+ const listeners = /* @__PURE__ */ new Set();
439
+ const clientDir = resolveClientDir(options.clientDir);
440
+ app.addHook("onClose", async () => {
441
+ for (const listener of listeners) {
442
+ listener.close();
443
+ }
444
+ });
445
+ app.get("/api/tasks", async () => listTasks(options.rootDir));
446
+ app.post("/api/tasks", async (request, reply) => {
447
+ try {
448
+ const task = await createTask(options.rootDir, request.body ?? {});
449
+ return reply.code(201).send(task);
450
+ } catch (error) {
451
+ sendJsonError(reply, error);
452
+ }
453
+ });
454
+ app.patch("/api/tasks/*", async (request, reply) => {
455
+ const currentPath = decodeURIComponent(request.params["*"] ?? "");
456
+ try {
457
+ const task = await updateTask(options.rootDir, currentPath, request.body ?? {});
458
+ return reply.send(task);
459
+ } catch (error) {
460
+ sendJsonError(reply, error);
461
+ }
462
+ });
463
+ app.delete("/api/tasks/*", async (request, reply) => {
464
+ const currentPath = decodeURIComponent(request.params["*"] ?? "");
465
+ try {
466
+ await deleteTask(options.rootDir, currentPath);
467
+ return reply.code(204).send();
468
+ } catch (error) {
469
+ sendJsonError(reply, error);
470
+ }
471
+ });
472
+ app.patch("/api/task-fields/*", async (request, reply) => {
473
+ const currentPath = decodeURIComponent(request.params["*"] ?? "");
474
+ try {
475
+ const task = await patchTaskFields(options.rootDir, currentPath, request.body ?? {});
476
+ return reply.send(task);
477
+ } catch (error) {
478
+ sendJsonError(reply, error);
479
+ }
480
+ });
481
+ app.put("/api/order", async (request, reply) => {
482
+ try {
483
+ const order = parseOrderPayload(request.body?.order ?? []);
484
+ await saveOrder(options.rootDir, order);
485
+ return reply.code(204).send();
486
+ } catch (error) {
487
+ sendJsonError(reply, error);
488
+ }
489
+ });
490
+ app.get("/api/config", async () => {
491
+ try {
492
+ return await readConfig(options.rootDir);
493
+ } catch (error) {
494
+ return { version: 1, taskDirs: ["."], order: [] };
495
+ }
496
+ });
497
+ app.put("/api/config", async (request, reply) => {
498
+ try {
499
+ const body = request.body;
500
+ const taskDirs = body?.taskDirs;
501
+ if (!Array.isArray(taskDirs) || taskDirs.some((item) => typeof item !== "string")) {
502
+ throw new ValidationError("taskDirs must be an array of strings.");
503
+ }
504
+ let ignorePaths;
505
+ if (body?.ignorePaths !== void 0) {
506
+ if (!Array.isArray(body.ignorePaths) || body.ignorePaths.some((item) => typeof item !== "string")) {
507
+ throw new ValidationError("ignorePaths must be an array of strings.");
508
+ }
509
+ ignorePaths = body.ignorePaths;
510
+ }
511
+ const config = await saveConfig(options.rootDir, taskDirs, ignorePaths);
512
+ return reply.send(config);
513
+ } catch (error) {
514
+ sendJsonError(reply, error);
515
+ }
516
+ });
517
+ app.get("/api/events", async (_request, reply) => {
518
+ reply.raw.writeHead(200, {
519
+ "Content-Type": "text/event-stream",
520
+ "Cache-Control": "no-cache, no-transform",
521
+ Connection: "keep-alive"
522
+ });
523
+ reply.raw.write("\n");
524
+ const listener = {
525
+ send(payload) {
526
+ reply.raw.write(`data: ${payload}
527
+
528
+ `);
529
+ },
530
+ close() {
531
+ reply.raw.end();
532
+ }
533
+ };
534
+ listeners.add(listener);
535
+ reply.raw.on("close", () => {
536
+ listeners.delete(listener);
537
+ });
538
+ return reply.hijack();
539
+ });
540
+ const watcher = chokidar.watch(options.rootDir, {
541
+ ignoreInitial: true,
542
+ ignored: (watchPath) => watchPath.includes(`${path2.sep}.git`) || watchPath.includes(`${path2.sep}node_modules`)
543
+ });
544
+ watcher.on("all", (eventName, changedPath) => {
545
+ const isMarkdown = changedPath.endsWith(".md") || changedPath.endsWith(".markdown");
546
+ const isConfigFile = path2.basename(changedPath) === ".md-task-viewer.json";
547
+ if (!isMarkdown && !isConfigFile) {
548
+ return;
549
+ }
550
+ const payload = JSON.stringify({
551
+ type: "tasks-changed",
552
+ eventName,
553
+ path: path2.relative(options.rootDir, changedPath)
554
+ });
555
+ for (const listener of listeners) {
556
+ listener.send(payload);
557
+ }
558
+ });
559
+ app.addHook("onClose", async () => {
560
+ await watcher.close();
561
+ });
562
+ if (clientDir) {
563
+ await app.register(fastifyStatic, {
564
+ root: clientDir,
565
+ prefix: "/"
566
+ });
567
+ app.setNotFoundHandler(async (request, reply) => {
568
+ if (request.raw.url?.startsWith("/api/")) {
569
+ return reply.code(404).send({ error: "Not found" });
570
+ }
571
+ return reply.sendFile("index.html");
572
+ });
573
+ }
574
+ return app;
575
+ }
576
+ export {
577
+ createServer
578
+ };
579
+ //# sourceMappingURL=server.js.map