#!/usr/bin/env bash
# ==============================================================
# Sort - Downloads cleaner
# --------------------------------------------------------------
# Author : Kris Yotam (aka. khr1st)
# Contact : krisyotam@protonmail.com
# License : GNU GPLv3
# Date : 2025-12-04
# --------------------------------------------------------------
# Description:
# Minimal, suckless-style bin script to tidy a Downloads
# directory by moving files into categorized subfolders under
# "$HOME/Misc" (Compressed, Installers, Packages, Other) and
# user directories (Pictures, Music, Videos). Shows a small TUI
# grid progress indicator and prints each move as it happens.
# At the end the script offers to run the separate `clean`
# utility which interactively prunes files in "$HOME/Misc".
# ==============================================================
set -eu
# Configuration
DEFAULT_SRCS=("$HOME/Downloads" "$HOME/downloads")
MISC_BASE="$HOME/Misc"
DIR_COMPRESSED="Compressed"
DIR_INSTALLERS="Installers"
DIR_PACKAGES="Packages"
DIR_OTHER="Other"
# User dirs
PICTURES_DIR="$HOME/Pictures"
MUSIC_DIR="$HOME/Music"
VIDEOS_DIR="$HOME/Videos"
# Grid config (10x10 => 100 cells mapping to percent)
GRID_COLS=10
GRID_ROWS=10
GRID_CELLS=$((GRID_COLS * GRID_ROWS))
declare -a FILES=()
declare -a LOGS=()
ext_lower() {
# Get lowercase extension without leading dot
fname="$1"
ext="${fname##*.}"
printf "%s" "${ext,,}"
}
ensure_dirs() {
mkdir -p "$MISC_BASE/$DIR_COMPRESSED" \
"$MISC_BASE/$DIR_INSTALLERS" \
"$MISC_BASE/$DIR_PACKAGES" \
"$MISC_BASE/$DIR_OTHER" \
"$PICTURES_DIR" "$MUSIC_DIR" "$VIDEOS_DIR"
}
pick_dest() {
filename="$1"
ext="$(ext_lower "$filename")"
case "$ext" in
# Compressed
zip|tar|gz|tgz|tbz|tbz2|bz2|xz|7z|rar|tar.gz|tar.bz2|tar.xz)
printf "%s" "$MISC_BASE/$DIR_COMPRESSED" ;;
# Images -> Pictures
jpg|jpeg|png|gif|webp|svg|bmp|tif|tiff|heic)
printf "%s" "$PICTURES_DIR" ;;
# Audio -> Music
mp3|wav|flac|m4a|aac|ogg|opus|wma)
printf "%s" "$MUSIC_DIR" ;;
# Video -> Videos
mp4|mkv|webm|mov|avi|mpeg|mpg|flv|3gp)
printf "%s" "$VIDEOS_DIR" ;;
# Installers
exe|msi|dmg|AppImage|sh|run)
printf "%s" "$MISC_BASE/$DIR_INSTALLERS" ;;
# Packages
deb|rpm|pkg|apk)
printf "%s" "$MISC_BASE/$DIR_PACKAGES" ;;
*) printf "%s" "$MISC_BASE/$DIR_OTHER" ;;
esac
}
unique_dest() {
destdir="$1"
base="$2"
dest="$destdir/$base"
if [ ! -e "$dest" ]; then
printf "%s" "$dest"
return 0
fi
i=1
name="${base%.*}"
ext=""
if [[ "$base" == *.* ]]; then
ext=".${base##*.}"
fi
while :; do
candidate="$destdir/${name}_$i$ext"
if [ ! -e "$candidate" ]; then
printf "%s" "$candidate" && return 0
fi
i=$((i + 1))
done
}
move_one() {
src="$1"
bn="$(basename -- "$src")"
destdir="$(pick_dest "$bn")"
mkdir -p "$destdir"
dest="$(unique_dest "$destdir" "$bn")"
if mv -- "$src" "$dest"; then
LOGS+=("Moved: $bn -> $(realpath --relative-to="$HOME" "$dest")")
echo "Moved: $bn -> $(realpath --relative-to="$HOME" "$dest")"
else
LOGS+=("Failed: $bn")
echo "Failed to move: $bn" >&2
fi
}
gather_files() {
src="$1"
# gather regular files only (no dirs)
while IFS= read -r -d '' f; do
FILES+=("$f")
done < <(find "$src" -maxdepth 1 -type f -print0 2>/dev/null)
}
draw_grid() {
filled_cells=$1
columns=$GRID_COLS
rows=$GRID_ROWS
idx=0
for ((r=0;r<rows;r++)); do
line=""
for ((c=0;c<columns;c++)); do
idx=$((r*columns + c + 1))
if [ $idx -le $filled_cells ]; then
cell='[**]'
else
cell='[ ]'
fi
line+="$cell"
done
printf "%s\n" "$line"
done
}
draw_tui() {
total=$1
done=$2
pct=0
if [ "$total" -gt 0 ]; then
pct=$((done * 100 / total))
fi
# map done -> number of filled cells
filled_cells=0
if [ "$total" -gt 0 ]; then
filled_cells=$((done * GRID_CELLS / total))
fi
clear
printf "Sort: %d/%d files (%d%%)\n\n" "$done" "$total" "$pct"
draw_grid $filled_cells
printf "\nRecent actions:\n"
# show last 8 logs
start=0
if [ ${#LOGS[@]} -gt 8 ]; then
start=$((${#LOGS[@]} - 8))
fi
for ((i=start;i<${#LOGS[@]};i++)); do
printf " %s\n" "${LOGS[$i]}"
done
}
main() {
srcdir=""
if [ $# -ge 1 ]; then
srcdir="$1"
else
# pick first existing default
for cand in "${DEFAULT_SRCS[@]}"; do
[ -d "$cand" ] && { srcdir="$cand"; break; }
done
fi
if [ -z "$srcdir" ]; then
echo "No Downloads folder found. Specify a directory: sort /path/to/dir" >&2
exit 1
fi
ensure_dirs
gather_files "$srcdir"
total=${#FILES[@]}
if [ "$total" -eq 0 ]; then
echo "No files to sort in $srcdir"
exit 0
fi
moved=0
# iterate and move files
for f in "${FILES[@]}"; do
move_one "$f"
moved=$((moved + 1))
# update tui
draw_tui "$total" "$moved"
# tiny pause so user sees progress
sleep 0.08
done
printf "\nDone. Moved %d files into %s and user dirs.\n" "$moved" "$MISC_BASE"
# Offer to run clean
printf "\nRun interactive clean on %s? [y/N]: " "$MISC_BASE"
IFS= read -r ans || ans=""
case "$ans" in
[yY]|[yY][eE][sS])
# call clean script if exists else run inline
if [ -x clean ]; then
clean "$MISC_BASE"
elif [ -x "/usr/local/bin/clean" ]; then
"/usr/local/bin/clean" "$MISC_BASE"
else
# try to call script in repo
if [ -x "$(dirname "$0")/clean" ]; then
"$(dirname "$0")/clean" "$MISC_BASE"
else
echo "clean utility not found. Skipping." >&2
fi
fi
;;
*) echo "Skipping clean." ;;
esac
}
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
main "$@"
fi