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 |