Skip to content

Implementation — File Organizer

Build the script step by step. Each step is independently runnable.

Step 1 — Scaffold and Argument Parsing

#!/usr/bin/env bash
set -euo pipefail

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

usage() {
    cat <<EOF
Usage: $0 [OPTIONS] [TARGET_DIR]

  -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

TARGET_DIR defaults to the current directory.
EOF
}

# Parse arguments
while [[ "${1:-}" =~ ^- ]]; do
    case "$1" in
        -d|--dry-run)  DRY_RUN=true  ;;
        -v|--verbose)  VERBOSE=true  ;;
        -l|--log)      LOGFILE="$2"; shift ;;
        -h|--help)     usage; exit 0 ;;
        *) echo "Unknown option: $1" >&2; usage; exit 1 ;;
    esac
    shift
done

TARGET_DIR="${1:-.}"

Test: bash -n file_organizer.sh and bash file_organizer.sh --help.

Step 2 — Logging Function

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

Step 3 — Extension-to-Category Mapping

Use a case statement to return the category for any file extension:

get_category() {
    local ext="${1,,}"   # lowercase — requires bash 4+
    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
}

Test the mapping before moving any files

source file_organizer.sh
get_category jpg      # → Images
get_category PDF      # → Documents (case-insensitive)
get_category unknown  # → Other

Step 4 — Deduplication Helper

When the destination file already exists, append _1, _2, etc.:

unique_dest() {
    local dest="$1"
    [[ ! -e "$dest" ]] && echo "$dest" && return

    local dir base ext counter=1
    dir="$(dirname "$dest")"
    base="$(basename "$dest")"

    # Split name and extension
    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}"
}

Why split name and extension manually

basename --suffix=.jpg would work for known extensions, but our files can have any extension (or none). The shell parameter expansions ${base##*.} and ${base%.*} handle the general case without external tools.

Step 5 — The Move Function

move_file() {
    local src="$1"
    local dest_dir="$2"
    local filename
    filename="$(basename "$src")"
    local dest
    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
}

Always use -- before filenames with mv

mv -- "$src" "$dest" protects against filenames starting with -. Without --, a file named -f would be interpreted as a flag.

Step 6 — Main Organizer Loop

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

    [[ -d "$target" ]] || { echo "Not a directory: $target" >&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
        # Skip dotfiles
        local basename
        basename="$(basename "$file")"
        if [[ "$basename" == .* ]]; then
            log_info "skipped dotfile: $file"
            (( skipped++ )) || true
            continue
        fi

        # Get extension and category
        local ext category dest_dir
        if [[ "$basename" == *.* ]]; then
            ext="${basename##*.}"
        else
            ext=""
        fi
        category="$(get_category "$ext")"
        dest_dir="${target}/${category}"

        # Skip if 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 ==="
    echo "Done: moved=$moved  skipped=$skipped  failed=$failed"

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

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

Why find -print0 and read -d ''

Files with spaces, newlines, or special characters in their names break for file in $(ls ...). The find -print0 / read -r -d '' pair uses the null byte as a delimiter, which cannot appear in a filename — so it handles all edge cases safely.

Step 7 — Validate Input Directory

Add this before the find loop:

if [[ ! -d "$target" ]]; then
    echo "Error: '$target' is not a directory" >&2
    exit 1
fi

if [[ ! -r "$target" ]]; then
    echo "Error: '$target' is not readable" >&2
    exit 1
fi

setup | testing