tatr-ql 0.1.0 → 0.2.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.
- package/bin/index.js +7 -0
- package/package.json +11 -4
- package/src/cli.js +242 -0
- package/src/index.js +5 -0
- package/src/query.js +199 -0
- package/src/store.js +123 -0
- package/src/task.js +107 -0
- package/tests/cli.test.js +353 -0
- package/tests/huid.test.js +138 -0
- package/tests/query.test.js +276 -0
- package/tests/store.test.js +264 -0
- package/tests/task.test.js +309 -0
package/bin/index.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tatr-ql",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Task Tracker (Nodejs Version)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"task",
|
|
@@ -22,11 +22,18 @@
|
|
|
22
22
|
"license": "MIT",
|
|
23
23
|
"author": "zakaria elalaoui",
|
|
24
24
|
"type": "module",
|
|
25
|
-
"main": "index.js",
|
|
26
25
|
"scripts": {
|
|
27
|
-
"test": "
|
|
26
|
+
"test": "vitest",
|
|
27
|
+
"test:run": "vitest run"
|
|
28
|
+
},
|
|
29
|
+
"bin": {
|
|
30
|
+
"tatr": "./bin/tatr.js"
|
|
28
31
|
},
|
|
29
32
|
"dependencies": {
|
|
30
|
-
"ziko": "^2.0.0-beta.10"
|
|
33
|
+
"ziko": "^2.0.0-beta.10",
|
|
34
|
+
"zod": "^4.6.2"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"vitest": "^4.1.11"
|
|
31
38
|
}
|
|
32
39
|
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
createTask,
|
|
6
|
+
deleteTask,
|
|
7
|
+
ensureTasksDir,
|
|
8
|
+
listTasks,
|
|
9
|
+
readTask,
|
|
10
|
+
tasksPath,
|
|
11
|
+
writeTask
|
|
12
|
+
} from "./store.js";
|
|
13
|
+
import { parseTags } from "./task.js";
|
|
14
|
+
import { matchesQuery } from "./query.js";
|
|
15
|
+
import packageJson from "../package.json" with { type: "json" };
|
|
16
|
+
|
|
17
|
+
const {version : VERSION} = packageJson;
|
|
18
|
+
|
|
19
|
+
const help = () => console.log(`
|
|
20
|
+
tatr ${VERSION} — filesystem task tracker
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
tatr <command> [options]
|
|
24
|
+
|
|
25
|
+
Commands:
|
|
26
|
+
new <title> Create a task
|
|
27
|
+
ls [query] List tasks, optionally filtered by TQL
|
|
28
|
+
show <id> Show a task
|
|
29
|
+
edit <id> Edit TASK.md in $EDITOR
|
|
30
|
+
close <id> Mark a task CLOSED
|
|
31
|
+
reopen <id> Mark a task OPEN
|
|
32
|
+
rm <id> Delete a task
|
|
33
|
+
tags Show tag descriptions
|
|
34
|
+
init Create tasks/
|
|
35
|
+
help Show this help
|
|
36
|
+
version Show the version
|
|
37
|
+
|
|
38
|
+
new options:
|
|
39
|
+
--priority <n> Task priority (default: 0)
|
|
40
|
+
--tags <a,b,c> Comma-separated tags
|
|
41
|
+
--suffix <name> HUID suffix
|
|
42
|
+
--description <x> Initial description
|
|
43
|
+
|
|
44
|
+
ls options:
|
|
45
|
+
--all Include CLOSED tasks (default is all; kept for compatibility)
|
|
46
|
+
--open Only OPEN tasks
|
|
47
|
+
--closed Only CLOSED tasks
|
|
48
|
+
|
|
49
|
+
TQL examples:
|
|
50
|
+
tatr ls :bug
|
|
51
|
+
tatr ls ':bug and not :ui'
|
|
52
|
+
tatr ls 'not tagged'
|
|
53
|
+
tatr ls ':bug and priority lt 50'
|
|
54
|
+
tatr ls '[:bug or :enhancement] and priority ge 10'
|
|
55
|
+
`.trim());
|
|
56
|
+
|
|
57
|
+
const parseFlags = (args) => {
|
|
58
|
+
const positionals = [];
|
|
59
|
+
const flags = {};
|
|
60
|
+
|
|
61
|
+
for (let i = 0; i < args.length; i++) {
|
|
62
|
+
const arg = args[i];
|
|
63
|
+
|
|
64
|
+
if (!arg.startsWith("--")) {
|
|
65
|
+
positionals.push(arg);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const eq = arg.indexOf("=");
|
|
70
|
+
|
|
71
|
+
if (eq !== -1) {
|
|
72
|
+
flags[arg.slice(2, eq)] = arg.slice(eq + 1);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const key = arg.slice(2);
|
|
77
|
+
const next = args[i + 1];
|
|
78
|
+
|
|
79
|
+
if (next && !next.startsWith("--")) {
|
|
80
|
+
flags[key] = next;
|
|
81
|
+
i++;
|
|
82
|
+
} else {
|
|
83
|
+
flags[key] = true;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { positionals, flags };
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const formatTask = (task) => {
|
|
91
|
+
const tags = task.tags.length ? ` [${task.tags.join(", ")}]` : "";
|
|
92
|
+
return `${task.id} ${task.status.padEnd(6)} ${String(task.priority).padStart(4)} ${task.title}${tags}`;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const commandNew = async (args) => {
|
|
96
|
+
const { positionals, flags } = parseFlags(args);
|
|
97
|
+
const title = positionals.join(" ").trim();
|
|
98
|
+
|
|
99
|
+
if (!title) throw new Error("Usage: tatr new <title>");
|
|
100
|
+
|
|
101
|
+
const task = await createTask({
|
|
102
|
+
title,
|
|
103
|
+
priority: flags.priority ?? 0,
|
|
104
|
+
tags: flags.tags ? parseTags(flags.tags) : [],
|
|
105
|
+
description: flags.description ?? "",
|
|
106
|
+
suffix: flags.suffix
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
console.log(task.id);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const commandLs = async (args) => {
|
|
113
|
+
const { positionals, flags } = parseFlags(args);
|
|
114
|
+
const query = positionals.join(" ").trim();
|
|
115
|
+
let tasks = await listTasks();
|
|
116
|
+
|
|
117
|
+
if (flags.open) tasks = tasks.filter((task) => task.status === "OPEN");
|
|
118
|
+
if (flags.closed) tasks = tasks.filter((task) => task.status === "CLOSED");
|
|
119
|
+
if (query) tasks = tasks.filter((task) => matchesQuery(query, task));
|
|
120
|
+
|
|
121
|
+
tasks.sort((a, b) =>
|
|
122
|
+
b.priority - a.priority ||
|
|
123
|
+
a.id.localeCompare(b.id)
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
for (const task of tasks) console.log(formatTask(task));
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const commandShow = async (id) => {
|
|
130
|
+
if (!id) throw new Error("Usage: tatr show <id>");
|
|
131
|
+
|
|
132
|
+
const task = await readTask(id);
|
|
133
|
+
|
|
134
|
+
console.log(`ID: ${task.id}`);
|
|
135
|
+
console.log(`TITLE: ${task.title}`);
|
|
136
|
+
console.log(`STATUS: ${task.status}`);
|
|
137
|
+
console.log(`PRIORITY: ${task.priority}`);
|
|
138
|
+
console.log(`TAGS: ${task.tags.join(", ")}`);
|
|
139
|
+
|
|
140
|
+
if (task.description) console.log(`\n${task.description}`);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const commandEdit = async (id) => {
|
|
144
|
+
if (!id) throw new Error("Usage: tatr edit <id>");
|
|
145
|
+
|
|
146
|
+
const editor = process.env.EDITOR || process.env.VISUAL;
|
|
147
|
+
if (!editor) throw new Error("Set $EDITOR or $VISUAL before using tatr edit");
|
|
148
|
+
|
|
149
|
+
const file = join(tasksPath(), id, "TASK.md");
|
|
150
|
+
await readFile(file, "utf8");
|
|
151
|
+
|
|
152
|
+
const child = spawn(editor, [file], {
|
|
153
|
+
stdio: "inherit",
|
|
154
|
+
shell: true
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
await new Promise((resolve, reject) => {
|
|
158
|
+
child.on("error", reject);
|
|
159
|
+
child.on("exit", (code) => {
|
|
160
|
+
if (code === 0) resolve();
|
|
161
|
+
else reject(new Error(`editor exited with code ${code}`));
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const setStatus = async (id, status) => {
|
|
167
|
+
if (!id) {
|
|
168
|
+
throw new Error(
|
|
169
|
+
`Usage: tatr ${status === "CLOSED" ? "close" : "reopen"} <id>`
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const task = await readTask(id);
|
|
174
|
+
task.status = status;
|
|
175
|
+
await writeTask(task);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const commandTags = async () => {
|
|
179
|
+
const file = join(tasksPath(), "tags");
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
console.log(await readFile(file, "utf8"));
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (error.code === "ENOENT") {
|
|
185
|
+
console.log("No tasks/tags file.");
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
export const main = async (argv = process.argv.slice(2)) => {
|
|
193
|
+
const [command = "help", ...args] = argv;
|
|
194
|
+
|
|
195
|
+
switch (command) {
|
|
196
|
+
case "help":
|
|
197
|
+
case "-h":
|
|
198
|
+
case "--help":
|
|
199
|
+
help();
|
|
200
|
+
break;
|
|
201
|
+
case "version":
|
|
202
|
+
case "-v":
|
|
203
|
+
case "--version":
|
|
204
|
+
console.log(VERSION);
|
|
205
|
+
break;
|
|
206
|
+
case "init":
|
|
207
|
+
await ensureTasksDir();
|
|
208
|
+
console.log(`Initialized ${tasksPath()}`);
|
|
209
|
+
break;
|
|
210
|
+
case "new":
|
|
211
|
+
case "add":
|
|
212
|
+
await commandNew(args);
|
|
213
|
+
break;
|
|
214
|
+
case "ls":
|
|
215
|
+
case "list":
|
|
216
|
+
await commandLs(args);
|
|
217
|
+
break;
|
|
218
|
+
case "show":
|
|
219
|
+
await commandShow(args[0]);
|
|
220
|
+
break;
|
|
221
|
+
case "edit":
|
|
222
|
+
await commandEdit(args[0]);
|
|
223
|
+
break;
|
|
224
|
+
case "close":
|
|
225
|
+
await setStatus(args[0], "CLOSED");
|
|
226
|
+
break;
|
|
227
|
+
case "reopen":
|
|
228
|
+
case "open":
|
|
229
|
+
await setStatus(args[0], "OPEN");
|
|
230
|
+
break;
|
|
231
|
+
case "rm":
|
|
232
|
+
case "delete":
|
|
233
|
+
if (!args[0]) throw new Error("Usage: tatr rm <id>");
|
|
234
|
+
await deleteTask(args[0]);
|
|
235
|
+
break;
|
|
236
|
+
case "tags":
|
|
237
|
+
await commandTags();
|
|
238
|
+
break;
|
|
239
|
+
default:
|
|
240
|
+
throw new Error(`Unknown command "${command}". Run "tatr help".`);
|
|
241
|
+
}
|
|
242
|
+
};
|
package/src/index.js
ADDED
package/src/query.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* TQL parser/evaluator.
|
|
3
|
+
*
|
|
4
|
+
* Grammar:
|
|
5
|
+
* expr := or
|
|
6
|
+
* or := and ("or" and)*
|
|
7
|
+
* and := compare ("and" compare)*
|
|
8
|
+
* compare := primary (compare-op primary)*
|
|
9
|
+
* primary := tag | "[" expr "]" | "not" primary | "any" | "tagged"
|
|
10
|
+
* | "priority" | number
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
|
|
15
|
+
const COMPARATORS = new Set(["lt", "le", "gt", "ge", "eq", "ne"]);
|
|
16
|
+
const KEYWORDS = new Set([
|
|
17
|
+
"and",
|
|
18
|
+
"or",
|
|
19
|
+
"not",
|
|
20
|
+
"any",
|
|
21
|
+
"tagged",
|
|
22
|
+
"priority",
|
|
23
|
+
...COMPARATORS
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
const TokenSchema = z.object({
|
|
27
|
+
type: z.string(),
|
|
28
|
+
value: z.union([z.string(), z.number(), z.null()])
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const TokensSchema = z.array(TokenSchema);
|
|
32
|
+
|
|
33
|
+
export const tokenize = (input) => {
|
|
34
|
+
const tokens = [];
|
|
35
|
+
let i = 0;
|
|
36
|
+
while (i < input.length) {
|
|
37
|
+
const ch = input[i];
|
|
38
|
+
if (/\s/.test(ch)) {
|
|
39
|
+
i++;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (ch === "[") {
|
|
43
|
+
tokens.push({ type: "lbracket", value: ch });
|
|
44
|
+
i++;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (ch === "]") {
|
|
48
|
+
tokens.push({ type: "rbracket", value: ch });
|
|
49
|
+
i++;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (ch === ":") {
|
|
53
|
+
let j = i + 1;
|
|
54
|
+
while (j < input.length && !/[\s[\]]/.test(input[j])) j++;
|
|
55
|
+
if (j === i + 1) throw new Error("Empty tag in TQL");
|
|
56
|
+
tokens.push({ type: "tag", value: input.slice(i + 1, j) });
|
|
57
|
+
i = j;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (ch === "-" || /\d/.test(ch)) {
|
|
61
|
+
let j = i;
|
|
62
|
+
if (input[j] === "-") j++;
|
|
63
|
+
const start = j;
|
|
64
|
+
while (j < input.length && /\d/.test(input[j])) j++;
|
|
65
|
+
if (j === start) {
|
|
66
|
+
throw new Error(`Invalid number near "${input.slice(i)}"`);
|
|
67
|
+
}
|
|
68
|
+
tokens.push({
|
|
69
|
+
type: "number",
|
|
70
|
+
value: Number.parseInt(input.slice(i, j), 10)
|
|
71
|
+
});
|
|
72
|
+
i = j;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let j = i;
|
|
77
|
+
while (j < input.length && !/[\s[\]]/.test(input[j])) j++;
|
|
78
|
+
const word = input.slice(i, j);
|
|
79
|
+
const lower = word.toLowerCase();
|
|
80
|
+
if (KEYWORDS.has(lower)) tokens.push({ type: lower, value: lower });
|
|
81
|
+
else throw new Error(`Unexpected token "${word}"`);
|
|
82
|
+
i = j;
|
|
83
|
+
}
|
|
84
|
+
return TokensSchema.parse([
|
|
85
|
+
...tokens,
|
|
86
|
+
{ type: "eof", value: null }
|
|
87
|
+
]);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const parseQuery = (input) => {
|
|
91
|
+
if (!input || !input.trim()) return { type: "any" };
|
|
92
|
+
const tokens = tokenize(input);
|
|
93
|
+
let pos = 0;
|
|
94
|
+
const peek = () => tokens[pos];
|
|
95
|
+
const take = () => tokens[pos++];
|
|
96
|
+
const expect = (type) => {
|
|
97
|
+
const token = take();
|
|
98
|
+
if (token.type !== type) throw new Error(`Expected ${type}, got ${token.type}`);
|
|
99
|
+
return token;
|
|
100
|
+
};
|
|
101
|
+
const parsePrimary = () => {
|
|
102
|
+
const token = peek();
|
|
103
|
+
switch (token.type) {
|
|
104
|
+
case "not":
|
|
105
|
+
take();
|
|
106
|
+
return { type: "not", expr: parsePrimary() };
|
|
107
|
+
case "lbracket":
|
|
108
|
+
take();
|
|
109
|
+
const expr = parseOr();
|
|
110
|
+
expect("rbracket");
|
|
111
|
+
return expr;
|
|
112
|
+
case "tag":
|
|
113
|
+
take();
|
|
114
|
+
return { type: "tag", tag: token.value };
|
|
115
|
+
case "any":
|
|
116
|
+
take();
|
|
117
|
+
return { type: "any" };
|
|
118
|
+
case "tagged":
|
|
119
|
+
take();
|
|
120
|
+
return { type: "tagged" };
|
|
121
|
+
case "priority":
|
|
122
|
+
take();
|
|
123
|
+
return { type: "priority" };
|
|
124
|
+
case "number":
|
|
125
|
+
take();
|
|
126
|
+
return { type: "number", value: token.value };
|
|
127
|
+
default:
|
|
128
|
+
throw new Error(`Expected expression, got ${token.type}`);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const parseCompare = () => {
|
|
133
|
+
let left = parsePrimary();
|
|
134
|
+
while (COMPARATORS.has(peek().type)) {
|
|
135
|
+
const op = take().type;
|
|
136
|
+
const right = parsePrimary();
|
|
137
|
+
left = { type: "compare", op, left, right };
|
|
138
|
+
}
|
|
139
|
+
return left;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const parseAnd = () => {
|
|
143
|
+
let left = parseCompare();
|
|
144
|
+
while (peek().type === "and") {
|
|
145
|
+
take();
|
|
146
|
+
left = {
|
|
147
|
+
type: "and",
|
|
148
|
+
left,
|
|
149
|
+
right: parseCompare()
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
return left;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const parseOr = () => {
|
|
156
|
+
let left = parseAnd();
|
|
157
|
+
while (peek().type === "or") {
|
|
158
|
+
take();
|
|
159
|
+
left = {
|
|
160
|
+
type: "or",
|
|
161
|
+
left,
|
|
162
|
+
right: parseAnd()
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
return left;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const ast = parseOr();
|
|
169
|
+
if (peek().type !== "eof") throw new Error(`Unexpected token "${peek().value}"`);
|
|
170
|
+
return ast;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
export const evaluateQuery = (ast, task) => {
|
|
174
|
+
switch (ast.type) {
|
|
175
|
+
case "any": return true;
|
|
176
|
+
case "tag": return task.tags.includes(ast.tag);
|
|
177
|
+
case "tagged": return task.tags.length > 0;
|
|
178
|
+
case "priority": return task.priority;
|
|
179
|
+
case "number": return ast.value;
|
|
180
|
+
case "not": return !Boolean(evaluateQuery(ast.expr, task));
|
|
181
|
+
case "and": return Boolean(evaluateQuery(ast.left, task)) && Boolean(evaluateQuery(ast.right, task));
|
|
182
|
+
case "or": return Boolean(evaluateQuery(ast.left, task)) || Boolean(evaluateQuery(ast.right, task));
|
|
183
|
+
case "compare": {
|
|
184
|
+
const left = evaluateQuery(ast.left, task);
|
|
185
|
+
const right = evaluateQuery(ast.right, task);
|
|
186
|
+
switch (ast.op) {
|
|
187
|
+
case "lt": return left < right;
|
|
188
|
+
case "le": return left <= right;
|
|
189
|
+
case "gt": return left > right;
|
|
190
|
+
case "ge": return left >= right;
|
|
191
|
+
case "eq": return left === right;
|
|
192
|
+
case "ne": return left !== right;
|
|
193
|
+
default: throw new Error(`Unknown comparator ${ast.op}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
default: throw new Error(`Unknown AST node ${ast.type}`);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
export const matchesQuery = (query, task) => evaluateQuery(typeof query === "string" ? parseQuery(query) : query, task);
|
package/src/store.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import {
|
|
2
|
+
access,
|
|
3
|
+
mkdir,
|
|
4
|
+
readdir,
|
|
5
|
+
readFile,
|
|
6
|
+
writeFile,
|
|
7
|
+
rm,
|
|
8
|
+
rename
|
|
9
|
+
} from "node:fs/promises";
|
|
10
|
+
import { join, resolve } from "node:path";
|
|
11
|
+
import { uniqueHuid, isHuid } from "./huid.js";
|
|
12
|
+
import { parseTaskMarkdown, serializeTask } from "./task.js";
|
|
13
|
+
|
|
14
|
+
export const findProjectRoot = (start = process.cwd()) => resolve(start);
|
|
15
|
+
export const tasksPath = (projectRoot = process.cwd()) => join(findProjectRoot(projectRoot), "tasks");
|
|
16
|
+
export const ensureTasksDir = async (projectRoot = process.cwd()) => {
|
|
17
|
+
const dir = tasksPath(projectRoot);
|
|
18
|
+
await mkdir(dir, { recursive: true });
|
|
19
|
+
return dir;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const listTaskIds = async (projectRoot = process.cwd()) => {
|
|
23
|
+
const dir = tasksPath(projectRoot);
|
|
24
|
+
try {
|
|
25
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
26
|
+
return entries
|
|
27
|
+
.filter((entry) => entry.isDirectory() && isHuid(entry.name))
|
|
28
|
+
.map((entry) => entry.name)
|
|
29
|
+
.sort();
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (error.code === "ENOENT") return [];
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const readTask = async (id, projectRoot = process.cwd()) => {
|
|
37
|
+
if (!isHuid(id)) {
|
|
38
|
+
throw new Error(`Invalid task ID: ${id}`);
|
|
39
|
+
}
|
|
40
|
+
const file = join(tasksPath(projectRoot), id, "TASK.md");
|
|
41
|
+
const markdown = await readFile(file, "utf8");
|
|
42
|
+
return parseTaskMarkdown(markdown, id);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const listTasks = async (projectRoot = process.cwd()) => {
|
|
46
|
+
const ids = await listTaskIds(projectRoot);
|
|
47
|
+
const tasks = [];
|
|
48
|
+
for (const id of ids) {
|
|
49
|
+
try {
|
|
50
|
+
tasks.push(await readTask(id, projectRoot));
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (error.code === "ENOENT") continue;
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return tasks;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const createTask = async ({
|
|
61
|
+
projectRoot = process.cwd(),
|
|
62
|
+
title,
|
|
63
|
+
priority = 0,
|
|
64
|
+
status = "OPEN",
|
|
65
|
+
tags = [],
|
|
66
|
+
description = "",
|
|
67
|
+
suffix
|
|
68
|
+
}) => {
|
|
69
|
+
if (!title?.trim()) {
|
|
70
|
+
throw new Error("Task title is required");
|
|
71
|
+
}
|
|
72
|
+
const dir = await ensureTasksDir(projectRoot);
|
|
73
|
+
const id = await uniqueHuid(dir, { suffix });
|
|
74
|
+
const taskDir = join(dir, id);
|
|
75
|
+
await mkdir(taskDir);
|
|
76
|
+
const task = {
|
|
77
|
+
id,
|
|
78
|
+
title: title.trim(),
|
|
79
|
+
status: String(status).toUpperCase(),
|
|
80
|
+
priority: Number.parseInt(priority, 10),
|
|
81
|
+
tags: Array.isArray(tags)
|
|
82
|
+
? tags
|
|
83
|
+
: String(tags)
|
|
84
|
+
.split(",")
|
|
85
|
+
.map((x) => x.trim())
|
|
86
|
+
.filter(Boolean),
|
|
87
|
+
description
|
|
88
|
+
};
|
|
89
|
+
await writeFile(
|
|
90
|
+
join(taskDir, "TASK.md"),
|
|
91
|
+
serializeTask(task),
|
|
92
|
+
"utf8"
|
|
93
|
+
);
|
|
94
|
+
return task;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export const writeTask = async (task, projectRoot = process.cwd()) => {
|
|
98
|
+
if (!isHuid(task.id)) {
|
|
99
|
+
throw new Error(`Invalid task ID: ${task.id}`);
|
|
100
|
+
}
|
|
101
|
+
const file = join(tasksPath(projectRoot), task.id, "TASK.md");
|
|
102
|
+
await writeFile(file, serializeTask(task), "utf8");
|
|
103
|
+
return task;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export const deleteTask = async (id, projectRoot = process.cwd()) => {
|
|
107
|
+
if (!isHuid(id)) {
|
|
108
|
+
throw new Error(`Invalid task ID: ${id}`);
|
|
109
|
+
}
|
|
110
|
+
await rm(join(tasksPath(projectRoot), id), {
|
|
111
|
+
recursive: true,
|
|
112
|
+
force: false
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export const moveTask = async (id, newId, projectRoot = process.cwd()) => {
|
|
117
|
+
if (!isHuid(id) || !isHuid(newId)) {
|
|
118
|
+
throw new Error("Both task IDs must be valid HUIDs");
|
|
119
|
+
}
|
|
120
|
+
const dir = tasksPath(projectRoot);
|
|
121
|
+
await access(join(dir, id));
|
|
122
|
+
await rename(join(dir, id), join(dir, newId));
|
|
123
|
+
};
|