tatr-ql 0.1.0 → 0.3.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 ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { main } from "../src/cli.js";
3
+
4
+ main().catch((error) => {
5
+ console.error(`tatr: ${error.message}`);
6
+ process.exitCode = 1;
7
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tatr-ql",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Task Tracker (Nodejs Version)",
5
5
  "keywords": [
6
6
  "task",
@@ -22,11 +22,20 @@
22
22
  "license": "MIT",
23
23
  "author": "zakaria elalaoui",
24
24
  "type": "module",
25
- "main": "index.js",
26
25
  "scripts": {
27
- "test": "echo \"Error: no test specified\" && exit 1"
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
+ "@b9g/termdom": "^0.1.6",
34
+ "vanjs-core": "^1.6.1",
35
+ "ziko": "^2.0.0-beta.10",
36
+ "zod": "^4.6.2"
37
+ },
38
+ "devDependencies": {
39
+ "vitest": "^4.1.11"
31
40
  }
32
41
  }
package/src/cli.js ADDED
@@ -0,0 +1,233 @@
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
+ import {TermDOM} from "@b9g/termdom";
18
+ const term = new TermDOM();
19
+ term.attach();
20
+ const {document, window} = term;
21
+ globalThis.document = document
22
+ globalThis.window = window
23
+
24
+
25
+ import {
26
+ StartPager,
27
+ Help,
28
+ ShowTask
29
+ } from "./components/index.js";
30
+
31
+ const {version : VERSION} = packageJson;
32
+
33
+ const parseFlags = (args) => {
34
+ const positionals = [];
35
+ const flags = {};
36
+
37
+ for (let i = 0; i < args.length; i++) {
38
+ const arg = args[i];
39
+
40
+ if (!arg.startsWith("--")) {
41
+ positionals.push(arg);
42
+ continue;
43
+ }
44
+
45
+ const eq = arg.indexOf("=");
46
+
47
+ if (eq !== -1) {
48
+ flags[arg.slice(2, eq)] = arg.slice(eq + 1);
49
+ continue;
50
+ }
51
+
52
+ const key = arg.slice(2);
53
+ const next = args[i + 1];
54
+
55
+ if (next && !next.startsWith("--")) {
56
+ flags[key] = next;
57
+ i++;
58
+ } else {
59
+ flags[key] = true;
60
+ }
61
+ }
62
+
63
+ return { positionals, flags };
64
+ };
65
+
66
+ const formatTask = (task) => {
67
+ const tags = task.tags.length ? ` [${task.tags.join(", ")}]` : "";
68
+ return `${task.id} ${task.status.padEnd(6)} ${String(task.priority).padStart(4)} ${task.title}${tags}`;
69
+ };
70
+
71
+ const commandNew = async (args) => {
72
+ const { positionals, flags } = parseFlags(args);
73
+ const title = positionals.join(" ").trim();
74
+
75
+ if (!title) throw new Error("Usage: tatr new <title>");
76
+
77
+ const task = await createTask({
78
+ title,
79
+ priority: flags.priority ?? 0,
80
+ tags: flags.tags ? parseTags(flags.tags) : [],
81
+ description: flags.description ?? "",
82
+ suffix: flags.suffix
83
+ });
84
+
85
+ console.log(task.id);
86
+ };
87
+
88
+ const commandLs = async (args) => {
89
+ const { positionals, flags } = parseFlags(args);
90
+ const query = positionals.join(" ").trim();
91
+ let tasks = await listTasks();
92
+
93
+ if (flags.open) tasks = tasks.filter((task) => task.status === "OPEN");
94
+ if (flags.closed) tasks = tasks.filter((task) => task.status === "CLOSED");
95
+ if (query) tasks = tasks.filter((task) => matchesQuery(query, task));
96
+
97
+ tasks.sort((a, b) =>
98
+ b.priority - a.priority ||
99
+ a.id.localeCompare(b.id)
100
+ );
101
+
102
+ for (const task of tasks) console.log(formatTask(task));
103
+ };
104
+
105
+ const commandShow = async (id) => {
106
+ if (!id) throw new Error("Usage: tatr show <id>");
107
+
108
+ const task = await readTask(id);
109
+
110
+ const {title, status, priority, tags} = task
111
+
112
+ ShowTask({id, title, status, priority, tags}).mount(document.body)
113
+
114
+ // console.log(`ID: ${task.id}`);
115
+ // console.log(`TITLE: ${task.title}`);
116
+ // console.log(`STATUS: ${task.status}`);
117
+ // console.log(`PRIORITY: ${task.priority}`);
118
+ // console.log(`TAGS: ${task.tags.join(", ")}`);
119
+
120
+ // table(
121
+ // tbody(
122
+ // tr(td("ID"), td(task.id)),
123
+ // tr(td("TITLE"), td(task.title)),
124
+ // tr(td("STATUS"), td(task.status)),
125
+ // tr(td("PRIORITY"), td(task.priority)),
126
+ // tr(td("TAGS"), td(task.tags.join(", ")))
127
+ // )
128
+ // ).mount(document.body)
129
+
130
+ if (task.description) console.log(`\n${task.description}`);
131
+ };
132
+
133
+ const commandEdit = async (id) => {
134
+ if (!id) throw new Error("Usage: tatr edit <id>");
135
+
136
+ const editor = process.env.EDITOR || process.env.VISUAL;
137
+ if (!editor) throw new Error("Set $EDITOR or $VISUAL before using tatr edit");
138
+
139
+ const file = join(tasksPath(), id, "TASK.md");
140
+ await readFile(file, "utf8");
141
+
142
+ const child = spawn(editor, [file], {
143
+ stdio: "inherit",
144
+ shell: true
145
+ });
146
+
147
+ await new Promise((resolve, reject) => {
148
+ child.on("error", reject);
149
+ child.on("exit", (code) => {
150
+ if (code === 0) resolve();
151
+ else reject(new Error(`editor exited with code ${code}`));
152
+ });
153
+ });
154
+ };
155
+
156
+ const setStatus = async (id, status) => {
157
+ if (!id) {
158
+ throw new Error(
159
+ `Usage: tatr ${status === "CLOSED" ? "close" : "reopen"} <id>`
160
+ );
161
+ }
162
+
163
+ const task = await readTask(id);
164
+ task.status = status;
165
+ await writeTask(task);
166
+ };
167
+
168
+ const commandTags = async () => {
169
+ const file = join(tasksPath(), "tags");
170
+
171
+ try {
172
+ console.log(await readFile(file, "utf8"));
173
+ } catch (error) {
174
+ if (error.code === "ENOENT") {
175
+ console.log("No tasks/tags file.");
176
+ return;
177
+ }
178
+ throw error;
179
+ }
180
+ };
181
+
182
+ export const main = async (argv = process.argv.slice(2)) => {
183
+ const [command = "help", ...args] = argv;
184
+
185
+ switch (command) {
186
+ case "help":
187
+ case "-h":
188
+ case "--help":
189
+ Help().mount(document.body);
190
+ await StartPager()
191
+ break;
192
+ case "version":
193
+ case "-v":
194
+ case "--version":
195
+ console.log(VERSION);
196
+ break;
197
+ case "init":
198
+ await ensureTasksDir();
199
+ console.log(`Initialized ${tasksPath()}`);
200
+ break;
201
+ case "new":
202
+ case "add":
203
+ await commandNew(args);
204
+ break;
205
+ case "ls":
206
+ case "list":
207
+ await commandLs(args);
208
+ break;
209
+ case "show":
210
+ await commandShow(args[0]);
211
+ break;
212
+ case "edit":
213
+ await commandEdit(args[0]);
214
+ break;
215
+ case "close":
216
+ await setStatus(args[0], "CLOSED");
217
+ break;
218
+ case "reopen":
219
+ case "open":
220
+ await setStatus(args[0], "OPEN");
221
+ break;
222
+ case "rm":
223
+ case "delete":
224
+ if (!args[0]) throw new Error("Usage: tatr rm <id>");
225
+ await deleteTask(args[0]);
226
+ break;
227
+ case "tags":
228
+ await commandTags();
229
+ break;
230
+ default:
231
+ throw new Error(`Unknown command "${command}". Run "tatr help".`);
232
+ }
233
+ };
@@ -0,0 +1,150 @@
1
+ import { tags } from "ziko/dom";
2
+ const {
3
+ div, h1, span, h2, section, pre, table, tbody, tr, td
4
+ } = tags
5
+ export const Help = ({ version = '0.1.0'} = {}) =>
6
+ div(
7
+ pre(`
8
+ -
9
+ `),
10
+ h1(
11
+ `tatr ${version}`,
12
+ span(" — filesystem task tracker")
13
+ ).style({
14
+ color : 'blue'
15
+ }),
16
+
17
+ section(
18
+ h2("Usage"),
19
+
20
+ pre(
21
+ "tatr <command> [options]"
22
+ )
23
+ ),
24
+
25
+ section(
26
+ h2("Commands"),
27
+
28
+ table(
29
+ tbody(
30
+ tr(
31
+ td("new <title>"),
32
+ td("Create a task")
33
+ ),
34
+
35
+ tr(
36
+ td("ls [query]"),
37
+ td("List tasks, optionally filtered by TQL")
38
+ ),
39
+
40
+ tr(
41
+ td("show <id>"),
42
+ td("Show a task")
43
+ ),
44
+
45
+ tr(
46
+ td("edit <id>"),
47
+ td("Edit TASK.md in $EDITOR")
48
+ ),
49
+
50
+ tr(
51
+ td("close <id>"),
52
+ td("Mark a task CLOSED")
53
+ ),
54
+
55
+ tr(
56
+ td("reopen <id>"),
57
+ td("Mark a task OPEN")
58
+ ),
59
+
60
+ tr(
61
+ td("rm <id>"),
62
+ td("Delete a task")
63
+ ),
64
+
65
+ tr(
66
+ td("tags"),
67
+ td("Show tag descriptions")
68
+ ),
69
+
70
+ tr(
71
+ td("init"),
72
+ td("Create tasks/")
73
+ ),
74
+
75
+ tr(
76
+ td("help"),
77
+ td("Show this help")
78
+ ),
79
+
80
+ tr(
81
+ td("version"),
82
+ td("Show the version")
83
+ )
84
+ )
85
+ )
86
+ ),
87
+
88
+ section(
89
+ h2("new options"),
90
+
91
+ table(
92
+ tbody(
93
+ tr(
94
+ td("--priority <n>"),
95
+ td("Task priority (default: 0)")
96
+ ),
97
+
98
+ tr(
99
+ td("--tags <a,b,c>"),
100
+ td("Comma-separated tags")
101
+ ),
102
+
103
+ tr(
104
+ td("--suffix <name>"),
105
+ td("HUID suffix")
106
+ ),
107
+
108
+ tr(
109
+ td("--description <x>"),
110
+ td("Initial description")
111
+ )
112
+ )
113
+ )
114
+ ),
115
+
116
+ section(
117
+ h2("ls options"),
118
+
119
+ table(
120
+ tbody(
121
+ tr(
122
+ td("--all"),
123
+ td("Include CLOSED tasks (default is all; kept for compatibility)")
124
+ ),
125
+
126
+ tr(
127
+ td("--open"),
128
+ td("Only OPEN tasks")
129
+ ),
130
+
131
+ tr(
132
+ td("--closed"),
133
+ td("Only CLOSED tasks")
134
+ )
135
+ )
136
+ )
137
+ ),
138
+
139
+ section(
140
+ h2("TQL examples"),
141
+
142
+ pre(
143
+ `tatr ls :bug
144
+ tatr ls ':bug and not :ui'
145
+ tatr ls 'not tagged'
146
+ tatr ls ':bug and priority lt 50'
147
+ tatr ls '[:bug or :enhancement] and priority ge 10'`
148
+ )
149
+ )
150
+ );
@@ -0,0 +1,11 @@
1
+ import { tags } from "ziko/dom"
2
+ const { table, tbody, tr, td} = tags
3
+ export const ShowTask = ({id, title, status, priority, tags} = {}) => table(
4
+ tbody(
5
+ tr(td("ID"), td(id)),
6
+ tr(td("TITLE"), td(title)),
7
+ tr(td("STATUS"), td(status)),
8
+ tr(td("PRIORITY"), td(priority)),
9
+ tr(td("TAGS"), td(tags.join(", ")))
10
+ )
11
+ )
@@ -0,0 +1,54 @@
1
+ export const StartPager = async () => {
2
+ // Let TermDOM finish its initial layout.
3
+ await new Promise((resolve) =>
4
+ window.requestAnimationFrame(resolve)
5
+ );
6
+
7
+ await new Promise((resolve) =>
8
+ window.requestAnimationFrame(resolve)
9
+ );
10
+
11
+ // Reset the camera after layout has settled.
12
+ window.scrollTo(0, 0);
13
+
14
+ const page = () => Math.max(1, window.innerHeight - 1);
15
+
16
+ const height = () =>
17
+ Math.max(
18
+ 0,
19
+ document.body.scrollHeight - window.innerHeight
20
+ );
21
+
22
+ const bindings = {
23
+ " ": () => window.scrollBy(0, page()),
24
+ f: () => window.scrollBy(0, page()),
25
+ PageDown: () => window.scrollBy(0, page()),
26
+
27
+ b: () => window.scrollBy(0, -page()),
28
+ PageUp: () => window.scrollBy(0, -page()),
29
+
30
+ j: () => window.scrollBy(0, 1),
31
+ ArrowDown: () => window.scrollBy(0, 1),
32
+
33
+ k: () => window.scrollBy(0, -1),
34
+ ArrowUp: () => window.scrollBy(0, -1),
35
+
36
+ g: () => window.scrollTo(0, 0),
37
+
38
+ G: () =>
39
+ window.scrollTo(
40
+ 0,
41
+ height()
42
+ ),
43
+
44
+ q: () =>
45
+ term.window.close()
46
+ };
47
+
48
+ document.addEventListener(
49
+ "keydown",
50
+ (event) => {
51
+ bindings[event.key]?.();
52
+ }
53
+ );
54
+ };
@@ -0,0 +1,3 @@
1
+ export * from './StartPager.js'
2
+ export * from './Help.js'
3
+ export * from './ShowTask.js'
package/src/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export * from './huid.js'
2
+ export * from './query.js'
3
+ export * from './store.js'
4
+ export * from './task.js'
5
+ export * from './cli.js'
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);