Choose a file from the tree.
import { Argument, Command, InvalidArgumentError, Option } from "commander";
import { version } from "../package.json";
import * as todo from "./todo.ts";
const program = new Command();
program.name("todo").description("a small todo list of your own").version(version);
program.addHelpText("after", "\nSet TODO_STORAGE=sqlite to keep the todos in a database instead of a folder.");
program
.command("add")
.description("write down a new todo")
.argument("<title...>", "what you have to do")
.option(
"--needs <ids>",
"ids of the todos this one waits for, separated by commas",
(value: string, gathered: string[] = []) => gathered.concat(value.split(",").filter((id) => id !== "")),
)
.action((title: string[], options: { needs?: string[] }) => {
const added = todo.add(title.join(" "), options.needs ?? []);
console.log(`added ${added.id} ${added.title}`);
});
program
.command("list")
.description("show the todos")
.addArgument(new Argument("[filter]", "which todos to show").choices(todo.filters).default("all"))
.addOption(new Option("--sort <order>", "the order to show them in").choices(todo.orders).default("created"))
.option("--limit <number>", "show at most this many", (value: string) => {
const limit = Number(value);
if (Number.isInteger(limit) && limit >= 1) return limit;
throw new InvalidArgumentError("it has to be a whole number, 1 or more.");
})
.action((filter: todo.Filter, options: { sort: todo.Order; limit?: number }) => {
list(filter, options.sort, options.limit);
});
program
.command("done")
.description("mark a todo as finished")
.argument("<id>", "the todo you have finished")
.action((id: string) => {
const finished = todo.done(id);
console.log(`done ${finished.id} ${finished.title}`);
});
program
.command("remove")
.description("throw a todo away")
.argument("<id>", "the todo you do not want any more")
.action((id: string) => {
const removed = todo.remove(id);
console.log(`removed ${removed.id}`);
});
try {
program.parse();
} catch (error) {
console.error((error as Error).message);
process.exitCode = 1;
}
function list(filter: todo.Filter, order: todo.Order, limit?: number) {
const shown = todo.list(filter, order, limit);
if (shown.length === 0) {
console.log("nothing to do");
return;
}
const rows = [["id", "status", "title", "needs"]];
for (const item of shown) {
const waiting = todo.waitingFor(item);
rows.push([item.id, todo.statusOf(item, waiting), item.title, waiting.map((other) => other.id).join(" ")]);
}
table(rows);
}
function table(rows: string[][]) {
const widths = rows[0].map((_, column) => Math.max(...rows.map((row) => row[column].length)));
for (const row of rows) {
console.log(
row
.map((cell, column) => cell.padEnd(widths[column]))
.join(" ")
.trimEnd(),
);
}
}
import { Database } from "bun:sqlite";
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, rmSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
export type Todo = {
id: string;
title: string;
done: boolean;
needs: string[];
createdAt: string;
};
export type Status = "done" | "ready" | "waiting";
export type Filter = "all" | "open" | "done" | "ready";
export type Order = "created" | "title";
export const filters: Filter[] = ["all", "open", "done", "ready"];
export const orders: Order[] = ["created", "title"];
type Row = { id: string; title: string; done: number; createdAt: string };
const dir = join(homedir(), ".todos");
const path = join(homedir(), ".todos.db");
let database: Database | undefined;
export function add(title: string, needs: string[]): Todo {
if (title.trim() === "") throw new Error("a todo needs a title");
for (const id of needs) find(id);
const todo: Todo = {
id: Date.now().toString(36),
title: title.trim(),
done: false,
needs,
createdAt: new Date().toISOString(),
};
if (sqlite()) {
const db = open();
db.run("insert into todos (id, title, done, createdAt) values (?, ?, 0, ?)", [todo.id, todo.title, todo.createdAt]);
for (const id of todo.needs) db.run("insert into needs (todo, waitsFor) values (?, ?)", [todo.id, id]);
} else {
write(todo);
}
return todo;
}
export function done(id: string): Todo {
const todo = find(id);
const waiting = waitingFor(todo);
if (waiting.length > 0) {
throw new Error(`${todo.id} still waits for ${waiting.map((other) => other.id).join(" ")}`);
}
todo.done = true;
if (sqlite()) {
open().run("update todos set done = 1 where id = ?", [todo.id]);
} else {
write(todo);
}
return todo;
}
export function remove(id: string): Todo {
const todo = find(id);
if (sqlite()) {
const db = open();
db.run("delete from todos where id = ?", [todo.id]);
db.run("delete from needs where todo = ?", [todo.id]);
} else {
rmSync(join(folder(), `${todo.id}.json`));
}
return todo;
}
export function list(filter: Filter, order: Order, limit?: number): Todo[] {
if (sqlite()) {
const where = {
all: "",
open: "where done = 0",
done: "where done = 1",
ready: "where done = 0 and id not in (select todo from needs join todos on id = waitsFor where done = 0)",
}[filter];
const by = order === "title" ? "title collate nocase" : "createdAt";
const rows = open().query(`select * from todos ${where} order by ${by} limit ?`).all(limit ?? -1) as Row[];
return rows.map(rebuild);
}
const todos = readAll();
const shown = todos.filter((todo) => {
if (filter === "open") return !todo.done;
if (filter === "done") return todo.done;
if (filter === "ready") return !todo.done && !blocked(todo, todos);
return true;
});
if (order === "title") shown.sort((a, b) => a.title.localeCompare(b.title));
else shown.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
return limit === undefined ? shown : shown.slice(0, limit);
}
export function waitingFor(todo: Todo): Todo[] {
const waiting: Todo[] = [];
for (const id of todo.needs) {
const other = look(id);
if (other !== undefined && !other.done) waiting.push(other);
}
return waiting;
}
export function statusOf(todo: Todo, waiting: Todo[]): Status {
if (todo.done) return "done";
if (waiting.length === 0) return "ready";
return "waiting";
}
function sqlite(): boolean {
return process.env.TODO_STORAGE === "sqlite";
}
function find(id: string): Todo {
const todo = look(id);
if (todo === undefined) throw new Error(`there is no todo with id ${id}`);
return todo;
}
function look(id: string): Todo | undefined {
if (sqlite()) {
const row = open().query("select * from todos where id = ?").get(id) as Row | null;
return row === null ? undefined : rebuild(row);
}
const file = join(folder(), `${id}.json`);
return existsSync(file) ? read(file) : undefined;
}
function blocked(todo: Todo, todos: Todo[]): boolean {
for (const id of todo.needs) {
const other = todos.find((candidate) => candidate.id === id);
if (other !== undefined && !other.done) return true;
}
return false;
}
function open(): Database {
if (database === undefined) {
database = new Database(path);
database.run("create table if not exists todos (id text primary key, title text, done integer, createdAt text)");
database.run("create table if not exists needs (todo text, waitsFor text)");
}
return database;
}
function rebuild(row: Row): Todo {
const needs = open().query("select waitsFor from needs where todo = ?").all(row.id) as { waitsFor: string }[];
return {
id: row.id,
title: row.title,
done: row.done === 1,
needs: needs.map((need) => need.waitsFor),
createdAt: row.createdAt,
};
}
function folder(): string {
if (!existsSync(dir)) mkdirSync(dir);
return dir;
}
function readAll(): Todo[] {
return readdirSync(folder())
.filter((name) => name.endsWith(".json"))
.map((name) => read(join(folder(), name)));
}
function read(file: string): Todo {
const todo = JSON.parse(readFileSync(file, "utf8"));
return { ...todo, needs: todo.needs ?? [] };
}
function write(todo: Todo) {
writeFileSync(join(folder(), `${todo.id}.json`), JSON.stringify(todo, null, 2) + "\n");
}
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "todo",
"dependencies": {
"commander": "^15.0.0",
},
"devDependencies": {
"@types/bun": "^1.3.14",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="],
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
}
}
{
"name": "todo",
"version": "0.1.0",
"description": "A small todo list that lives in a folder",
"type": "module",
"scripts": {
"start": "bun run src/main.ts",
"build": "bun build --compile --minify --outfile dist/todo src/main.ts"
},
"dependencies": {
"commander": "^15.0.0"
},
"devDependencies": {
"@types/bun": "^1.3.14"
}
}
{
"compilerOptions": {
"target": "ESNext",
"module": "Preserve",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"types": ["bun"]
}
}