Explorador
Práctica

Práctica: script que organiza archivos por tipo

Un script de bash construido en 5 versiones, cada una con un concepto nuevo, de validar argumentos a mover archivos sin perder datos.

Actualizado el

En esta guía

El script se construye en 5 versiones incrementales. Cada una añade un concepto y se puede ejecutar y probar sola.

Preparación: carpeta de pruebas

mkdir -p ~/proyectos/organizador/caos && cd ~/proyectos/organizador
git init
touch caos/foto.JPG caos/informe.pdf caos/cancion.mp3 caos/video.mp4 \
      caos/backup.tar.gz caos/script.py caos/README caos/notas.txt
mkdir caos/subcarpeta

touch crea archivos vacíos, y para el script solo importa el nombre. Un desorden fácil de regenerar permite probar muchas veces: si algo sale mal, rm -rf caos y se vuelve a crear.

Versión 1: recibir y validar el argumento

Conceptos: argumentos ($1, $#, $0), if, códigos de salida.

#!/usr/bin/env bash
# organizar.sh - Ordena los archivos de un directorio en carpetas por tipo
# Uso: ./organizar.sh <directorio>

if [ $# -ne 1 ]; then
    echo "Uso: $0 <directorio>" >&2
    exit 1
fi

directorio="$1"

if [ ! -d "$directorio" ]; then
    echo "Error: '$directorio' no es un directorio válido" >&2
    exit 1
fi

echo "Voy a organizar: $directorio"
Pieza Qué significa
$# Cantidad de argumentos recibidos
$1 Primer argumento. $0 es el nombre del script
-ne “not equal” para números. También -eq, -lt, -gt
[ ... ] Es un comando (type [ lo demuestra). Los espacios dentro son obligatorios
-d Verdadero si es un directorio. Otros: -f archivo, -e existe
! Niega la condición
>&2 Manda el mensaje a stderr
exit 1 Termina con código de error (0 es éxito)
chmod +x organizar.sh
./organizar.sh               # Uso: ./organizar.sh <directorio>
./organizar.sh /noexiste     # Error
./organizar.sh caos          # Voy a organizar: caos
echo $?                      # 0

Validar al inicio y salir (fail fast) es mejor que dejar que el script falle más adelante con un error confuso.

git add . && git commit -m "v1: validación de argumentos"

Versión 2: recorrer los archivos

Conceptos: bucle for, comodines (globbing), comillas, basename.

Reemplaza el último echo por:

for ruta in "$directorio"/*; do
    [ -f "$ruta" ] || continue
    nombre=$(basename "$ruta")
    echo "Encontré: $nombre"
done
Pieza Qué significa
"$directorio"/* La shell expande * a la lista de archivos antes de correr el bucle
for ruta in ... Repite el bloque una vez por cada elemento
[ -f "$ruta" ] || continue Si no es archivo regular, salta a la siguiente vuelta. Así se ignoran las carpetas
$(comando) Sustitución de comandos: ejecuta y reemplaza por su salida
basename Quita la ruta: caos/foto.JPG pasa a foto.JPG

Puntos que sorprenden:

  • Las comillas dobles alrededor de "$ruta" son obligatorias. Sin ellas, un archivo llamado mi foto.png se partiría en dos palabras. Se comprueba con touch "caos/mi foto.png" y quitando las comillas.
  • El * no incluye archivos ocultos (los que empiezan con .), lo cual aquí conviene.
  • Si el directorio está vacío, * queda literal y -f falla. El continue también protege ese caso.
git commit -am "v2: recorrer archivos"

Versión 3: obtener la extensión

Conceptos: expansión de parámetros, [[ ]], minúsculas.

Dentro del bucle, después de nombre=...:

    if [[ "$nombre" == *.* ]]; then
        extension="${nombre##*.}"
        extension="${extension,,}"
    else
        extension=""
    fi
    echo "$nombre -> extensión: '$extension'"
Pieza Qué significa
[[ ... ]] Versión mejorada de [ ], exclusiva de bash. Permite patrones con *. Por esto el shebang no es sh
*.* Patrón: “contiene al menos un punto”
${nombre##*.} Borra el prefijo más largo que termine en . y deja solo la extensión
${extension,,} Convierte a minúsculas, para que JPG y jpg se traten igual

Para entender ##, conviene probarlo en la terminal:

f="backup.tar.gz"
echo ${f##*.}     # gz          (## quita lo más largo)
echo ${f#*.}      # tar.gz      (#  quita lo más corto)
echo ${f%.*}      # backup.tar  (%  quita desde el final)

Caso borde: README no tiene punto, así que queda con extensión vacía. Por eso existe el else.

git commit -am "v3: extraer extensión"

Versión 4: decidir la categoría

Conceptos: funciones, case, echo como “valor de retorno”.

Antes del bucle (las funciones se definen antes de usarse):

categoria() {
    case "$1" in
        jpg|jpeg|png|gif|bmp|svg|webp)         echo "Imagenes" ;;
        pdf|doc|docx|txt|odt|md|xlsx|csv|pptx) echo "Documentos" ;;
        mp3|wav|flac|ogg)                      echo "Audio" ;;
        mp4|mkv|avi|mov)                       echo "Videos" ;;
        zip|tar|gz|rar|7z)                     echo "Comprimidos" ;;
        sh|py|js|c|html|css|java)              echo "Codigo" ;;
        *)                                     echo "Otros" ;;
    esac
}

En el bucle, el echo de depuración se reemplaza por:

    carpeta=$(categoria "$extension")
    echo "$nombre -> $carpeta"
Pieza Qué significa
categoria() { ... } Define una función. Se llama como un comando
$1 dentro de la función Es el argumento de la función, no del script
case ... esac Un if/elif múltiple para comparar contra patrones
a|b|c) El | significa “o”
;; Fin de cada caso
*) Caso por defecto, lo que no coincidió
$(categoria ...) Captura lo que la función imprime con echo

En bash las funciones no “retornan” textos: imprimen, y quien las llama captura la salida. El return solo devuelve códigos numéricos de éxito o error.

Esta versión todavía no mueve nada. Es buena práctica ver qué haría el script antes de dejar que modifique archivos.

git commit -am "v4: clasificación por categoría"

Versión 5: mover los archivos

Conceptos: mkdir -p, mv, seguridad ante colisiones, aritmética.

Antes del bucle van los contadores:

movidos=0
omitidos=0

Y el echo de depuración se reemplaza por el bloque final:

    carpeta=$(categoria "$extension")
    destino="$directorio/$carpeta"

    mkdir -p "$destino"

    if [ -e "$destino/$nombre" ]; then
        echo "  OMITIDO: $nombre (ya existe en $carpeta/)"
        omitidos=$((omitidos + 1))
        continue
    fi

    mv "$ruta" "$destino/"
    echo "  $nombre -> $carpeta/"
    movidos=$((movidos + 1))
done

echo "Listo: $movidos movidos, $omitidos omitidos."
Pieza Qué significa
mkdir -p Crea la carpeta si no existe, sin error si ya existe
mv origen destino/ Mueve. La / final indica “esto es una carpeta”
-e Verdadero si la ruta existe
$((a + 1)) Aritmética entera. Sin $(( )), bash trataría + como texto

mv sobrescribe en silencio. Sin la verificación con -e, mover un segundo informe.pdf destruiría el primero sin avisar.

touch caos/informe.pdf      # un duplicado
./organizar.sh caos         # OMITIDO: informe.pdf

La lección de fondo: un script que modifica archivos debe pensar primero en qué puede salir mal.

git commit -am "v5: mover archivos con protección"

Script final

#!/usr/bin/env bash
# organizar.sh - Ordena los archivos de un directorio en carpetas por tipo
# Uso: ./organizar.sh <directorio>

if [ $# -ne 1 ]; then
    echo "Uso: $0 <directorio>" >&2
    exit 1
fi

directorio="$1"

if [ ! -d "$directorio" ]; then
    echo "Error: '$directorio' no es un directorio válido" >&2
    exit 1
fi

categoria() {
    case "$1" in
        jpg|jpeg|png|gif|bmp|svg|webp)         echo "Imagenes" ;;
        pdf|doc|docx|txt|odt|md|xlsx|csv|pptx) echo "Documentos" ;;
        mp3|wav|flac|ogg)                      echo "Audio" ;;
        mp4|mkv|avi|mov)                       echo "Videos" ;;
        zip|tar|gz|rar|7z)                     echo "Comprimidos" ;;
        sh|py|js|c|html|css|java)              echo "Codigo" ;;
        *)                                     echo "Otros" ;;
    esac
}

movidos=0
omitidos=0

for ruta in "$directorio"/*; do
    [ -f "$ruta" ] || continue

    nombre=$(basename "$ruta")

    if [[ "$nombre" == *.* ]]; then
        extension="${nombre##*.}"
        extension="${extension,,}"
    else
        extension=""
    fi

    carpeta=$(categoria "$extension")
    destino="$directorio/$carpeta"

    mkdir -p "$destino"

    if [ -e "$destino/$nombre" ]; then
        echo "  OMITIDO: $nombre (ya existe en $carpeta/)"
        omitidos=$((omitidos + 1))
        continue
    fi

    mv "$ruta" "$destino/"
    echo "  $nombre -> $carpeta/"
    movidos=$((movidos + 1))
done

echo "Listo: $movidos movidos, $omitidos omitidos."

Resultado esperado sobre la carpeta caos:

caos/
├── Audio/        cancion.mp3
├── Codigo/       script.py
├── Comprimidos/  backup.tar.gz
├── Documentos/   informe.pdf, notas.txt
├── Imagenes/     foto.JPG
├── Otros/        README
├── Videos/       video.mp4
└── subcarpeta/   (no se toca)

Depurar y verificar

  • Probar siempre sobre una copia o sobre caos/, nunca sobre la carpeta personal en la primera ejecución.
  • bash -x ./organizar.sh caos imprime cada línea que se ejecuta con sus variables ya expandidas. Es la mejor herramienta para entender qué hace un script.
  • Para verificar el resultado: tree caos o find caos -type f | sort.

Retos para profundizar

  • Modo simulación: con --dry-run solo muestra qué haría, sin mover nada. Obliga a procesar opciones y a separar decidir de actuar.
  • Renombrar duplicados en lugar de omitirlos (informe_1.pdf, informe_2.pdf). Requiere un bucle while.
  • Resumen final con la cantidad de archivos por categoría.
  • Deshacer: que el script guarde con >> un registro de cada movimiento y otro script lo revierta.
  • Evitar que el script se mueva a sí mismo si se ejecuta dentro de su propia carpeta. Pista: [ "$ruta" -ef "$0" ].