Switch from flat text file to sqlite3 databases for task storage. Many new flags added including -f for separate files meaning you can have multiple task lists.

This commit is contained in:
Storm Dragon
2025-08-13 03:16:13 -04:00
parent fff22dd093
commit 9fdfee279f

540
ged
View File

@@ -2,19 +2,39 @@
# Git 'er Done - Simple Todo Manager # Git 'er Done - Simple Todo Manager
# Usage: # Usage:
# ged [task description] [due_date] - Add a task # ged [-f file_name] [task description] [due_date] - Add a task
# ged - List all tasks # ged [-f file_name] - List all tasks
# ged -d <task_number> - Mark task as done # ged [-f file_name] -d <task_number> - Mark task as done
# ged -r <task_number> <new_date> - Reschedule task # ged [-f file_name] -r <task_number> <new_date> - Reschedule task
# Use XDG data directory or fallback # Use XDG data directory or fallback
XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}" XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
TASKS_DIR="$XDG_DATA_HOME/git-er-done" 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 # Create directory if it doesn't exist
mkdir -p "$TASKS_DIR" 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() { help() {
echo "${0##*/}" echo "${0##*/}"
echo "Git 'er Done - Simple Todo Manager" echo "Git 'er Done - Simple Todo Manager"
@@ -33,16 +53,36 @@ help() {
done | sort done | sort
echo "" echo ""
echo "When adding tasks:" 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 ""
echo "Date formats: YYYY-MM-DD, 2025-08-10, tomorrow, next monday, etc." echo "Date formats: YYYY-MM-DD, 2025-08-10, tomorrow, next monday, etc."
echo "" echo ""
echo "Examples:" echo "Examples:"
echo " ged \"Fix bug in project\"" echo " Basic usage:"
echo " ged \"Call dentist\" tomorrow" echo " ged \"Fix bug in project\" # Add task"
echo " ged \"Submit report\" 2025-08-15" echo " ged \"Call dentist\" tomorrow # Add task with due date"
echo " ged -d 1" echo " ged # List pending tasks"
echo " ged -r 2 \"next friday\"" 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 exit 0
} }
@@ -101,11 +141,18 @@ add_task() {
fi fi
fi fi
# Create tasks file if it doesn't exist # Initialize database
touch "$TASKS_FILE" init_db
# Add task to file (format: description|due_date) # Add task to database using safe SQL escaping
echo "$description|$parsedDue" >> "$TASKS_FILE" 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 if [[ -n "$parsedDue" ]]; then
local formattedDate local formattedDate
@@ -117,58 +164,42 @@ add_task() {
} }
list_tasks() { 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!" echo "No tasks found. Add some tasks to get started!"
return return
fi fi
echo "Git 'er Done task list" 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 local displayNum=1
# Display tasks with dates first # Get tasks with dates first, sorted by due_date
for task in "${tasksWithDates[@]}"; do while IFS='|' read -r description dueDate; do
IFS='|' read -r _ description dueDate <<< "$task" if [[ -n "$dueDate" ]]; then
local formattedDate local formattedDate
formattedDate=$(format_date "$dueDate") formattedDate=$(format_date "$dueDate")
echo "$displayNum. $description Due by $formattedDate" echo "$displayNum. $description Due by $formattedDate"
((displayNum++)) else
done
# Then display tasks without dates
for task in "${tasksWithoutDates[@]}"; do
IFS='|' read -r _ description _ <<< "$task"
echo "$displayNum. $description" echo "$displayNum. $description"
fi
((displayNum++)) ((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() { mark_done() {
local taskNum="$1" local taskNum="$1"
if [[ ! -f "$TASKS_FILE" ]] || [[ ! -s "$TASKS_FILE" ]]; then # Initialize database
echo "No tasks found." init_db
return 1
fi
if [[ ! "$taskNum" =~ ^[0-9]+$ ]]; then if [[ ! "$taskNum" =~ ^[0-9]+$ ]]; then
echo "Error: Task number must be a number" echo "Error: Task number must be a number"
@@ -183,23 +214,22 @@ mark_done() {
return 1 return 1
fi fi
# Remove the task from the file # Get the task ID by display number
local tempFile local taskId
tempFile=$(mktemp) taskId=$(get_task_id_by_display_number "$taskNum")
remove_task_by_display_number "$taskNum" > "$tempFile"
mv "$tempFile" "$TASKS_FILE"
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() { reschedule_task() {
local taskNum="$1" local taskNum="$1"
local newDate="$2" local newDate="$2"
if [[ ! -f "$TASKS_FILE" ]] || [[ ! -s "$TASKS_FILE" ]]; then # Initialize database
echo "No tasks found." init_db
return 1
fi
if [[ ! "$taskNum" =~ ^[0-9]+$ ]]; then if [[ ! "$taskNum" =~ ^[0-9]+$ ]]; then
echo "Error: Task number must be a number" echo "Error: Task number must be a number"
@@ -221,11 +251,12 @@ reschedule_task() {
return 1 return 1
fi fi
# Update the task # Get the task ID by display number
local tempFile local taskId
tempFile=$(mktemp) taskId=$(get_task_id_by_display_number "$taskNum")
update_task_by_display_number "$taskNum" "$parsedDate" > "$tempFile"
mv "$tempFile" "$TASKS_FILE" # Update the task's due date
sqlite3 "$TASKS_DB" "UPDATE tasks SET due_date = '$parsedDate' WHERE id = $taskId;"
local formattedDate local formattedDate
formattedDate=$(format_date "$parsedDate") formattedDate=$(format_date "$parsedDate")
@@ -234,171 +265,259 @@ reschedule_task() {
get_task_by_display_number() { get_task_by_display_number() {
local targetNum="$1" local targetNum="$1"
local currentNum=1
# Build sorted list like in list_tasks # Initialize database
local tasksWithDates=() init_db
local tasksWithoutDates=()
local lineNum=1
while IFS='|' read -r description dueDate; do # Get the task description by row number from ordered query
if [[ -n "$dueDate" ]]; then sqlite3 "$TASKS_DB" "SELECT description FROM tasks WHERE status = 'pending' ORDER BY
tasksWithDates+=("$lineNum|$description|$dueDate") CASE WHEN due_date IS NULL THEN 1 ELSE 0 END,
else due_date ASC
tasksWithoutDates+=("$lineNum|$description|") LIMIT 1 OFFSET $((targetNum - 1));"
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
} }
remove_task_by_display_number() { get_task_id_by_display_number() {
local targetNum="$1" local targetNum="$1"
local currentNum=1
# Build sorted list and track original line numbers # Initialize database
local tasksWithDates=() init_db
local tasksWithoutDates=()
local lineNum=1
while IFS='|' read -r description dueDate; do # Get the task ID by row number from ordered query
if [[ -n "$dueDate" ]]; then sqlite3 "$TASKS_DB" "SELECT id FROM tasks WHERE status = 'pending' ORDER BY
tasksWithDates+=("$lineNum|$description|$dueDate") CASE WHEN due_date IS NULL THEN 1 ELSE 0 END,
else due_date ASC
tasksWithoutDates+=("$lineNum|$description|") LIMIT 1 OFFSET $((targetNum - 1));"
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"
} }
update_task_by_display_number() {
local targetNum="$1"
local newDueDate="$2"
local currentNum=1
# Build sorted list and track original line numbers
local tasksWithDates=()
local tasksWithoutDates=()
local lineNum=1
while IFS='|' read -r description dueDate; do # 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 if [[ -n "$dueDate" ]]; then
tasksWithDates+=("$lineNum|$description|$dueDate") local formattedDue
formattedDue=$(format_date "$dueDate")
echo "$displayNum. ✓ $description (was due $formattedDue) - completed $completedDate"
else else
tasksWithoutDates+=("$lineNum|$description|") echo "$displayNum. ✓ $description - completed $completedDate"
fi fi
((lineNum++)) ((displayNum++))
done < "$TASKS_FILE" done < <(sqlite3 "$TASKS_DB" "SELECT description, due_date, completed_at FROM tasks WHERE status = 'completed' ORDER BY completed_at DESC;")
}
if [[ ${#tasksWithDates[@]} -gt 0 ]]; then # List all tasks with status
readarray -t tasksWithDates < <(printf '%s\n' "${tasksWithDates[@]}" | sort -t'|' -k3) 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 fi
local updateLine=0 echo "All tasks:"
local targetDescription=""
# Find which original line to update local displayNum=1
for task in "${tasksWithDates[@]}"; do
if [[ $currentNum -eq $targetNum ]]; then
IFS='|' read -r origLine description _ <<< "$task"
updateLine=$origLine
targetDescription="$description"
break
fi
((currentNum++))
done
if [[ $updateLine -eq 0 ]]; then # Get all tasks sorted by status (pending first), then by due date
for task in "${tasksWithoutDates[@]}"; do while IFS='|' read -r description dueDate status completedAt; do
if [[ $currentNum -eq $targetNum ]]; then local statusIcon=""
IFS='|' read -r origLine description _ <<< "$task" local statusText=""
updateLine=$origLine
targetDescription="$description"
break
fi
((currentNum++))
done
fi
# Output all lines, updating the target line case "$status" in
lineNum=1 "pending")
while IFS='|' read -r description dueDate; do statusIcon="○"
if [[ $lineNum -eq $updateLine ]]; then statusText=""
echo "$targetDescription|$newDueDate" ;;
"completed")
statusIcon="✓"
local completedDate
if [[ -n "$completedAt" ]]; then
completedDate=$(date -d "$completedAt" "+%m-%d" 2>/dev/null || echo "$completedAt")
statusText=" (done $completedDate)"
else else
echo "$description|$dueDate" 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 fi
((lineNum++))
done < "$TASKS_FILE"
} }
# Array of command line arguments # Array of command line arguments
declare -A command=( 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." [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." [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 # Main script logic with improved help but simple case parsing
case "$1" in case "$1" in
"") "")
@@ -414,6 +533,10 @@ case "$1" in
fi fi
mark_done "$2" mark_done "$2"
;; ;;
"-f")
echo "Error: -f flag must be used as first argument"
exit 1
;;
"-r") "-r")
if [[ -z "$2" ]] || [[ -z "$3" ]]; then if [[ -z "$2" ]] || [[ -z "$3" ]]; then
echo "Error: Task number and new date required for -r option" echo "Error: Task number and new date required for -r option"
@@ -424,6 +547,25 @@ case "$1" in
"-l") "-l")
list_tasks 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 new task
add_task "$1" "$2" add_task "$1" "$2"