#!/usr/bin/env bash # Git 'er Done - Simple Todo Manager # Usage: # ged [-f file_name] [task description] [due_date] - Add a task # ged [-f file_name] - List all tasks # ged [-f file_name] -d - Mark task as done # ged [-f file_name] -r - Reschedule task # Use XDG data directory or fallback XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}" TASKS_DIR="$XDG_DATA_HOME/git-er-done" DEFAULT_DB="tasks.db" TASKS_DB="$TASKS_DIR/$DEFAULT_DB" # Create directory if it doesn't exist mkdir -p "$TASKS_DIR" # Initialize database with schema if it doesn't exist init_db() { sqlite3 "$TASKS_DB" <<'EOF' CREATE TABLE IF NOT EXISTS tasks ( id INTEGER PRIMARY KEY AUTOINCREMENT, description TEXT NOT NULL, due_date TEXT, status TEXT DEFAULT 'pending', created_at TEXT DEFAULT CURRENT_TIMESTAMP, completed_at TEXT, project TEXT DEFAULT 'default' ); CREATE INDEX IF NOT EXISTS idx_status ON tasks(status); CREATE INDEX IF NOT EXISTS idx_due_date ON tasks(due_date); CREATE INDEX IF NOT EXISTS idx_project ON tasks(project); EOF } help() { echo "${0##*/}" echo "Git 'er Done - Simple Todo Manager" echo "" echo "Usage:" echo "With no arguments, list all tasks." for i in "${!command[@]}" ; do if [[ "$i" == *:* ]]; then local flag="${i%%:*}" local params="${i#*:}" params="${params//:/ ><}" echo "-${flag} <${params}>: ${command[${i}]}" else echo "-${i}: ${command[${i}]}" fi done | sort echo "" echo "When adding tasks:" echo " ged [-f file_name] \"task description\" [due_date]" echo "" echo "Multiple task lists:" echo " Use -f to specify different task files (creates separate .db files)" echo " Default file is 'tasks.db' if no -f flag is used" echo "" echo "Date formats: YYYY-MM-DD, 2025-08-10, tomorrow, next monday, etc." echo "" echo "Examples:" echo " Basic usage:" echo " ged \"Fix bug in project\" # Add task" echo " ged \"Call dentist\" tomorrow # Add task with due date" echo " ged # List pending tasks" echo " ged -d 1 # Mark task 1 as done" echo " ged -r 2 \"next friday\" # Reschedule task 2" echo "" echo " Multiple task lists:" echo " ged -f errands \"Buy groceries\" # Add to errands.db" echo " ged -f work -l # List work tasks" echo " ged -f personal -s # Show personal stats" echo "" echo " View completed and all tasks:" echo " ged -c (--completed) # Show completed tasks" echo " ged -a (--all) # Show all tasks with status" echo " ged -s (--stats) # Show statistics" echo "" echo " Task management:" echo " ged -x 3 (--remove) # Permanently delete task 3" echo " ged -p (--purge) # Remove tasks completed >30 days ago" echo " ged -p 7 (--purge 7) # Remove tasks completed >7 days ago" exit 0 } parse_date() { local inputDate="$1" if [[ -z "$inputDate" ]]; then echo "" return fi # If it's already in YYYY-MM-DD format, return it if [[ "$inputDate" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then echo "$inputDate" return fi # Use date command to parse natural language dates local parsedDate if parsedDate=$(date -d "$inputDate" +%Y-%m-%d 2>/dev/null); then echo "$parsedDate" else echo "" fi } format_date() { local dateStr="$1" if [[ -z "$dateStr" ]]; then echo "" return fi local formatted if formatted=$(date -d "$dateStr" "+%A, %B %d, %Y" 2>/dev/null); then echo "$formatted" else echo "$dateStr" fi } add_task() { local description="$1" local dueDate="$2" if [[ -z "$description" ]]; then echo "Error: Task description required" return 1 fi local parsedDue="" if [[ -n "$dueDate" ]]; then parsedDue=$(parse_date "$dueDate") if [[ -z "$parsedDue" ]]; then echo "Error: Invalid date format '$dueDate'" return 1 fi fi # Initialize database init_db # Add task to database using safe SQL escaping local escapedDesc escapedDesc="${description//\'/\'\'}" # Escape single quotes if [[ -n "$parsedDue" ]]; then sqlite3 "$TASKS_DB" "INSERT INTO tasks (description, due_date) VALUES ('$escapedDesc', '$parsedDue');" else sqlite3 "$TASKS_DB" "INSERT INTO tasks (description, due_date) VALUES ('$escapedDesc', NULL);" fi if [[ -n "$parsedDue" ]]; then local formattedDate formattedDate=$(format_date "$parsedDue") echo "Task \"$description\" added, due by $formattedDate." else echo "Task \"$description\" added." fi } list_tasks() { # Initialize database init_db # Check if there are any pending tasks local taskCount taskCount=$(sqlite3 "$TASKS_DB" "SELECT COUNT(*) FROM tasks WHERE status = 'pending';") if [[ "$taskCount" -eq 0 ]]; then echo "No tasks found. Add some tasks to get started!" return fi echo "Git 'er Done task list" local displayNum=1 # Get tasks with dates first, sorted by due_date while IFS='|' read -r description dueDate; do if [[ -n "$dueDate" ]]; then local formattedDate formattedDate=$(format_date "$dueDate") echo "$displayNum. $description Due by $formattedDate" else echo "$displayNum. $description" fi ((displayNum++)) done < <(sqlite3 "$TASKS_DB" "SELECT description, due_date FROM tasks WHERE status = 'pending' ORDER BY CASE WHEN due_date IS NULL THEN 1 ELSE 0 END, due_date ASC;") } mark_done() { local taskNum="$1" # Initialize database init_db if [[ ! "$taskNum" =~ ^[0-9]+$ ]]; then echo "Error: Task number must be a number" return 1 fi # Get the task description for confirmation local taskDescription taskDescription=$(get_task_by_display_number "$taskNum") if [[ -z "$taskDescription" ]]; then echo "Error: Task $taskNum not found" return 1 fi # Get the task ID by display number local taskId taskId=$(get_task_id_by_display_number "$taskNum") # Mark task as completed with timestamp sqlite3 "$TASKS_DB" "UPDATE tasks SET status = 'completed', completed_at = CURRENT_TIMESTAMP WHERE id = $taskId;" echo "Task $taskNum \"$taskDescription\" marked as done." } reschedule_task() { local taskNum="$1" local newDate="$2" # Initialize database init_db if [[ ! "$taskNum" =~ ^[0-9]+$ ]]; then echo "Error: Task number must be a number" return 1 fi local parsedDate parsedDate=$(parse_date "$newDate") if [[ -z "$parsedDate" ]]; then echo "Error: Invalid date format '$newDate'" return 1 fi # Get the task description local taskDescription taskDescription=$(get_task_by_display_number "$taskNum") if [[ -z "$taskDescription" ]]; then echo "Error: Task $taskNum not found" return 1 fi # Get the task ID by display number local taskId taskId=$(get_task_id_by_display_number "$taskNum") # Update the task's due date sqlite3 "$TASKS_DB" "UPDATE tasks SET due_date = '$parsedDate' WHERE id = $taskId;" local formattedDate formattedDate=$(format_date "$parsedDate") echo "Task $taskNum \"$taskDescription\" rescheduled to $formattedDate." } get_task_by_display_number() { local targetNum="$1" # Initialize database init_db # Get the task description by row number from ordered query sqlite3 "$TASKS_DB" "SELECT description FROM tasks WHERE status = 'pending' ORDER BY CASE WHEN due_date IS NULL THEN 1 ELSE 0 END, due_date ASC LIMIT 1 OFFSET $((targetNum - 1));" } get_task_id_by_display_number() { local targetNum="$1" # Initialize database init_db # Get the task ID by row number from ordered query sqlite3 "$TASKS_DB" "SELECT id FROM tasks WHERE status = 'pending' ORDER BY CASE WHEN due_date IS NULL THEN 1 ELSE 0 END, due_date ASC LIMIT 1 OFFSET $((targetNum - 1));" } # Function to set database file based on -f flag set_db_file() { local fileName="$1" if [[ -n "$fileName" ]]; then TASKS_DB="$TASKS_DIR/${fileName}.db" fi } # Remove a task permanently remove_task() { local taskNum="$1" # Initialize database init_db if [[ ! "$taskNum" =~ ^[0-9]+$ ]]; then echo "Error: Task number must be a number" return 1 fi # Get the task description for confirmation local taskDescription taskDescription=$(get_task_by_display_number "$taskNum") if [[ -z "$taskDescription" ]]; then echo "Error: Task $taskNum not found" return 1 fi # Get the task ID by display number local taskId taskId=$(get_task_id_by_display_number "$taskNum") # Permanently delete the task sqlite3 "$TASKS_DB" "DELETE FROM tasks WHERE id = $taskId;" echo "Task $taskNum \"$taskDescription\" permanently removed." } # List completed tasks only list_completed_tasks() { # Initialize database init_db # Check if there are any completed tasks local taskCount taskCount=$(sqlite3 "$TASKS_DB" "SELECT COUNT(*) FROM tasks WHERE status = 'completed';") if [[ "$taskCount" -eq 0 ]]; then echo "No completed tasks found." return fi echo "Completed tasks:" local displayNum=1 # Get completed tasks sorted by completion date while IFS='|' read -r description dueDate completedAt; do local completedDate if [[ -n "$completedAt" ]]; then completedDate=$(date -d "$completedAt" "+%Y-%m-%d" 2>/dev/null || echo "$completedAt") else completedDate="unknown" fi if [[ -n "$dueDate" ]]; then local formattedDue formattedDue=$(format_date "$dueDate") echo "$displayNum. ✓ $description (was due $formattedDue) - completed $completedDate" else echo "$displayNum. ✓ $description - completed $completedDate" fi ((displayNum++)) done < <(sqlite3 "$TASKS_DB" "SELECT description, due_date, completed_at FROM tasks WHERE status = 'completed' ORDER BY completed_at DESC;") } # List all tasks with status list_all_tasks() { # Initialize database init_db # Check if there are any tasks local taskCount taskCount=$(sqlite3 "$TASKS_DB" "SELECT COUNT(*) FROM tasks;") if [[ "$taskCount" -eq 0 ]]; then echo "No tasks found." return fi echo "All tasks:" local displayNum=1 # Get all tasks sorted by status (pending first), then by due date while IFS='|' read -r description dueDate status completedAt; do local statusIcon="" local statusText="" case "$status" in "pending") statusIcon="○" statusText="" ;; "completed") statusIcon="✓" local completedDate if [[ -n "$completedAt" ]]; then completedDate=$(date -d "$completedAt" "+%m-%d" 2>/dev/null || echo "$completedAt") statusText=" (done $completedDate)" else statusText=" (done)" fi ;; esac if [[ -n "$dueDate" ]]; then local formattedDate formattedDate=$(format_date "$dueDate") echo "$displayNum. $statusIcon $description Due by $formattedDate$statusText" else echo "$displayNum. $statusIcon $description$statusText" fi ((displayNum++)) done < <(sqlite3 "$TASKS_DB" "SELECT description, due_date, status, completed_at FROM tasks ORDER BY CASE WHEN status = 'pending' THEN 0 ELSE 1 END, CASE WHEN due_date IS NULL THEN 1 ELSE 0 END, due_date ASC;") } # Show task statistics show_stats() { # Initialize database init_db echo "Task Statistics:" echo "==================" # Get basic counts local totalTasks pendingTasks completedTasks totalTasks=$(sqlite3 "$TASKS_DB" "SELECT COUNT(*) FROM tasks;") pendingTasks=$(sqlite3 "$TASKS_DB" "SELECT COUNT(*) FROM tasks WHERE status = 'pending';") completedTasks=$(sqlite3 "$TASKS_DB" "SELECT COUNT(*) FROM tasks WHERE status = 'completed';") echo "Total tasks: $totalTasks" echo "Pending: $pendingTasks" echo "Completed: $completedTasks" if [[ "$totalTasks" -gt 0 ]]; then local completionRate completionRate=$(sqlite3 "$TASKS_DB" "SELECT ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM tasks), 1) FROM tasks WHERE status = 'completed';") echo "Completion rate: ${completionRate}%" fi # Overdue tasks local overdueTasks overdueTasks=$(sqlite3 "$TASKS_DB" "SELECT COUNT(*) FROM tasks WHERE status = 'pending' AND due_date < date('now');") if [[ "$overdueTasks" -gt 0 ]]; then echo "⚠️ Overdue tasks: $overdueTasks" fi # Tasks due today local todayTasks todayTasks=$(sqlite3 "$TASKS_DB" "SELECT COUNT(*) FROM tasks WHERE status = 'pending' AND due_date = date('now');") if [[ "$todayTasks" -gt 0 ]]; then echo "📅 Due today: $todayTasks" fi } # Purge old completed tasks purge_old_tasks() { local daysOld="${1:-30}" # Initialize database init_db if [[ ! "$daysOld" =~ ^[0-9]+$ ]]; then echo "Error: Days must be a number" return 1 fi # Count tasks to be purged local tasksToDelete tasksToDelete=$(sqlite3 "$TASKS_DB" "SELECT COUNT(*) FROM tasks WHERE status = 'completed' AND completed_at < datetime('now', '-$daysOld days');") if [[ "$tasksToDelete" -eq 0 ]]; then echo "No completed tasks older than $daysOld days found." return fi echo "Found $tasksToDelete completed tasks older than $daysOld days." read -p "Are you sure you want to permanently delete them? (y/N): " -n 1 -r echo if [[ $REPLY =~ ^[Yy]$ ]]; then sqlite3 "$TASKS_DB" "DELETE FROM tasks WHERE status = 'completed' AND completed_at < datetime('now', '-$daysOld days');" echo "$tasksToDelete old completed tasks have been purged." else echo "Purge cancelled." fi } # Array of command line arguments declare -A command=( [a]="List all tasks with their status (--all)." [c]="List completed tasks only (--completed)." [d:task_number]="Mark task as done." [f:file_name]="Use specified database file (without .db extension)." [h]="This help screen." [l]="List pending tasks (same as no arguments)." [p:days]="Remove completed tasks older than N days (--purge, default: 30)." [r:task_number:new_date]="Reschedule task to new date." [s]="Show task statistics (--stats)." [x:task_number]="Permanently delete a task (--remove)." ) # Parse -f flag first if present if [[ "$1" == "-f" ]]; then if [[ -z "$2" ]]; then echo "Error: File name required for -f option" exit 1 fi set_db_file "$2" # Shift arguments to remove -f and filename shift 2 fi # Main script logic with improved help but simple case parsing case "$1" in "") list_tasks ;; "-h"|"--help") help ;; "-d") if [[ -z "$2" ]]; then echo "Error: Task number required for -d option" exit 1 fi mark_done "$2" ;; "-f") echo "Error: -f flag must be used as first argument" exit 1 ;; "-r") if [[ -z "$2" ]] || [[ -z "$3" ]]; then echo "Error: Task number and new date required for -r option" exit 1 fi reschedule_task "$2" "$3" ;; "-l") list_tasks ;; "--remove"|"--rm"|"-x") if [[ -z "$2" ]]; then echo "Error: Task number required for --remove option" exit 1 fi remove_task "$2" ;; "--completed"|"--done"|"-c") list_completed_tasks ;; "--all"|"-a") list_all_tasks ;; "--stats"|"-s") show_stats ;; "--purge"|"-p") purge_old_tasks "$2" ;; *) # Add new task add_task "$1" "$2" ;; esac exit 0