@rushinglabs/taskfile 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/LICENSE +15 -0
- package/README.md +72 -0
- package/dist/commands/add.js +5 -0
- package/dist/commands/list.js +12 -0
- package/dist/commands/remove.js +15 -0
- package/dist/index.js +49 -0
- package/dist/settings.js +74 -0
- package/dist/snapshots.js +97 -0
- package/dist/store.js +253 -0
- package/dist/tui/AddTaskInput.js +13 -0
- package/dist/tui/App.js +210 -0
- package/dist/tui/Divider.js +5 -0
- package/dist/tui/EditTaskModal.js +7 -0
- package/dist/tui/Frame.js +21 -0
- package/dist/tui/MessageArea.js +6 -0
- package/dist/tui/ProgressBar.js +41 -0
- package/dist/tui/SettingsPage.js +110 -0
- package/dist/tui/Sidebar.js +12 -0
- package/dist/tui/SnapshotDetailView.js +6 -0
- package/dist/tui/SnapshotsModal.js +7 -0
- package/dist/tui/TaskList.js +23 -0
- package/dist/tui/TodosPage.js +222 -0
- package/dist/tui/index.js +10 -0
- package/dist/tui/theme.js +6 -0
- package/package.json +46 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Darrien Rushing
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
10
|
+
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
11
|
+
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
12
|
+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
13
|
+
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
|
14
|
+
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
15
|
+
PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Taskfile
|
|
2
|
+
|
|
3
|
+
A simple todo list CLI, runnable via `npx`.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @rushinglabs/taskfile add "Buy milk"
|
|
9
|
+
npx @rushinglabs/taskfile list
|
|
10
|
+
npx @rushinglabs/taskfile remove 1
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Development
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### Running commands against the dev instance
|
|
20
|
+
|
|
21
|
+
There are a few ways to run the CLI locally while developing, without publishing anything.
|
|
22
|
+
|
|
23
|
+
**Option 1: `ts-node` (no build step)**
|
|
24
|
+
|
|
25
|
+
Runs the TypeScript source directly — fastest way to iterate:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm run dev -- add "Buy milk"
|
|
29
|
+
npm run dev -- list
|
|
30
|
+
npm run dev -- remove 1
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
This starts the TUI via the development build.
|
|
34
|
+
```bash
|
|
35
|
+
npm run dev --
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**Option 2: build once, run compiled output**
|
|
39
|
+
|
|
40
|
+
Matches what actually ships:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npm run build
|
|
44
|
+
node dist/index.js add "Buy milk"
|
|
45
|
+
node dist/index.js list
|
|
46
|
+
node dist/index.js remove 1
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**Option 3: `npm link` (test the real `taskfile` command)**
|
|
50
|
+
|
|
51
|
+
Simulates installing the package globally, so you can run it as `taskfile` from anywhere, the same way `npx` would invoke it:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
npm run build
|
|
55
|
+
npm link
|
|
56
|
+
taskfile add "Buy milk"
|
|
57
|
+
taskfile list
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
When you're done, remove the link with:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
npm unlink -g @rushinglabs/taskfile
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Tasks are stored at `~/.taskfile/tasks.json` regardless of which option you use.
|
|
67
|
+
|
|
68
|
+
### Running tests
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
npm test
|
|
72
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { getDefaultListId, loadTasks } from "../store.js";
|
|
2
|
+
export function listItems() {
|
|
3
|
+
const tasks = loadTasks(getDefaultListId());
|
|
4
|
+
if (tasks.length === 0) {
|
|
5
|
+
console.log("No tasks yet. Add one with `taskfile add <text>`.");
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
for (const task of tasks) {
|
|
9
|
+
const marker = task.done ? "[x]" : "[ ]";
|
|
10
|
+
console.log(`${marker} #${task.id} ${task.text}`);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { removeTask } from "../store.js";
|
|
2
|
+
export function removeItem(id) {
|
|
3
|
+
const result = removeTask(id);
|
|
4
|
+
if (result.status === "not-found") {
|
|
5
|
+
console.error(`No task found with id #${id}`);
|
|
6
|
+
process.exitCode = 1;
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
if (result.status === "blocked") {
|
|
10
|
+
console.error(result.reason);
|
|
11
|
+
process.exitCode = 1;
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
console.log(`Removed task #${result.task.id}: ${result.task.text}`);
|
|
15
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
process.removeAllListeners("warning");
|
|
3
|
+
process.on("warning", (warning) => {
|
|
4
|
+
if (warning.name === "ExperimentalWarning" && /SQLite/i.test(warning.message)) {
|
|
5
|
+
return;
|
|
6
|
+
}
|
|
7
|
+
console.warn(warning);
|
|
8
|
+
});
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { Command } from "commander";
|
|
13
|
+
import { addItem } from "./commands/add.js";
|
|
14
|
+
import { removeItem } from "./commands/remove.js";
|
|
15
|
+
import { listItems } from "./commands/list.js";
|
|
16
|
+
import { runTui } from "./tui/index.js";
|
|
17
|
+
const packageDir = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const { version } = JSON.parse(readFileSync(join(packageDir, "../package.json"), "utf8"));
|
|
19
|
+
const program = new Command();
|
|
20
|
+
program.name("taskfile").description("A simple todo list CLI").version(version);
|
|
21
|
+
program
|
|
22
|
+
.command("add <text...>")
|
|
23
|
+
.description("Add a new task")
|
|
24
|
+
.action((text) => {
|
|
25
|
+
addItem(text.join(" "));
|
|
26
|
+
});
|
|
27
|
+
program
|
|
28
|
+
.command("remove <id>")
|
|
29
|
+
.description("Remove a task by id")
|
|
30
|
+
.action((id) => {
|
|
31
|
+
removeItem(Number(id));
|
|
32
|
+
});
|
|
33
|
+
program
|
|
34
|
+
.command("list")
|
|
35
|
+
.description("List all tasks")
|
|
36
|
+
.action(() => {
|
|
37
|
+
listItems();
|
|
38
|
+
});
|
|
39
|
+
program
|
|
40
|
+
.command("tui", { isDefault: true, hidden: true })
|
|
41
|
+
.description("Launch the interactive TUI")
|
|
42
|
+
.action(async () => {
|
|
43
|
+
if (!process.stdin.isTTY) {
|
|
44
|
+
program.help();
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
await runTui();
|
|
48
|
+
});
|
|
49
|
+
await program.parseAsync();
|
package/dist/settings.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as os from "os";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
export const SNAPSHOT_RETENTION_OPTIONS = [
|
|
5
|
+
"1-week",
|
|
6
|
+
"1-month",
|
|
7
|
+
"3-months",
|
|
8
|
+
"6-months",
|
|
9
|
+
"12-months",
|
|
10
|
+
"forever",
|
|
11
|
+
];
|
|
12
|
+
const DEFAULT_SETTINGS = {
|
|
13
|
+
width: 120,
|
|
14
|
+
height: 40,
|
|
15
|
+
progressAnimation: true,
|
|
16
|
+
lastActiveListId: null,
|
|
17
|
+
dailySnapshotsEnabled: true,
|
|
18
|
+
dailySnapshotRetention: "12-months",
|
|
19
|
+
};
|
|
20
|
+
const WIDTH_RANGE = { min: 40, max: 300 };
|
|
21
|
+
const HEIGHT_RANGE = { min: 10, max: 100 };
|
|
22
|
+
function isValidRetention(value) {
|
|
23
|
+
return typeof value === "string" && SNAPSHOT_RETENTION_OPTIONS.includes(value);
|
|
24
|
+
}
|
|
25
|
+
function clamp(value, range) {
|
|
26
|
+
return Math.min(Math.max(Math.round(value), range.min), range.max);
|
|
27
|
+
}
|
|
28
|
+
function getStoreDir() {
|
|
29
|
+
return path.join(os.homedir(), ".taskfile");
|
|
30
|
+
}
|
|
31
|
+
function getSettingsFile() {
|
|
32
|
+
return path.join(getStoreDir(), "settings.json");
|
|
33
|
+
}
|
|
34
|
+
export function loadSettings() {
|
|
35
|
+
const settingsFile = getSettingsFile();
|
|
36
|
+
if (!fs.existsSync(settingsFile)) {
|
|
37
|
+
return { ...DEFAULT_SETTINGS };
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(fs.readFileSync(settingsFile, "utf-8"));
|
|
41
|
+
return {
|
|
42
|
+
width: typeof parsed.width === "number" ? clamp(parsed.width, WIDTH_RANGE) : DEFAULT_SETTINGS.width,
|
|
43
|
+
height: typeof parsed.height === "number" ? clamp(parsed.height, HEIGHT_RANGE) : DEFAULT_SETTINGS.height,
|
|
44
|
+
progressAnimation: typeof parsed.progressAnimation === "boolean" ? parsed.progressAnimation : DEFAULT_SETTINGS.progressAnimation,
|
|
45
|
+
lastActiveListId: typeof parsed.lastActiveListId === "number" ? parsed.lastActiveListId : DEFAULT_SETTINGS.lastActiveListId,
|
|
46
|
+
dailySnapshotsEnabled: typeof parsed.dailySnapshotsEnabled === "boolean"
|
|
47
|
+
? parsed.dailySnapshotsEnabled
|
|
48
|
+
: DEFAULT_SETTINGS.dailySnapshotsEnabled,
|
|
49
|
+
dailySnapshotRetention: isValidRetention(parsed.dailySnapshotRetention)
|
|
50
|
+
? parsed.dailySnapshotRetention
|
|
51
|
+
: DEFAULT_SETTINGS.dailySnapshotRetention,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return { ...DEFAULT_SETTINGS };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export function saveSettings(update) {
|
|
59
|
+
const current = loadSettings();
|
|
60
|
+
const next = {
|
|
61
|
+
width: update.width !== undefined ? clamp(update.width, WIDTH_RANGE) : current.width,
|
|
62
|
+
height: update.height !== undefined ? clamp(update.height, HEIGHT_RANGE) : current.height,
|
|
63
|
+
progressAnimation: update.progressAnimation !== undefined ? update.progressAnimation : current.progressAnimation,
|
|
64
|
+
lastActiveListId: update.lastActiveListId !== undefined ? update.lastActiveListId : current.lastActiveListId,
|
|
65
|
+
dailySnapshotsEnabled: update.dailySnapshotsEnabled !== undefined ? update.dailySnapshotsEnabled : current.dailySnapshotsEnabled,
|
|
66
|
+
dailySnapshotRetention: update.dailySnapshotRetention !== undefined ? update.dailySnapshotRetention : current.dailySnapshotRetention,
|
|
67
|
+
};
|
|
68
|
+
const storeDir = getStoreDir();
|
|
69
|
+
if (!fs.existsSync(storeDir)) {
|
|
70
|
+
fs.mkdirSync(storeDir, { recursive: true });
|
|
71
|
+
}
|
|
72
|
+
fs.writeFileSync(getSettingsFile(), JSON.stringify(next, null, 2));
|
|
73
|
+
return next;
|
|
74
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { getDb, loadLists, loadTasks } from "./store.js";
|
|
2
|
+
const RETENTION_DAYS = {
|
|
3
|
+
"1-week": 7,
|
|
4
|
+
"1-month": 30,
|
|
5
|
+
"3-months": 90,
|
|
6
|
+
"6-months": 180,
|
|
7
|
+
"12-months": 365,
|
|
8
|
+
};
|
|
9
|
+
function localDateString(date) {
|
|
10
|
+
const year = date.getFullYear();
|
|
11
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
12
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
13
|
+
return `${year}-${month}-${day}`;
|
|
14
|
+
}
|
|
15
|
+
export function captureSnapshot(date) {
|
|
16
|
+
const db = getDb();
|
|
17
|
+
const createdAt = new Date().toISOString();
|
|
18
|
+
const snapshotInfo = db.prepare("INSERT INTO snapshots (date, createdAt) VALUES (?, ?)").run(date, createdAt);
|
|
19
|
+
const snapshotId = Number(snapshotInfo.lastInsertRowid);
|
|
20
|
+
const lists = loadLists();
|
|
21
|
+
lists.forEach((list, listOrderIndex) => {
|
|
22
|
+
const listInfo = db
|
|
23
|
+
.prepare("INSERT INTO snapshot_lists (snapshotId, name, createdAt, orderIndex) VALUES (?, ?, ?, ?)")
|
|
24
|
+
.run(snapshotId, list.name, list.createdAt, listOrderIndex);
|
|
25
|
+
const snapshotListId = Number(listInfo.lastInsertRowid);
|
|
26
|
+
const originalToSnapshotTaskId = new Map();
|
|
27
|
+
loadTasks(list.id).forEach((task, taskOrderIndex) => {
|
|
28
|
+
const parentSnapshotTaskId = task.parentId === null ? null : (originalToSnapshotTaskId.get(task.parentId) ?? null);
|
|
29
|
+
const taskInfo = db
|
|
30
|
+
.prepare(`INSERT INTO snapshot_tasks
|
|
31
|
+
(snapshotListId, text, done, priority, parentSnapshotTaskId, createdAt, orderIndex)
|
|
32
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
|
33
|
+
.run(snapshotListId, task.text, task.done ? 1 : 0, task.priority ? 1 : 0, parentSnapshotTaskId, task.createdAt, taskOrderIndex);
|
|
34
|
+
originalToSnapshotTaskId.set(task.id, Number(taskInfo.lastInsertRowid));
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
export function captureSnapshotIfNeeded(settings) {
|
|
39
|
+
if (!settings.dailySnapshotsEnabled)
|
|
40
|
+
return;
|
|
41
|
+
const today = localDateString(new Date());
|
|
42
|
+
const existing = getDb().prepare("SELECT id FROM snapshots WHERE date = ?").get(today);
|
|
43
|
+
if (existing)
|
|
44
|
+
return;
|
|
45
|
+
captureSnapshot(today);
|
|
46
|
+
}
|
|
47
|
+
export function pruneSnapshots(retention) {
|
|
48
|
+
if (retention === "forever")
|
|
49
|
+
return;
|
|
50
|
+
const cutoff = localDateString(new Date(Date.now() - RETENTION_DAYS[retention] * 24 * 60 * 60 * 1000));
|
|
51
|
+
const db = getDb();
|
|
52
|
+
db.prepare(`DELETE FROM snapshot_tasks WHERE snapshotListId IN (
|
|
53
|
+
SELECT id FROM snapshot_lists WHERE snapshotId IN (SELECT id FROM snapshots WHERE date < ?)
|
|
54
|
+
)`).run(cutoff);
|
|
55
|
+
db.prepare("DELETE FROM snapshot_lists WHERE snapshotId IN (SELECT id FROM snapshots WHERE date < ?)").run(cutoff);
|
|
56
|
+
db.prepare("DELETE FROM snapshots WHERE date < ?").run(cutoff);
|
|
57
|
+
}
|
|
58
|
+
export function runStartupSnapshotMaintenance(settings) {
|
|
59
|
+
pruneSnapshots(settings.dailySnapshotRetention);
|
|
60
|
+
captureSnapshotIfNeeded(settings);
|
|
61
|
+
}
|
|
62
|
+
export function listSnapshots() {
|
|
63
|
+
const rows = getDb().prepare("SELECT id, date FROM snapshots ORDER BY date DESC").all();
|
|
64
|
+
return rows.map((row) => ({ id: row.id, date: row.date }));
|
|
65
|
+
}
|
|
66
|
+
export function loadSnapshotDetail(id) {
|
|
67
|
+
const db = getDb();
|
|
68
|
+
const snapshot = db.prepare("SELECT id, date FROM snapshots WHERE id = ?").get(id);
|
|
69
|
+
if (!snapshot)
|
|
70
|
+
return undefined;
|
|
71
|
+
const listRows = db
|
|
72
|
+
.prepare("SELECT id, name FROM snapshot_lists WHERE snapshotId = ? ORDER BY orderIndex")
|
|
73
|
+
.all(id);
|
|
74
|
+
const lists = listRows.map((listRow) => {
|
|
75
|
+
const taskRows = db
|
|
76
|
+
.prepare(`SELECT id, snapshotListId, text, done, priority, parentSnapshotTaskId, createdAt
|
|
77
|
+
FROM snapshot_tasks WHERE snapshotListId = ? ORDER BY orderIndex`)
|
|
78
|
+
.all(listRow.id);
|
|
79
|
+
const tasks = taskRows.map((row) => ({
|
|
80
|
+
id: row.id,
|
|
81
|
+
text: row.text,
|
|
82
|
+
done: !!row.done,
|
|
83
|
+
parentId: row.parentSnapshotTaskId,
|
|
84
|
+
priority: !!row.priority,
|
|
85
|
+
listId: row.snapshotListId,
|
|
86
|
+
createdAt: row.createdAt,
|
|
87
|
+
}));
|
|
88
|
+
return { name: listRow.name, tasks };
|
|
89
|
+
});
|
|
90
|
+
return { id: snapshot.id, date: snapshot.date, lists };
|
|
91
|
+
}
|
|
92
|
+
export function deleteSnapshot(id) {
|
|
93
|
+
const db = getDb();
|
|
94
|
+
db.prepare(`DELETE FROM snapshot_tasks WHERE snapshotListId IN (SELECT id FROM snapshot_lists WHERE snapshotId = ?)`).run(id);
|
|
95
|
+
db.prepare("DELETE FROM snapshot_lists WHERE snapshotId = ?").run(id);
|
|
96
|
+
db.prepare("DELETE FROM snapshots WHERE id = ?").run(id);
|
|
97
|
+
}
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as os from "os";
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
let db;
|
|
6
|
+
let dbPath;
|
|
7
|
+
function getStoreDir() {
|
|
8
|
+
return path.join(os.homedir(), ".taskfile");
|
|
9
|
+
}
|
|
10
|
+
function getStoreFile() {
|
|
11
|
+
return path.join(getStoreDir(), "tasks.db");
|
|
12
|
+
}
|
|
13
|
+
function rowToTask(row) {
|
|
14
|
+
return {
|
|
15
|
+
id: row.id,
|
|
16
|
+
text: row.text,
|
|
17
|
+
done: !!row.done,
|
|
18
|
+
parentId: row.parentId,
|
|
19
|
+
priority: !!row.priority,
|
|
20
|
+
listId: row.listId,
|
|
21
|
+
createdAt: row.createdAt,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function rowToList(row) {
|
|
25
|
+
return { id: row.id, name: row.name, createdAt: row.createdAt };
|
|
26
|
+
}
|
|
27
|
+
function ensureSchema(database) {
|
|
28
|
+
database.exec(`
|
|
29
|
+
CREATE TABLE IF NOT EXISTS lists (
|
|
30
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
31
|
+
name TEXT NOT NULL,
|
|
32
|
+
createdAt TEXT NOT NULL
|
|
33
|
+
)
|
|
34
|
+
`);
|
|
35
|
+
database.exec(`
|
|
36
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
37
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
38
|
+
text TEXT NOT NULL,
|
|
39
|
+
done INTEGER NOT NULL DEFAULT 0,
|
|
40
|
+
createdAt TEXT NOT NULL,
|
|
41
|
+
parentId INTEGER REFERENCES tasks(id),
|
|
42
|
+
priority INTEGER NOT NULL DEFAULT 0,
|
|
43
|
+
listId INTEGER REFERENCES lists(id)
|
|
44
|
+
)
|
|
45
|
+
`);
|
|
46
|
+
database.exec(`
|
|
47
|
+
CREATE TABLE IF NOT EXISTS snapshots (
|
|
48
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
49
|
+
date TEXT NOT NULL UNIQUE,
|
|
50
|
+
createdAt TEXT NOT NULL
|
|
51
|
+
)
|
|
52
|
+
`);
|
|
53
|
+
database.exec(`
|
|
54
|
+
CREATE TABLE IF NOT EXISTS snapshot_lists (
|
|
55
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
56
|
+
snapshotId INTEGER NOT NULL REFERENCES snapshots(id),
|
|
57
|
+
name TEXT NOT NULL,
|
|
58
|
+
createdAt TEXT NOT NULL,
|
|
59
|
+
orderIndex INTEGER NOT NULL
|
|
60
|
+
)
|
|
61
|
+
`);
|
|
62
|
+
database.exec(`
|
|
63
|
+
CREATE TABLE IF NOT EXISTS snapshot_tasks (
|
|
64
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
65
|
+
snapshotListId INTEGER NOT NULL REFERENCES snapshot_lists(id),
|
|
66
|
+
text TEXT NOT NULL,
|
|
67
|
+
done INTEGER NOT NULL DEFAULT 0,
|
|
68
|
+
priority INTEGER NOT NULL DEFAULT 0,
|
|
69
|
+
parentSnapshotTaskId INTEGER REFERENCES snapshot_tasks(id),
|
|
70
|
+
createdAt TEXT NOT NULL,
|
|
71
|
+
orderIndex INTEGER NOT NULL
|
|
72
|
+
)
|
|
73
|
+
`);
|
|
74
|
+
const columns = database.prepare("PRAGMA table_info(tasks)").all();
|
|
75
|
+
const hasParentId = columns.some((column) => column.name === "parentId");
|
|
76
|
+
if (!hasParentId) {
|
|
77
|
+
database.exec("ALTER TABLE tasks ADD COLUMN parentId INTEGER REFERENCES tasks(id)");
|
|
78
|
+
}
|
|
79
|
+
const hasPriority = columns.some((column) => column.name === "priority");
|
|
80
|
+
if (!hasPriority) {
|
|
81
|
+
database.exec("ALTER TABLE tasks ADD COLUMN priority INTEGER NOT NULL DEFAULT 0");
|
|
82
|
+
}
|
|
83
|
+
const hasListId = columns.some((column) => column.name === "listId");
|
|
84
|
+
if (!hasListId) {
|
|
85
|
+
database.exec("ALTER TABLE tasks ADD COLUMN listId INTEGER REFERENCES lists(id)");
|
|
86
|
+
}
|
|
87
|
+
const { count } = database.prepare("SELECT COUNT(*) AS count FROM lists").get();
|
|
88
|
+
let defaultListId;
|
|
89
|
+
if (count === 0) {
|
|
90
|
+
const createdAt = new Date().toISOString();
|
|
91
|
+
const info = database.prepare("INSERT INTO lists (name, createdAt) VALUES (?, ?)").run("Todos", createdAt);
|
|
92
|
+
defaultListId = Number(info.lastInsertRowid);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
const first = database.prepare("SELECT id FROM lists ORDER BY id LIMIT 1").get();
|
|
96
|
+
defaultListId = first.id;
|
|
97
|
+
}
|
|
98
|
+
database.prepare("UPDATE tasks SET listId = ? WHERE listId IS NULL").run(defaultListId);
|
|
99
|
+
}
|
|
100
|
+
export function getDb() {
|
|
101
|
+
const currentPath = getStoreFile();
|
|
102
|
+
if (db && dbPath === currentPath) {
|
|
103
|
+
return db;
|
|
104
|
+
}
|
|
105
|
+
if (db) {
|
|
106
|
+
db.close();
|
|
107
|
+
}
|
|
108
|
+
const storeDir = getStoreDir();
|
|
109
|
+
if (!fs.existsSync(storeDir)) {
|
|
110
|
+
fs.mkdirSync(storeDir, { recursive: true });
|
|
111
|
+
}
|
|
112
|
+
db = new DatabaseSync(currentPath);
|
|
113
|
+
ensureSchema(db);
|
|
114
|
+
dbPath = currentPath;
|
|
115
|
+
return db;
|
|
116
|
+
}
|
|
117
|
+
export function loadTasks(listId) {
|
|
118
|
+
const rows = getDb()
|
|
119
|
+
.prepare(`SELECT id, text, done, parentId, priority, listId, createdAt FROM tasks
|
|
120
|
+
WHERE listId = ?
|
|
121
|
+
ORDER BY
|
|
122
|
+
(SELECT priority FROM tasks p WHERE p.id = COALESCE(tasks.parentId, tasks.id)) DESC,
|
|
123
|
+
COALESCE(parentId, id),
|
|
124
|
+
parentId IS NOT NULL,
|
|
125
|
+
priority DESC,
|
|
126
|
+
id`)
|
|
127
|
+
.all(listId);
|
|
128
|
+
return rows.map(rowToTask);
|
|
129
|
+
}
|
|
130
|
+
export function addTask(listId, text, parentId = null) {
|
|
131
|
+
const createdAt = new Date().toISOString();
|
|
132
|
+
const info = getDb()
|
|
133
|
+
.prepare("INSERT INTO tasks (text, done, createdAt, parentId, listId) VALUES (?, 0, ?, ?, ?)")
|
|
134
|
+
.run(text, createdAt, parentId, listId);
|
|
135
|
+
return { id: Number(info.lastInsertRowid), text, done: false, parentId, priority: false, listId, createdAt };
|
|
136
|
+
}
|
|
137
|
+
export function loadLists() {
|
|
138
|
+
const rows = getDb().prepare("SELECT id, name, createdAt FROM lists ORDER BY id").all();
|
|
139
|
+
return rows.map(rowToList);
|
|
140
|
+
}
|
|
141
|
+
export function createList(name) {
|
|
142
|
+
const trimmed = name.trim();
|
|
143
|
+
const createdAt = new Date().toISOString();
|
|
144
|
+
const info = getDb().prepare("INSERT INTO lists (name, createdAt) VALUES (?, ?)").run(trimmed, createdAt);
|
|
145
|
+
return { id: Number(info.lastInsertRowid), name: trimmed, createdAt };
|
|
146
|
+
}
|
|
147
|
+
export function renameList(id, name) {
|
|
148
|
+
const row = getDb().prepare("SELECT id, name, createdAt FROM lists WHERE id = ?").get(id);
|
|
149
|
+
if (!row) {
|
|
150
|
+
return { status: "not-found" };
|
|
151
|
+
}
|
|
152
|
+
const trimmed = name.trim();
|
|
153
|
+
if (trimmed.length === 0) {
|
|
154
|
+
return { status: "blocked", reason: "List name cannot be empty." };
|
|
155
|
+
}
|
|
156
|
+
getDb().prepare("UPDATE lists SET name = ? WHERE id = ?").run(trimmed, id);
|
|
157
|
+
return { status: "ok", list: { ...rowToList(row), name: trimmed } };
|
|
158
|
+
}
|
|
159
|
+
export function getDefaultListId() {
|
|
160
|
+
return loadLists()[0].id;
|
|
161
|
+
}
|
|
162
|
+
export function removeTask(id) {
|
|
163
|
+
const row = getDb()
|
|
164
|
+
.prepare("SELECT id, text, done, parentId, priority, listId, createdAt FROM tasks WHERE id = ?")
|
|
165
|
+
.get(id);
|
|
166
|
+
if (!row) {
|
|
167
|
+
return { status: "not-found" };
|
|
168
|
+
}
|
|
169
|
+
if (row.parentId === null) {
|
|
170
|
+
const { count } = getDb()
|
|
171
|
+
.prepare("SELECT COUNT(*) AS count FROM tasks WHERE parentId = ?")
|
|
172
|
+
.get(id);
|
|
173
|
+
if (count > 0) {
|
|
174
|
+
return { status: "blocked", reason: "Delete all sub-items first." };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
getDb().prepare("DELETE FROM tasks WHERE id = ?").run(id);
|
|
178
|
+
return { status: "ok", task: rowToTask(row) };
|
|
179
|
+
}
|
|
180
|
+
export function toggleTask(id) {
|
|
181
|
+
const row = getDb()
|
|
182
|
+
.prepare("SELECT id, text, done, parentId, priority, listId, createdAt FROM tasks WHERE id = ?")
|
|
183
|
+
.get(id);
|
|
184
|
+
if (!row) {
|
|
185
|
+
return { status: "not-found" };
|
|
186
|
+
}
|
|
187
|
+
const nextDone = !row.done;
|
|
188
|
+
if (nextDone && row.parentId === null) {
|
|
189
|
+
const { count } = getDb()
|
|
190
|
+
.prepare("SELECT COUNT(*) AS count FROM tasks WHERE parentId = ? AND done = 0")
|
|
191
|
+
.get(id);
|
|
192
|
+
if (count > 0) {
|
|
193
|
+
return { status: "blocked", reason: "Complete all sub-items before completing this item." };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
getDb()
|
|
197
|
+
.prepare("UPDATE tasks SET done = ? WHERE id = ?")
|
|
198
|
+
.run(nextDone ? 1 : 0, id);
|
|
199
|
+
if (!nextDone && row.parentId !== null) {
|
|
200
|
+
getDb().prepare("UPDATE tasks SET done = 0 WHERE id = ? AND done = 1").run(row.parentId);
|
|
201
|
+
}
|
|
202
|
+
const updated = getDb()
|
|
203
|
+
.prepare("SELECT id, text, done, parentId, priority, listId, createdAt FROM tasks WHERE id = ?")
|
|
204
|
+
.get(id);
|
|
205
|
+
return { status: "ok", task: rowToTask(updated) };
|
|
206
|
+
}
|
|
207
|
+
export function updateTask(id, text) {
|
|
208
|
+
const row = getDb()
|
|
209
|
+
.prepare("SELECT id, text, done, parentId, priority, listId, createdAt FROM tasks WHERE id = ?")
|
|
210
|
+
.get(id);
|
|
211
|
+
if (!row) {
|
|
212
|
+
return { status: "not-found" };
|
|
213
|
+
}
|
|
214
|
+
const trimmed = text.trim();
|
|
215
|
+
if (trimmed.length === 0) {
|
|
216
|
+
return { status: "blocked", reason: "Task text cannot be empty." };
|
|
217
|
+
}
|
|
218
|
+
getDb().prepare("UPDATE tasks SET text = ? WHERE id = ?").run(trimmed, id);
|
|
219
|
+
return { status: "ok", task: { ...rowToTask(row), text: trimmed } };
|
|
220
|
+
}
|
|
221
|
+
// Counts all tasks flat, ignoring parentId (main items and sub-items count equally).
|
|
222
|
+
export function computeProgress(tasks) {
|
|
223
|
+
const total = tasks.length;
|
|
224
|
+
const done = tasks.filter((task) => task.done).length;
|
|
225
|
+
const percent = total === 0 ? 0 : Math.round((done / total) * 100);
|
|
226
|
+
return { done, total, percent };
|
|
227
|
+
}
|
|
228
|
+
export function hasSubItems(id) {
|
|
229
|
+
const { count } = getDb()
|
|
230
|
+
.prepare("SELECT COUNT(*) AS count FROM tasks WHERE parentId = ?")
|
|
231
|
+
.get(id);
|
|
232
|
+
return count > 0;
|
|
233
|
+
}
|
|
234
|
+
export function setPriority(id, priority) {
|
|
235
|
+
const row = getDb()
|
|
236
|
+
.prepare("SELECT id, text, done, parentId, priority, listId, createdAt FROM tasks WHERE id = ?")
|
|
237
|
+
.get(id);
|
|
238
|
+
if (!row) {
|
|
239
|
+
return { status: "not-found" };
|
|
240
|
+
}
|
|
241
|
+
getDb()
|
|
242
|
+
.prepare("UPDATE tasks SET priority = ? WHERE id = ?")
|
|
243
|
+
.run(priority ? 1 : 0, id);
|
|
244
|
+
if (row.parentId === null) {
|
|
245
|
+
getDb()
|
|
246
|
+
.prepare("UPDATE tasks SET priority = ? WHERE parentId = ?")
|
|
247
|
+
.run(priority ? 1 : 0, id);
|
|
248
|
+
}
|
|
249
|
+
const updated = getDb()
|
|
250
|
+
.prepare("SELECT id, text, done, parentId, priority, listId, createdAt FROM tasks WHERE id = ?")
|
|
251
|
+
.get(id);
|
|
252
|
+
return { status: "ok", task: rowToTask(updated) };
|
|
253
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { Box, Text } from "ink";
|
|
4
|
+
import TextInput from "ink-text-input";
|
|
5
|
+
export function AddTaskInput({ onSubmit, label = "New task: ", initialValue = "", }) {
|
|
6
|
+
const [value, setValue] = useState(initialValue);
|
|
7
|
+
return (_jsxs(Box, { children: [_jsx(Text, { children: label }), _jsx(TextInput, { value: value, onChange: setValue, onSubmit: (text) => {
|
|
8
|
+
const trimmed = text.trim();
|
|
9
|
+
if (trimmed.length > 0) {
|
|
10
|
+
onSubmit(trimmed);
|
|
11
|
+
}
|
|
12
|
+
} })] }));
|
|
13
|
+
}
|