#!/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 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 "$HOME/.local/bin/clean" ]; then "$HOME/.local/bin/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