Created
September 21, 2025 03:15
-
-
Save htlin222/84956dc3e4871b9d4867d9b1b8c96887 to your computer and use it in GitHub Desktop.
A Bash script that organizes files in the ~/Downloads folder by file age and file type.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| # Downloads Organizer Script | |
| # Organizes files by age (3d, 7d, 14d, 30d, old) and then by file type | |
| set -e | |
| # Configuration | |
| DOWNLOADS_DIR="/Users/htlin/Downloads" | |
| DRY_RUN=${DRY_RUN:-false} | |
| VERBOSE=${VERBOSE:-true} | |
| # Colors for output | |
| RED='\033[0;31m' | |
| GREEN='\033[0;32m' | |
| YELLOW='\033[1;33m' | |
| BLUE='\033[0;34m' | |
| NC='\033[0m' # No Color | |
| # Logging function | |
| log() { | |
| if [[ $VERBOSE == true ]]; then | |
| echo -e "${BLUE}[INFO]${NC} $1" | |
| fi | |
| } | |
| log_success() { | |
| echo -e "${GREEN}[SUCCESS]${NC} $1" | |
| } | |
| log_warning() { | |
| echo -e "${YELLOW}[WARNING]${NC} $1" | |
| } | |
| log_error() { | |
| echo -e "${RED}[ERROR]${NC} $1" | |
| } | |
| # Get file extension in lowercase | |
| get_extension() { | |
| local file="$1" | |
| echo "${file##*.}" | tr '[:upper:]' '[:lower:]' | |
| } | |
| # Get file category based on extension | |
| get_category() { | |
| local ext="$1" | |
| case "$ext" in | |
| # Documents | |
| pdf|doc|docx|ppt|pptx|txt|rtf|odt|pages) | |
| echo "documents" | |
| ;; | |
| # Images | |
| jpg|jpeg|png|gif|bmp|tiff|svg|webp|ico|heic) | |
| echo "images" | |
| ;; | |
| # Data files | |
| csv|xlsx|xls|json|xml|yaml|yml|db|sql) | |
| echo "data" | |
| ;; | |
| # Code and config | |
| js|ts|py|go|java|c|cpp|h|css|html|sh|bash|zsh|fish|vim|conf|cfg|ini|toml|mod|sum) | |
| echo "code" | |
| ;; | |
| # Archives | |
| zip|tar|gz|rar|7z|bz2|xz|dmg|pkg|deb|rpm) | |
| echo "archives" | |
| ;; | |
| # E-books | |
| epub|mobi|azw|azw3|azw8|pdf) | |
| # PDFs could be either documents or ebooks - check filename patterns | |
| if [[ "$2" =~ (book|novel|guide|manual|tutorial) ]]; then | |
| echo "ebooks" | |
| else | |
| echo "documents" | |
| fi | |
| ;; | |
| # Audio/Video | |
| mp3|wav|flac|aac|mp4|avi|mkv|mov|wmv|webm) | |
| echo "media" | |
| ;; | |
| # Misc | |
| *) | |
| echo "misc" | |
| ;; | |
| esac | |
| } | |
| # Get age bucket based on file modification time | |
| get_age_bucket() { | |
| local file="$1" | |
| local mod_time | |
| local now | |
| local diff_days | |
| # Get modification time in seconds since epoch | |
| if [[ "$OSTYPE" == "darwin"* ]]; then | |
| # macOS | |
| mod_time=$(stat -f "%m" "$file" 2>/dev/null || echo 0) | |
| else | |
| # Linux | |
| mod_time=$(stat -c "%Y" "$file" 2>/dev/null || echo 0) | |
| fi | |
| now=$(date +%s) | |
| diff_days=$(( (now - mod_time) / 86400 )) | |
| if [[ $diff_days -le 3 ]]; then | |
| echo "3d" | |
| elif [[ $diff_days -le 7 ]]; then | |
| echo "7d" | |
| elif [[ $diff_days -le 14 ]]; then | |
| echo "14d" | |
| elif [[ $diff_days -le 30 ]]; then | |
| echo "30d" | |
| else | |
| echo "old" | |
| fi | |
| } | |
| # Create directory structure | |
| create_structure() { | |
| local base_dirs=("3d" "7d" "14d" "30d" "old") | |
| local sub_dirs=("documents" "images" "data" "code" "archives" "ebooks" "media" "misc") | |
| log "Creating directory structure..." | |
| for base in "${base_dirs[@]}"; do | |
| for sub in "${sub_dirs[@]}"; do | |
| local dir_path="$DOWNLOADS_DIR/$base/$sub" | |
| if [[ $DRY_RUN == false ]]; then | |
| mkdir -p "$dir_path" | |
| fi | |
| log "Created: $dir_path" | |
| done | |
| done | |
| } | |
| # Move file to appropriate directory | |
| move_file() { | |
| local file="$1" | |
| local age_bucket="$2" | |
| local category="$3" | |
| local dest_dir="$DOWNLOADS_DIR/$age_bucket/$category" | |
| local filename=$(basename "$file") | |
| # Skip if file is already in organized structure (unless re-aging) | |
| if [[ "$file" =~ ^$DOWNLOADS_DIR/(3d|7d|14d|30d|old)/ ]] && [[ "${4:-}" != "reage" ]]; then | |
| log "Skipping already organized file: $filename" | |
| return | |
| fi | |
| # Skip script files and hidden files | |
| if [[ "$filename" == "organize.sh" ]] || [[ "$filename" == "Makefile" ]] || [[ "$filename" == .* ]]; then | |
| log "Skipping system file: $filename" | |
| return | |
| fi | |
| # Create destination directory if it doesn't exist | |
| if [[ $DRY_RUN == false ]]; then | |
| mkdir -p "$dest_dir" | |
| fi | |
| local dest_path="$dest_dir/$filename" | |
| # Handle filename conflicts | |
| local counter=1 | |
| local base_name="${filename%.*}" | |
| local extension="${filename##*.}" | |
| while [[ -e "$dest_path" ]]; do | |
| if [[ "$filename" == "$extension" ]]; then | |
| # No extension | |
| dest_path="$dest_dir/${base_name}_${counter}" | |
| else | |
| dest_path="$dest_dir/${base_name}_${counter}.${extension}" | |
| fi | |
| ((counter++)) | |
| done | |
| if [[ $DRY_RUN == true ]]; then | |
| echo "DRY RUN: Would move '$file' → '$dest_path'" | |
| else | |
| mv "$file" "$dest_path" | |
| log_success "Moved: $filename → $age_bucket/$category/" | |
| fi | |
| } | |
| # Organize files | |
| organize_files() { | |
| log "Starting file organization..." | |
| local total_files=0 | |
| local moved_files=0 | |
| # Find all files in Downloads (excluding directories and already organized files) | |
| while IFS= read -r -d '' file; do | |
| # Skip directories | |
| if [[ -d "$file" ]]; then | |
| continue | |
| fi | |
| ((total_files++)) | |
| local filename=$(basename "$file") | |
| local extension=$(get_extension "$filename") | |
| local age_bucket=$(get_age_bucket "$file") | |
| local category=$(get_category "$extension" "$filename") | |
| log "Processing: $filename (Age: $age_bucket, Category: $category)" | |
| move_file "$file" "$age_bucket" "$category" | |
| ((moved_files++)) | |
| done < <(find "$DOWNLOADS_DIR" -maxdepth 1 -type f -print0) | |
| log_success "Organization complete! Processed $total_files files, moved $moved_files files." | |
| } | |
| # Clean empty directories | |
| clean_empty_dirs() { | |
| log "Cleaning empty directories..." | |
| if [[ $DRY_RUN == false ]]; then | |
| find "$DOWNLOADS_DIR" -type d -empty -delete 2>/dev/null || true | |
| fi | |
| log_success "Empty directories cleaned." | |
| } | |
| # Re-age organized files based on current age | |
| reage_files() { | |
| log "Starting file re-aging based on current modification dates..." | |
| local total_files=0 | |
| local moved_files=0 | |
| # Process all files in organized directories | |
| for age_dir in "$DOWNLOADS_DIR"/{3d,7d,14d,30d,old}; do | |
| if [[ -d "$age_dir" ]]; then | |
| local current_age_bucket=$(basename "$age_dir") | |
| while IFS= read -r -d '' file; do | |
| # Skip directories | |
| if [[ -d "$file" ]]; then | |
| continue | |
| fi | |
| ((total_files++)) | |
| local filename=$(basename "$file") | |
| local extension=$(get_extension "$filename") | |
| local correct_age_bucket=$(get_age_bucket "$file") | |
| local category=$(get_category "$extension" "$filename") | |
| # Only move if age bucket has changed | |
| if [[ "$current_age_bucket" != "$correct_age_bucket" ]]; then | |
| log "Re-aging: $filename ($current_age_bucket → $correct_age_bucket)" | |
| move_file "$file" "$correct_age_bucket" "$category" "reage" | |
| ((moved_files++)) | |
| else | |
| log "Age correct: $filename (staying in $current_age_bucket)" | |
| fi | |
| done < <(find "$age_dir" -type f -print0) | |
| fi | |
| done | |
| log_success "Re-aging complete! Processed $total_files files, moved $moved_files files." | |
| } | |
| # Show statistics | |
| show_stats() { | |
| log "File organization statistics:" | |
| for age_dir in "$DOWNLOADS_DIR"/{3d,7d,14d,30d,old}; do | |
| if [[ -d "$age_dir" ]]; then | |
| local age_name=$(basename "$age_dir") | |
| echo -e "${YELLOW}$age_name:${NC}" | |
| for cat_dir in "$age_dir"/*; do | |
| if [[ -d "$cat_dir" ]]; then | |
| local cat_name=$(basename "$cat_dir") | |
| local file_count=$(find "$cat_dir" -type f | wc -l) | |
| if [[ $file_count -gt 0 ]]; then | |
| echo " $cat_name: $file_count files" | |
| fi | |
| fi | |
| done | |
| fi | |
| done | |
| } | |
| # Main function | |
| main() { | |
| echo -e "${GREEN}Downloads Organizer${NC}" | |
| echo "===================" | |
| # Change to downloads directory | |
| cd "$DOWNLOADS_DIR" || { | |
| log_error "Cannot access Downloads directory: $DOWNLOADS_DIR" | |
| exit 1 | |
| } | |
| case "${1:-organize}" in | |
| "organize") | |
| create_structure | |
| organize_files | |
| clean_empty_dirs | |
| show_stats | |
| ;; | |
| "reage") | |
| create_structure | |
| reage_files | |
| clean_empty_dirs | |
| show_stats | |
| ;; | |
| "reage-dry-run") | |
| DRY_RUN=true | |
| log_warning "Running REAGE in DRY RUN mode - no files will be moved" | |
| create_structure | |
| reage_files | |
| ;; | |
| "stats") | |
| show_stats | |
| ;; | |
| "dry-run") | |
| DRY_RUN=true | |
| log_warning "Running in DRY RUN mode - no files will be moved" | |
| create_structure | |
| organize_files | |
| ;; | |
| "help") | |
| echo "Usage: $0 [organize|reage|stats|dry-run|reage-dry-run|help]" | |
| echo "" | |
| echo "Commands:" | |
| echo " organize - Organize files (default)" | |
| echo " reage - Move files to correct age folders based on current date" | |
| echo " reage-dry-run - Preview re-aging without moving files" | |
| echo " stats - Show organization statistics" | |
| echo " dry-run - Show what would be moved without moving" | |
| echo " help - Show this help" | |
| echo "" | |
| echo "Environment variables:" | |
| echo " DRY_RUN=true - Run without moving files" | |
| echo " VERBOSE=false - Reduce output" | |
| ;; | |
| *) | |
| log_error "Unknown command: $1" | |
| echo "Use '$0 help' for usage information" | |
| exit 1 | |
| ;; | |
| esac | |
| } | |
| # Run main function with all arguments | |
| main "$@" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
./organize.sh [organize|reage|stats|dry-run|reage-dry-run|help]