#!/bin/bash # 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