Files
git-er-done/ged

633 lines
19 KiB
Bash
Executable File

#!/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 <task_number> - Mark task as done
# ged [-f file_name] -r <task_number> <new_date> - 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"
echo " ged -L (--list) # List all available task files"
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
}
# List available task files
list_task_files() {
echo "Available task files:"
echo "===================="
# Check if tasks directory exists
if [[ ! -d "$TASKS_DIR" ]]; then
echo "No task files found. Create your first task to get started!"
return
fi
# Find all .db files in the tasks directory
local dbFiles=()
local fileFound=false
while IFS= read -r -d '' file; do
fileFound=true
local fileName=$(basename "$file" .db)
local fileSize=$(stat -f%z "$file" 2>/dev/null || stat -c%s "$file" 2>/dev/null || echo "0")
local fileSizeKb=$((fileSize / 1024))
if [[ $fileSizeKb -eq 0 && $fileSize -gt 0 ]]; then
fileSizeKb="<1"
fi
# Get task counts for this file
local totalTasks pendingTasks completedTasks
totalTasks=$(sqlite3 "$file" "SELECT COUNT(*) FROM tasks;" 2>/dev/null || echo "0")
pendingTasks=$(sqlite3 "$file" "SELECT COUNT(*) FROM tasks WHERE status = 'pending';" 2>/dev/null || echo "0")
completedTasks=$(sqlite3 "$file" "SELECT COUNT(*) FROM tasks WHERE status = 'completed';" 2>/dev/null || echo "0")
# Mark current/default file
local marker=""
if [[ "$file" == "$TASKS_DB" ]]; then
marker=" (current)"
elif [[ "$fileName" == "tasks" ]]; then
marker=" (default)"
fi
printf " %-15s %3s pending, %3s done, %3s total (%s KB)%s\n" \
"$fileName" "$pendingTasks" "$completedTasks" "$totalTasks" "$fileSizeKb" "$marker"
done < <(find "$TASKS_DIR" -name "*.db" -print0 2>/dev/null | sort -z)
if [[ "$fileFound" == false ]]; then
echo "No task files found. Create your first task to get started!"
else
echo ""
echo "Usage: ged -f <filename> [command]"
echo "Example: ged -f work -s"
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)."
[L]="List available task files (--list)."
[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
;;
"-L"|"--list")
list_task_files
;;
"--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