diff --git a/ged b/ged index af0cbc4..a3bac2f 100755 --- a/ged +++ b/ged @@ -2,19 +2,39 @@ # Git 'er Done - Simple Todo Manager # Usage: -# ged [task description] [due_date] - Add a task -# ged - List all tasks -# ged -d - Mark task as done -# ged -r - Reschedule task +# 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" -TASKS_FILE="$TASKS_DIR/tasks" +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" @@ -33,16 +53,36 @@ help() { done | sort echo "" echo "When adding tasks:" - echo " ged \"task description\" [due_date]" + 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 " ged \"Fix bug in project\"" - echo " ged \"Call dentist\" tomorrow" - echo " ged \"Submit report\" 2025-08-15" - echo " ged -d 1" - echo " ged -r 2 \"next friday\"" + 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 } @@ -101,11 +141,18 @@ add_task() { fi fi - # Create tasks file if it doesn't exist - touch "$TASKS_FILE" + # Initialize database + init_db - # Add task to file (format: description|due_date) - echo "$description|$parsedDue" >> "$TASKS_FILE" + # 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 @@ -117,58 +164,42 @@ add_task() { } list_tasks() { - if [[ ! -f "$TASKS_FILE" ]] || [[ ! -s "$TASKS_FILE" ]]; then + # 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" - # Read tasks, sort by due date (tasks with dates first, then tasks without) - local tasksWithDates=() - local tasksWithoutDates=() - local lineNum=1 - - while IFS='|' read -r description dueDate; do - if [[ -n "$dueDate" ]]; then - tasksWithDates+=("$lineNum|$description|$dueDate") - else - tasksWithoutDates+=("$lineNum|$description|") - fi - ((lineNum++)) - done < "$TASKS_FILE" - - # Sort tasks with dates by date - if [[ ${#tasksWithDates[@]} -gt 0 ]]; then - readarray -t tasksWithDates < <(printf '%s\n' "${tasksWithDates[@]}" | sort -t'|' -k3) - fi - local displayNum=1 - # Display tasks with dates first - for task in "${tasksWithDates[@]}"; do - IFS='|' read -r _ description dueDate <<< "$task" - local formattedDate - formattedDate=$(format_date "$dueDate") - echo "$displayNum. $description Due by $formattedDate" + # 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 - - # Then display tasks without dates - for task in "${tasksWithoutDates[@]}"; do - IFS='|' read -r _ description _ <<< "$task" - echo "$displayNum. $description" - ((displayNum++)) - done + 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" - if [[ ! -f "$TASKS_FILE" ]] || [[ ! -s "$TASKS_FILE" ]]; then - echo "No tasks found." - return 1 - fi + # Initialize database + init_db if [[ ! "$taskNum" =~ ^[0-9]+$ ]]; then echo "Error: Task number must be a number" @@ -183,23 +214,22 @@ mark_done() { return 1 fi - # Remove the task from the file - local tempFile - tempFile=$(mktemp) - remove_task_by_display_number "$taskNum" > "$tempFile" - mv "$tempFile" "$TASKS_FILE" + # Get the task ID by display number + local taskId + taskId=$(get_task_id_by_display_number "$taskNum") - echo "Task $taskNum \"$taskDescription\" marked as done and removed from list." + # 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" - if [[ ! -f "$TASKS_FILE" ]] || [[ ! -s "$TASKS_FILE" ]]; then - echo "No tasks found." - return 1 - fi + # Initialize database + init_db if [[ ! "$taskNum" =~ ^[0-9]+$ ]]; then echo "Error: Task number must be a number" @@ -221,11 +251,12 @@ reschedule_task() { return 1 fi - # Update the task - local tempFile - tempFile=$(mktemp) - update_task_by_display_number "$taskNum" "$parsedDate" > "$tempFile" - mv "$tempFile" "$TASKS_FILE" + # 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") @@ -234,171 +265,259 @@ reschedule_task() { get_task_by_display_number() { local targetNum="$1" - local currentNum=1 - # Build sorted list like in list_tasks - local tasksWithDates=() - local tasksWithoutDates=() - local lineNum=1 + # Initialize database + init_db - while IFS='|' read -r description dueDate; do - if [[ -n "$dueDate" ]]; then - tasksWithDates+=("$lineNum|$description|$dueDate") - else - tasksWithoutDates+=("$lineNum|$description|") - fi - ((lineNum++)) - done < "$TASKS_FILE" - - if [[ ${#tasksWithDates[@]} -gt 0 ]]; then - readarray -t tasksWithDates < <(printf '%s\n' "${tasksWithDates[@]}" | sort -t'|' -k3) - fi - - # Check tasks with dates first - for task in "${tasksWithDates[@]}"; do - if [[ $currentNum -eq $targetNum ]]; then - IFS='|' read -r _ description _ <<< "$task" - echo "$description" - return - fi - ((currentNum++)) - done - - # Then check tasks without dates - for task in "${tasksWithoutDates[@]}"; do - if [[ $currentNum -eq $targetNum ]]; then - IFS='|' read -r _ description _ <<< "$task" - echo "$description" - return - fi - ((currentNum++)) - done + # 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));" } -remove_task_by_display_number() { +get_task_id_by_display_number() { local targetNum="$1" - local currentNum=1 - # Build sorted list and track original line numbers - local tasksWithDates=() - local tasksWithoutDates=() - local lineNum=1 + # Initialize database + init_db - while IFS='|' read -r description dueDate; do - if [[ -n "$dueDate" ]]; then - tasksWithDates+=("$lineNum|$description|$dueDate") - else - tasksWithoutDates+=("$lineNum|$description|") - fi - ((lineNum++)) - done < "$TASKS_FILE" - - if [[ ${#tasksWithDates[@]} -gt 0 ]]; then - readarray -t tasksWithDates < <(printf '%s\n' "${tasksWithDates[@]}" | sort -t'|' -k3) - fi - - local skipLine=0 - - # Find which original line to skip - for task in "${tasksWithDates[@]}"; do - if [[ $currentNum -eq $targetNum ]]; then - IFS='|' read -r origLine _ _ <<< "$task" - skipLine=$origLine - break - fi - ((currentNum++)) - done - - if [[ $skipLine -eq 0 ]]; then - for task in "${tasksWithoutDates[@]}"; do - if [[ $currentNum -eq $targetNum ]]; then - IFS='|' read -r origLine _ _ <<< "$task" - skipLine=$origLine - break - fi - ((currentNum++)) - done - fi - - # Output all lines except the one to skip - lineNum=1 - while IFS='|' read -r description dueDate; do - if [[ $lineNum -ne $skipLine ]]; then - echo "$description|$dueDate" - fi - ((lineNum++)) - done < "$TASKS_FILE" + # 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));" } -update_task_by_display_number() { - local targetNum="$1" - local newDueDate="$2" - local currentNum=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" - # Build sorted list and track original line numbers - local tasksWithDates=() - local tasksWithoutDates=() - local lineNum=1 + # Initialize database + init_db - while IFS='|' read -r description dueDate; do + 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 - tasksWithDates+=("$lineNum|$description|$dueDate") + local formattedDue + formattedDue=$(format_date "$dueDate") + echo "$displayNum. ✓ $description (was due $formattedDue) - completed $completedDate" else - tasksWithoutDates+=("$lineNum|$description|") + echo "$displayNum. ✓ $description - completed $completedDate" fi - ((lineNum++)) - done < "$TASKS_FILE" + ((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 - if [[ ${#tasksWithDates[@]} -gt 0 ]]; then - readarray -t tasksWithDates < <(printf '%s\n' "${tasksWithDates[@]}" | sort -t'|' -k3) + # 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 - local updateLine=0 - local targetDescription="" + echo "All tasks:" - # Find which original line to update - for task in "${tasksWithDates[@]}"; do - if [[ $currentNum -eq $targetNum ]]; then - IFS='|' read -r origLine description _ <<< "$task" - updateLine=$origLine - targetDescription="$description" - break + 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 - ((currentNum++)) - done + ((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 - if [[ $updateLine -eq 0 ]]; then - for task in "${tasksWithoutDates[@]}"; do - if [[ $currentNum -eq $targetNum ]]; then - IFS='|' read -r origLine description _ <<< "$task" - updateLine=$origLine - targetDescription="$description" - break - fi - ((currentNum++)) - done + 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 - # Output all lines, updating the target line - lineNum=1 - while IFS='|' read -r description dueDate; do - if [[ $lineNum -eq $updateLine ]]; then - echo "$targetDescription|$newDueDate" - else - echo "$description|$dueDate" - fi - ((lineNum++)) - done < "$TASKS_FILE" + # 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=( - [d:task_number]="Mark task as done and remove from list." + [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." - [l]="List all tasks (same as no arguments)." + [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 "") @@ -414,6 +533,10 @@ case "$1" in 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" @@ -424,6 +547,25 @@ case "$1" in "-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"