← back to Desktop Sorter

sort-desktop.sh

95 lines

#!/bin/bash
# Desktop sorter — keep loose items on the Desktop for 3 days, then file them
# into category subfolders by type. Reversible (every move is logged).
#
# Excludes ALWAYS: .app bundles, symlinks/aliases, dotfiles, and the category
# folders themselves (so it never re-sweeps its own buckets).
#
# Usage:
#   ./sort-desktop.sh            # live run
#   DRY_RUN=1 ./sort-desktop.sh  # preview only, moves nothing
#   AGE_DAYS=7 ./sort-desktop.sh # override the 3-day threshold
set -euo pipefail

DESKTOP="$HOME/Desktop"
LOG_DIR="$HOME/Projects/desktop-sorter/logs"
mkdir -p "$LOG_DIR"
LOG="$LOG_DIR/sort-$(date +%Y-%m-%d).log"

DRY_RUN="${DRY_RUN:-0}"
AGE_DAYS="${AGE_DAYS:-3}"
CUTOFF=$(( $(date +%s) - AGE_DAYS * 86400 ))

# Category buckets — also the exclusion set (never re-sweep our own folders).
CATEGORIES=(Images Videos Audio Documents Data Web Scripts Folders Misc)

is_category() {
  local name="$1" c
  for c in "${CATEGORIES[@]}"; do [ "$name" = "$c" ] && return 0; done
  return 1
}

category_for_ext() {
  case "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" in
    png|jpg|jpeg|gif|heic|webp|tiff|tif|bmp|svg)            echo Images ;;
    mov|mp4|m4v|avi|mkv|webm)                               echo Videos ;;
    m4a|aifc|aiff|mp3|wav|aac|flac)                         echo Audio ;;
    md|txt|pdf|csv|tsv|xlsx|xls|doc|docx|ppt|pptx|rtf|pages|numbers|key) echo Documents ;;
    json|ics|xml|yaml|yml|plist|bak|log)                   echo Data ;;
    html|htm|css|js|mjs)                                    echo Web ;;
    command|sh|zsh|py|rb|pl|bash)                           echo Scripts ;;
    *)                                                      echo Misc ;;
  esac
}

moved=0
shopt -s nullglob
cd "$DESKTOP"

for item in *; do
  case "$item" in .*) continue ;; esac          # skip dotfiles (belt + suspenders)
  [ -L "$item" ] && continue                     # never touch symlinks/aliases
  case "$item" in *.app) continue ;; esac        # never touch app bundles
  is_category "$item" && continue                # skip our own buckets

  mtime=$(stat -f %m "$item")
  [ "$mtime" -ge "$CUTOFF" ] && continue          # younger than AGE_DAYS → keep on Desktop

  if [ -d "$item" ]; then
    dest="Folders"
  elif [ "$item" = "${item##*.}" ]; then
    dest="Misc"                                   # no extension
  else
    dest="$(category_for_ext "${item##*.}")"
  fi

  mkdir -p "$DESKTOP/$dest"
  target="$DESKTOP/$dest/$item"
  if [ -e "$target" ]; then                       # collision-safe rename
    ts=$(date +%H%M%S)
    if [ "$item" = "${item##*.}" ] || [ -d "$item" ]; then
      target="$DESKTOP/$dest/${item}-${ts}"
    else
      target="$DESKTOP/$dest/${item%.*}-${ts}.${item##*.}"
    fi
  fi

  if [ "${MANIFEST:-0}" = "1" ]; then
    # Emit NUL-delimited src + final target for an external parallel driver.
    printf '%s\0%s\0' "$DESKTOP/$item" "$target"
  elif [ "$DRY_RUN" = "1" ]; then
    printf '[DRY] %s  ->  %s/\n' "$item" "$dest"
  else
    mv "$item" "$target"
    printf '%s  %s  ->  %s/\n' "$(date '+%F %T')" "$item" "$dest" >> "$LOG"
    moved=$((moved + 1))
  fi
done

if [ "$DRY_RUN" = "1" ]; then
  printf '\nDry run complete — nothing moved (AGE_DAYS=%s).\n' "$AGE_DAYS"
else
  printf '%s  Done. Moved %s item(s) (AGE_DAYS=%s).\n' "$(date '+%F %T')" "$moved" "$AGE_DAYS" >> "$LOG"
  printf 'Moved %s item(s). Log: %s\n' "$moved" "$LOG"
fi