#!/bin/sh
# todo - aesthetic terminal todo list backed by SQLite
# Inspired by todo.txt-cli. Data lives in ~/.config/scripts/scripts.db
# Usage: todo [command] [args...]
DB="${XDG_CONFIG_HOME:-$HOME/.config}/scripts/scripts.db"
# --- colors ---
C_RESET='\033[0m'
C_BOLD='\033[1m'
C_DIM='\033[2m'
C_ID='\033[33m' # yellow
C_PRI_A='\033[1;31m' # bold red
C_PRI_B='\033[1;33m' # bold yellow
C_PRI_C='\033[1;34m' # bold blue
C_PRI='\033[1;36m' # bold cyan (D+)
C_DONE='\033[9;2m' # strikethrough + dim
C_LIST='\033[35m' # magenta
C_DATE='\033[2m' # dim
C_COUNT='\033[1;32m' # bold green
C_HEADER='\033[1;4m' # bold underline
# --- init ---
mkdir -p "$(dirname "$DB")"
sqlite3 "$DB" "
CREATE TABLE IF NOT EXISTS todo_lists (
name TEXT PRIMARY KEY,
created_at TEXT DEFAULT (datetime('now', 'localtime'))
);
INSERT OR IGNORE INTO todo_lists (name) VALUES ('inbox');
CREATE TABLE IF NOT EXISTS todo (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task TEXT NOT NULL,
priority TEXT DEFAULT '',
list TEXT DEFAULT 'inbox',
done INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now', 'localtime')),
done_at TEXT DEFAULT ''
);"
# --- helpers ---
die() { printf '%s\n' "$1" >&2; exit 1; }
esc() { printf '%s' "$1" | sed "s/'/''/g"; }
pri_color() {
case "$1" in
A) printf '%b' "$C_PRI_A" ;;
B) printf '%b' "$C_PRI_B" ;;
C) printf '%b' "$C_PRI_C" ;;
?*) printf '%b' "$C_PRI" ;;
*) ;;
esac
}
fmt_task() {
id="$1"; task="$2"; pri="$3"; list="$4"; done="$5"; created="$6"
if [ "$done" = "1" ]; then
printf "${C_DIM}%4s ${C_DONE}%s${C_RESET}\n" "$id" "$task"
return
fi
pri_str=""
if [ -n "$pri" ]; then
pri_str="$(pri_color "$pri")(${pri})${C_RESET} "
fi
list_str=""
if [ "$list" != "inbox" ]; then
list_str=" ${C_LIST}@${list}${C_RESET}"
fi
printf "${C_ID}%4s${C_RESET} ${pri_str}%s${list_str}\n" "$id" "$task"
}
# --- commands ---
cmd_add() {
[ -z "$1" ] && die "Usage: todo add \"task text\" [-p PRI] [-l LIST]"
task=""; pri=""; list="inbox"
while [ $# -gt 0 ]; do
case "$1" in
-p) shift; pri=$(printf '%s' "$1" | tr '[:lower:]' '[:upper:]'); shift ;;
-l) shift; list="$1"; shift ;;
*) [ -n "$task" ] && task="$task $1" || task="$1"; shift ;;
esac
done
[ -z "$task" ] && die "No task text provided"
sqlite3 "$DB" "INSERT OR IGNORE INTO todo_lists (name) VALUES ('$(esc "$list")');"
new_id=$(sqlite3 "$DB" "INSERT INTO todo (task, priority, list) VALUES ('$(esc "$task")', '$(esc "$pri")', '$(esc "$list")'); SELECT last_insert_rowid();")
printf "${C_COUNT}+${C_RESET} %s ${C_DIM}#%s${C_RESET}\n" "$task" "$new_id"
}
cmd_list() {
filter_list=""; search=""; show_done=0; show_all=0
while [ $# -gt 0 ]; do
case "$1" in
-l) shift; filter_list="$1"; shift ;;
-a) show_all=1; shift ;;
--done) show_done=1; shift ;;
*) search="$1"; shift ;;
esac
done
where="1=1"
if [ "$show_all" = "0" ] && [ "$show_done" = "0" ]; then
where="done = 0"
elif [ "$show_done" = "1" ]; then
where="done = 1"
fi
[ -n "$filter_list" ] && where="$where AND list = '$(esc "$filter_list")'"
[ -n "$search" ] && where="$where AND task LIKE '%$(esc "$search")%'"
order="CASE WHEN priority = '' THEN 1 ELSE 0 END, priority, id"
result=$(sqlite3 -separator '|' "$DB" "SELECT id, task, priority, list, done, created_at FROM todo WHERE $where ORDER BY $order;")
if [ -z "$result" ]; then
printf "${C_DIM}No tasks found${C_RESET}\n"
return
fi
printf '%s\n' "$result" | while IFS='|' read -r id task pri list done created; do
fmt_task "$id" "$task" "$pri" "$list" "$done" "$created"
done
total=$(sqlite3 "$DB" "SELECT COUNT(*) FROM todo WHERE done = 0;")
done_count=$(sqlite3 "$DB" "SELECT COUNT(*) FROM todo WHERE done = 1;")
printf "\n${C_DIM}─── ${C_COUNT}%s${C_DIM} open · %s done ───${C_RESET}\n" "$total" "$done_count"
}
cmd_do() {
[ -z "$1" ] && die "Usage: todo do ID [ID ...]"
for id in "$@"; do
task=$(sqlite3 "$DB" "SELECT task FROM todo WHERE id = $id AND done = 0;")
[ -z "$task" ] && { printf "${C_DIM}#%s not found or already done${C_RESET}\n" "$id"; continue; }
sqlite3 "$DB" "UPDATE todo SET done = 1, done_at = datetime('now', 'localtime') WHERE id = $id;"
printf "${C_DONE}✓${C_RESET} ${C_DIM}#%s${C_RESET} %s\n" "$id" "$task"
done
}
cmd_undo() {
[ -z "$1" ] && die "Usage: todo undo ID"
task=$(sqlite3 "$DB" "SELECT task FROM todo WHERE id = $1 AND done = 1;")
[ -z "$task" ] && die "#$1 not found or not done"
sqlite3 "$DB" "UPDATE todo SET done = 0, done_at = '' WHERE id = $1;"
printf "${C_COUNT}↩${C_RESET} ${C_DIM}#%s${C_RESET} %s\n" "$1" "$task"
}
cmd_rm() {
[ -z "$1" ] && die "Usage: todo rm ID [ID ...]"
for id in "$@"; do
task=$(sqlite3 "$DB" "SELECT task FROM todo WHERE id = $id;")
[ -z "$task" ] && { printf "${C_DIM}#%s not found${C_RESET}\n" "$id"; continue; }
sqlite3 "$DB" "DELETE FROM todo WHERE id = $id;"
printf "${C_DIM}× #%s %s${C_RESET}\n" "$id" "$task"
done
}
cmd_pri() {
[ -z "$1" ] || [ -z "$2" ] && die "Usage: todo pri ID PRIORITY"
id="$1"
pri=$(printf '%s' "$2" | tr '[:lower:]' '[:upper:]')
task=$(sqlite3 "$DB" "SELECT task FROM todo WHERE id = $id;")
[ -z "$task" ] && die "#$id not found"
sqlite3 "$DB" "UPDATE todo SET priority = '$(esc "$pri")' WHERE id = $id;"
printf "$(pri_color "$pri")(%s)${C_RESET} ${C_DIM}#%s${C_RESET} %s\n" "$pri" "$id" "$task"
}
cmd_depri() {
[ -z "$1" ] && die "Usage: todo depri ID"
sqlite3 "$DB" "UPDATE todo SET priority = '' WHERE id = $1;"
printf "${C_DIM}Removed priority from #%s${C_RESET}\n" "$1"
}
cmd_edit() {
[ -z "$1" ] || [ -z "$2" ] && die "Usage: todo edit ID \"new task text\""
id="$1"; shift
new_task="$*"
sqlite3 "$DB" "UPDATE todo SET task = '$(esc "$new_task")' WHERE id = $id;"
printf "${C_DIM}Edited #%s →${C_RESET} %s\n" "$id" "$new_task"
}
cmd_mv() {
[ -z "$1" ] || [ -z "$2" ] && die "Usage: todo mv ID LIST"
id="$1"; list="$2"
task=$(sqlite3 "$DB" "SELECT task FROM todo WHERE id = $id;")
[ -z "$task" ] && die "#$id not found"
sqlite3 "$DB" "INSERT OR IGNORE INTO todo_lists (name) VALUES ('$(esc "$list")'); UPDATE todo SET list = '$(esc "$list")' WHERE id = $id;"
printf "${C_DIM}#%s →${C_RESET} ${C_LIST}@%s${C_RESET} %s\n" "$id" "$list" "$task"
}
cmd_lists() {
printf "${C_HEADER}Lists${C_RESET}\n"
sqlite3 -separator '|' "$DB" "
SELECT l.name,
COALESCE(SUM(CASE WHEN t.done = 0 THEN 1 ELSE 0 END), 0) as open,
COALESCE(SUM(CASE WHEN t.done = 1 THEN 1 ELSE 0 END), 0) as done
FROM todo_lists l
LEFT JOIN todo t ON t.list = l.name
GROUP BY l.name ORDER BY open DESC, l.name;
" | while IFS='|' read -r list open done_n; do
printf " ${C_LIST}@%-15s${C_RESET} ${C_COUNT}%3s${C_RESET} open ${C_DIM}%3s done${C_RESET}\n" "$list" "$open" "$done_n"
done
}
cmd_mklist() {
[ -z "$1" ] && die "Usage: todo mklist NAME"
name="$1"
exists=$(sqlite3 "$DB" "SELECT COUNT(*) FROM todo_lists WHERE name = '$(esc "$name")';")
[ "$exists" != "0" ] && die "@$name already exists"
sqlite3 "$DB" "INSERT INTO todo_lists (name) VALUES ('$(esc "$name")');"
printf "${C_COUNT}+${C_RESET} ${C_LIST}@%s${C_RESET} created\n" "$name"
}
cmd_rmlist() {
[ -z "$1" ] && die "Usage: todo rmlist NAME"
name="$1"
[ "$name" = "inbox" ] && die "Cannot delete @inbox"
exists=$(sqlite3 "$DB" "SELECT COUNT(*) FROM todo_lists WHERE name = '$(esc "$name")';")
[ "$exists" = "0" ] && die "@$name not found"
count=$(sqlite3 "$DB" "SELECT COUNT(*) FROM todo WHERE list = '$(esc "$name")' AND done = 0;")
if [ "$count" != "0" ]; then
printf "${C_DIM}@%s has %s open tasks. Move them to inbox? [y/N]${C_RESET} " "$name" "$count"
read -r ans
case "$ans" in
y|Y) sqlite3 "$DB" "UPDATE todo SET list = 'inbox' WHERE list = '$(esc "$name")';" ;;
*) die "Aborted" ;;
esac
fi
sqlite3 "$DB" "DELETE FROM todo WHERE list = '$(esc "$name")'; DELETE FROM todo_lists WHERE name = '$(esc "$name")';"
printf "${C_DIM}× @%s deleted${C_RESET}\n" "$name"
}
cmd_renlist() {
[ -z "$1" ] || [ -z "$2" ] && die "Usage: todo renlist OLD NEW"
old="$1"; new="$2"
[ "$old" = "inbox" ] && die "Cannot rename @inbox"
exists=$(sqlite3 "$DB" "SELECT COUNT(*) FROM todo_lists WHERE name = '$(esc "$old")';")
[ "$exists" = "0" ] && die "@$old not found"
taken=$(sqlite3 "$DB" "SELECT COUNT(*) FROM todo_lists WHERE name = '$(esc "$new")';")
[ "$taken" != "0" ] && die "@$new already exists"
sqlite3 "$DB" "
UPDATE todo_lists SET name = '$(esc "$new")' WHERE name = '$(esc "$old")';
UPDATE todo SET list = '$(esc "$new")' WHERE list = '$(esc "$old")';
"
printf "${C_LIST}@%s${C_RESET} ${C_DIM}→${C_RESET} ${C_LIST}@%s${C_RESET}\n" "$old" "$new"
}
cmd_search() {
[ -z "$1" ] && die "Usage: todo search TERM"
cmd_list "$1"
}
cmd_archive() {
n=$(sqlite3 "$DB" "SELECT COUNT(*) FROM todo WHERE done = 1;")
[ "$n" = "0" ] && { printf "${C_DIM}Nothing to archive${C_RESET}\n"; return; }
sqlite3 "$DB" "DELETE FROM todo WHERE done = 1;"
printf "${C_DIM}Archived %s completed tasks${C_RESET}\n" "$n"
}
cmd_count() {
open=$(sqlite3 "$DB" "SELECT COUNT(*) FROM todo WHERE done = 0;")
done_n=$(sqlite3 "$DB" "SELECT COUNT(*) FROM todo WHERE done = 1;")
total=$((open + done_n))
printf "${C_COUNT}%s${C_RESET} open ${C_DIM}%s done %s total${C_RESET}\n" "$open" "$done_n" "$total"
}
# --- dmenu modes ---
cmd_dmenu_add() {
# pick list
lists=$(sqlite3 "$DB" "SELECT name FROM todo_lists ORDER BY name;")
[ -z "$lists" ] && lists="inbox"
list=$(printf '%s\nNew list...' "$lists" | dmenu -i -l 10 -p "List:" || exit 0)
[ -z "$list" ] && exit 0
if [ "$list" = "New list..." ]; then
list=$(printf '' | dmenu -l 1 -p "List name:" || exit 0)
[ -z "$list" ] && exit 0
sqlite3 "$DB" "INSERT OR IGNORE INTO todo_lists (name) VALUES ('$(esc "$list")');"
fi
# pick priority
pri=$(printf 'None\nA\nB\nC\nD' | dmenu -i -l 5 -p "Priority:" || exit 0)
[ -z "$pri" ] && exit 0
[ "$pri" = "None" ] && pri=""
# task text
task=$(printf '' | dmenu -c -l 1 -h 40 -p "Todo:" || exit 0)
[ -z "$task" ] && exit 0
sqlite3 "$DB" "INSERT INTO todo (task, priority, list) VALUES ('$(esc "$task")', '$(esc "$pri")', '$(esc "$list")');"
notify-send "Todo added" "$task → @$list"
}
cmd_dmenu_view() {
# pick a list
lists=$(sqlite3 "$DB" "SELECT list || ' (' || SUM(CASE WHEN done=0 THEN 1 ELSE 0 END) || ')' FROM todo GROUP BY list ORDER BY list;")
[ -z "$lists" ] && { notify-send "Todo" "No lists found"; exit 0; }
selected=$(printf '%s' "$lists" | dmenu -i -l 15 -p "View list:" || exit 0)
[ -z "$selected" ] && exit 0
list=$(printf '%s' "$selected" | sed 's/ ([0-9]*)$//')
tasks=$(sqlite3 -separator '|' "$DB" "SELECT priority, task FROM todo WHERE list = '$(esc "$list")' AND done = 0 ORDER BY CASE WHEN priority = '' THEN 1 ELSE 0 END, priority, id;")
if [ -z "$tasks" ]; then
notify-send "@$list" "No open tasks"
exit 0
fi
# format for dunst
body=""
n=0
printf '%s\n' "$tasks" | while IFS='|' read -r pri task; do
n=$((n + 1))
if [ -n "$pri" ]; then
line="($pri) $task"
else
line=" $task"
fi
if [ -n "$body" ]; then
body="$body
$line"
else
body="$line"
fi
# dunst gets it all at the end
printf '%s\n' "$line"
done | {
body=$(cat)
count=$(printf '%s\n' "$body" | wc -l)
notify-send -t 10000 "@$list [$count]" "$body"
}
}
cmd_dmenu_do() {
tasks=$(sqlite3 -separator ' | ' "$DB" "SELECT id, CASE WHEN priority != '' THEN '(' || priority || ') ' ELSE '' END || task || ' @' || list FROM todo WHERE done = 0 ORDER BY CASE WHEN priority = '' THEN 1 ELSE 0 END, priority, id;")
[ -z "$tasks" ] && { notify-send "Todo" "No open tasks"; exit 0; }
selected=$(printf '%s' "$tasks" | dmenu -i -l 20 -p "Complete:" || exit 0)
[ -z "$selected" ] && exit 0
id=$(printf '%s' "$selected" | cut -d'|' -f1 | tr -d ' ')
sqlite3 "$DB" "UPDATE todo SET done = 1, done_at = datetime('now', 'localtime') WHERE id = $id;"
task=$(sqlite3 "$DB" "SELECT task FROM todo WHERE id = $id;")
notify-send "Done" "$task"
}
# --- main ---
cmd_help() {
printf '%b' "\
${C_BOLD}todo${C_RESET} - terminal todo list
${C_HEADER}Tasks${C_RESET}
todo add \"task\" [-p PRI] [-l LIST] Add a task
todo ls [-l LIST] [-a] [--done] [Q] List tasks
todo do ID [ID ...] Mark task(s) done
todo undo ID Reopen a task
todo rm ID [ID ...] Delete task(s)
todo pri ID A-Z Set priority
todo depri ID Remove priority
todo edit ID \"new text\" Edit task text
todo mv ID LIST Move task to a list
todo search TERM Search tasks
todo archive Purge completed tasks
todo count Task counts
${C_HEADER}Lists${C_RESET}
todo lists Show all lists
todo mklist NAME Create a new list
todo rmlist NAME Delete a list
todo renlist OLD NEW Rename a list
${C_HEADER}Dmenu${C_RESET}
todo -d Dmenu add mode
todo -D Dmenu view list → dunst
todo -x Dmenu complete mode
${C_HEADER}Notes${C_RESET}
Default list is ${C_LIST}@inbox${C_RESET}. Use ${C_DIM}-l${C_RESET} to filter or add to a list.
Priorities: ${C_PRI_A}(A)${C_RESET} ${C_PRI_B}(B)${C_RESET} ${C_PRI_C}(C)${C_RESET} highest → lowest.
"
}
case "$1" in
add|a) shift; cmd_add "$@" ;;
ls|list) shift; cmd_list "$@" ;;
do|done) shift; cmd_do "$@" ;;
undo) shift; cmd_undo "$@" ;;
rm|del) shift; cmd_rm "$@" ;;
pri|p) shift; cmd_pri "$@" ;;
depri|dp) shift; cmd_depri "$@" ;;
edit) shift; cmd_edit "$@" ;;
mv|move) shift; cmd_mv "$@" ;;
lists) cmd_lists ;;
mklist) shift; cmd_mklist "$@" ;;
rmlist) shift; cmd_rmlist "$@" ;;
renlist) shift; cmd_renlist "$@" ;;
search|s) shift; cmd_search "$@" ;;
archive) cmd_archive ;;
count) cmd_count ;;
-d|--dmenu) cmd_dmenu_add ;;
-D|--dview) cmd_dmenu_view ;;
-x|--ddo) cmd_dmenu_do ;;
-h|--help|help) cmd_help ;;
"") cmd_list ;;
*) printf "Unknown command: %s (try todo -h)\n" "$1" ;;
esac