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
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