Skip to content

Solution — File Organizer

Full reference implementation. Study this after attempting the project yourself.

file_organizer.sh

#!/usr/bin/env bash
# =============================================================================
# file_organizer.sh — Sort files into category subdirectories
#
# Usage:
#   file_organizer.sh [OPTIONS] [TARGET_DIR]
#
# Options:
#   -d, --dry-run     Show moves without executing
#   -l, --log FILE    Log file path (default: ./organizer.log)
#   -v, --verbose     Print each action to stdout
#   -h, --help        Show this help
# =============================================================================
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOGFILE="${SCRIPT_DIR}/organizer.log"
DRY_RUN=false
VERBOSE=false
TARGET_DIR="."

# ── usage ────────────────────────────────────────────────────────────────────
usage() {
    cat <<EOF
Usage: $(basename "$0") [OPTIONS] [TARGET_DIR]

  -d, --dry-run     Show what would be moved without executing
  -l, --log FILE    Write log to FILE (default: ./organizer.log)
  -v, --verbose     Print each action to stdout
  -h, --help        Show this help

TARGET_DIR defaults to the current directory.
EOF
}

# ── argument parsing ──────────────────────────────────────────────────────────
parse_args() {
    while [[ "${1:-}" =~ ^- ]]; do
        case "$1" in
            -d|--dry-run)  DRY_RUN=true  ;;
            -v|--verbose)  VERBOSE=true  ;;
            -l|--log)      LOGFILE="${2:?--log requires a value}"; shift ;;
            -h|--help)     usage; exit 0 ;;
            *) echo "Unknown option: $1" >&2; usage; exit 1 ;;
        esac
        shift
    done
    TARGET_DIR="${1:-.}"
}

# ── logging ───────────────────────────────────────────────────────────────────
_log() {
    local level="$1"; shift
    local msg="[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $*"
    echo "$msg" >> "$LOGFILE"
    [[ "$VERBOSE" == true || "$level" == "ERROR" ]] && echo "$msg" >&2
}
log_info()  { _log "INFO " "$@"; }
log_warn()  { _log "WARN " "$@"; }
log_error() { _log "ERROR" "$@"; }

# ── extension → category ──────────────────────────────────────────────────────
get_category() {
    local ext="${1,,}"
    case "$ext" in
        jpg|jpeg|png|gif|bmp|svg|webp|ico|tiff)    echo "Images"    ;;
        pdf|doc|docx|txt|md|odt|rtf|csv|xlsx|pptx) echo "Documents" ;;
        mp4|mkv|avi|mov|wmv|flv|webm)              echo "Videos"    ;;
        mp3|wav|flac|aac|ogg|m4a)                  echo "Audio"     ;;
        zip|tar|gz|bz2|xz|7z|rar|tgz)             echo "Archives"  ;;
        sh|py|js|ts|go|rs|c|cpp|h|java|rb|php)    echo "Code"      ;;
        *)                                          echo "Other"     ;;
    esac
}

# ── deduplication: return a unique destination path ───────────────────────────
unique_dest() {
    local dest="$1"
    [[ ! -e "$dest" ]] && echo "$dest" && return

    local dir base ext counter=1
    dir="$(dirname "$dest")"
    base="$(basename "$dest")"
    if [[ "$base" == *.* ]]; then
        ext=".${base##*.}"
        base="${base%.*}"
    else
        ext=""
    fi
    while [-e "${dir}/${base}_${counter}${ext}"](<../../-e "${dir}/${base}_${counter}${ext}".md>); do
        (( counter++ ))
    done
    echo "${dir}/${base}_${counter}${ext}"
}

# ── move one file ─────────────────────────────────────────────────────────────
move_file() {
    local src="$1" dest_dir="$2"
    local filename dest
    filename="$(basename "$src")"
    dest="$(unique_dest "${dest_dir}/${filename}")"

    if [[ "$DRY_RUN" == true ]]; then
        log_info "[DRY RUN] would move: $src$dest"
        return 0
    fi

    mkdir -p "$dest_dir"
    if mv -- "$src" "$dest"; then
        log_info "moved: $src$dest"
        return 0
    else
        log_error "failed to move: $src"
        return 1
    fi
}

# ── main ──────────────────────────────────────────────────────────────────────
main() {
    parse_args "$@"

    local target
    target="$(realpath "$TARGET_DIR")"

    [[ -d "$target" ]] || { echo "Error: '$target' is not a directory" >&2; exit 1; }
    [[ -r "$target" ]] || { echo "Error: '$target' is not readable" >&2;  exit 1; }

    log_info "=== Starting organizer on $target (dry_run=$DRY_RUN) ==="

    local moved=0 skipped=0 failed=0

    while IFS= read -r -d '' file; do
        local base
        base="$(basename "$file")"

        # Skip dotfiles
        if [[ "$base" == .* ]]; then
            log_info "skipped dotfile: $file"
            (( skipped++ )) || true
            continue
        fi

        # Get extension and category
        local ext category dest_dir
        [[ "$base" == *.* ]] && ext="${base##*.}" || ext=""
        category="$(get_category "$ext")"
        dest_dir="${target}/${category}"

        # Already in the right place
        if [[ "$(dirname "$file")" == "$dest_dir" ]]; then
            (( skipped++ )) || true
            continue
        fi

        if move_file "$file" "$dest_dir"; then
            (( moved++ )) || true
        else
            (( failed++ )) || true
        fi
    done < <(find "$target" -maxdepth 1 -type f -print0)

    log_info "=== Done: moved=$moved skipped=$skipped failed=$failed ==="
    printf "Done: moved=%-4d skipped=%-4d failed=%d\n" "$moved" "$skipped" "$failed"

    [[ "$failed" -eq 0 ]]
}

[[ "${BASH_SOURCE[0]}" == "$0" ]] && main "$@"

Sample Output

$ bash file_organizer.sh --dry-run --verbose ~/Downloads/ [2024-01-15 10:00:00] [INFO ] === Starting organizer on /home/user/Downloads (dry_run=true) === [2024-01-15 10:00:00] [INFO ] [DRY RUN] would move: .../photo.jpg → .../Images/photo.jpg [2024-01-15 10:00:00] [INFO ] [DRY RUN] would move: .../report.pdf → .../Documents/report.pdf [2024-01-15 10:00:00] [INFO ] [DRY RUN] would move: .../lecture.mp4 → .../Videos/lecture.mp4 [2024-01-15 10:00:00] [INFO ] skipped dotfile: .../.DS_Store [2024-01-15 10:00:00] [INFO ] === Done: moved=0 skipped=1 failed=0 === Done: moved=0 skipped=1 failed=0
$ bash file_organizer.sh ~/Downloads/ Done: moved=47 skipped=3 failed=0

Key Design Decisions

Decision Why
find -maxdepth 1 -print0 Null-delimited: safe for filenames with spaces, tabs, newlines
read -r -d '' Paired with -print0: reads null-terminated records
${ext,,} for lowercase bash 4+ built-in; avoids spawning tr per file
unique_dest before move Prevents silent overwrites without using -n (which silently fails)
|| true on (( counter++ )) Arithmetic expansion returns 1 when result is 0; -e would exit
realpath on TARGET_DIR Resolves symlinks and relative paths for log clarity
BASH_SOURCE guard Enables source-based unit testing of individual functions

enhancements | overview