1040 lines
37 KiB
Bash
1040 lines
37 KiB
Bash
#!/usr/bin/env bash
|
||
# ------------------------------------------------------------------------------
|
||
# Copyright (c) 2013-2026 Geoffray Levasseur <fatalerrors@geoffray-levasseur.org>
|
||
# Protected by the BSD3 license. Please read bellow for details.
|
||
#
|
||
# * Redistribution and use in source and binary forms,
|
||
# * with or without modification, are permitted provided
|
||
# * that the following conditions are met:
|
||
# *
|
||
# * Redistributions of source code must retain the above
|
||
# * copyright notice, this list of conditions and the
|
||
# * following disclaimer.
|
||
# *
|
||
# * Redistributions in binary form must reproduce the above
|
||
# * copyright notice, this list of conditions and the following
|
||
# * disclaimer in the documentation and/or other materials
|
||
# * provided with the distribution.
|
||
# *
|
||
# * Neither the name of the copyright holder nor the names
|
||
# * of any other contributors may be used to endorse or
|
||
# * promote products derived from this software without
|
||
# * specific prior written permission.
|
||
# *
|
||
# * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
|
||
# * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
||
# * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
|
||
# * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||
# * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
|
||
# * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||
# * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
|
||
# * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||
# * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||
# * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
|
||
# * OF SUCH DAMAGE.
|
||
# ------------------------------------------------------------------------------
|
||
|
||
# Common helpers (helpers/common.sh)
|
||
# shellcheck source=../helpers/common.sh
|
||
declare -F _profile_load_helpers >/dev/null || . "$(dirname -- "${BASH_SOURCE[0]}")/../helpers/common.sh"
|
||
|
||
load_conf "filefct"
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Expand wildcards in a file/directory list and quote the results: each match is
|
||
# wrapped in double quotes (with \, ", $ and ` escaped) and the matches are
|
||
# joined with the separator. Items without wildcards are quoted as they are.
|
||
# Usage: expandlist [options] <item1 [item2 ... itemN]>
|
||
expandlist()
|
||
{
|
||
local separator="${EXPANDLIST_DEFAULT_SEPARATOR:- }"
|
||
local PARSED
|
||
PARSED=$(getopt -o hs:n --long help,separator:,newline -n 'expandlist' -- "$@")
|
||
# shellcheck disable=SC2181 # getopt return code is checked immediately after
|
||
if [[ $? -ne 0 ]]; then
|
||
disp E "Invalid options, use \"expandlist --help\" to display usage."
|
||
return 1
|
||
fi
|
||
# Restore the getopt-normalised (shell-quoted) argument list
|
||
local -a _pargs=()
|
||
_getopt_split _pargs "$PARSED"
|
||
set -- "${_pargs[@]}"
|
||
|
||
while true; do
|
||
case "$1" in
|
||
-h|--help)
|
||
printf "expandlist: expand globs and wrap matched items in double quotes.\n\n"
|
||
printf "Usage: expandlist [options] <item1 [item2 ... itemN]>\n\n"
|
||
printf "Options:\n"
|
||
printf "\t-h, --help\t\tDisplay this help screen\n"
|
||
printf "\t-s, --separator SEP\tSet output separator (default: space); \\\\n, \\\\t... are interpreted\n"
|
||
printf "\t-n, --newline\t\tUse a newline as separator\n"
|
||
return 0
|
||
;;
|
||
-s|--separator)
|
||
separator="$2"
|
||
shift 2
|
||
;;
|
||
-n|--newline)
|
||
separator=$'\n'
|
||
shift
|
||
;;
|
||
--)
|
||
shift
|
||
break
|
||
;;
|
||
*)
|
||
disp E "Invalid options, use \"expandlist --help\" to display usage."
|
||
return 1
|
||
;;
|
||
esac
|
||
done
|
||
|
||
# Backslash escapes in the separator (\n, \t, ...) are interpreted, so the
|
||
# configuration file can express a newline as EXPANDLIST_DEFAULT_SEPARATOR=\n
|
||
printf -v separator '%b' "$separator"
|
||
|
||
local item="" result="" matched=0
|
||
# Restore the caller's nullglob setting on exit instead of forcing it off.
|
||
local _had_nullglob=0
|
||
shopt -q nullglob && _had_nullglob=1
|
||
shopt -s nullglob
|
||
# An empty IFS keeps the unquoted expansion below from splitting on spaces
|
||
# (it would cut "my dir/*.txt" in two) while globbing still happens.
|
||
local IFS=
|
||
|
||
for item in "$@"; do
|
||
local expanded=()
|
||
|
||
# True glob expansion when wildcards are present.
|
||
if [[ "$item" == *'*'* || "$item" == *'?'* || "$item" == *'['* ]]; then
|
||
# shellcheck disable=SC2206 # Intentional glob expansion, no splitting (IFS is empty)
|
||
expanded=( $item )
|
||
else
|
||
expanded=( "$item" )
|
||
fi
|
||
|
||
if [[ ${#expanded[@]} -eq 0 ]]; then
|
||
continue
|
||
fi
|
||
|
||
local content
|
||
for content in "${expanded[@]}"; do
|
||
if (( matched )); then
|
||
result+="$separator"
|
||
fi
|
||
# Escape what stays special inside double quotes so that the output
|
||
# can safely be re-evaluated (eval, command substitution, ...).
|
||
content=${content//\\/\\\\}
|
||
content=${content//\"/\\\"}
|
||
content=${content//\$/\\\$}
|
||
content=${content//\`/\\\`}
|
||
result+="\"$content\""
|
||
matched=1
|
||
done
|
||
unset content
|
||
done
|
||
|
||
(( _had_nullglob )) || shopt -u nullglob
|
||
printf '%s\n' "$result"
|
||
}
|
||
export -f expandlist
|
||
# ------------------------------------------------------------------------------
|
||
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Clean a directory tree from temporary or backup files
|
||
# Usage: clean [options] [directory1] [...[directoryX]]
|
||
# Options:
|
||
# -h, --help: display help screen
|
||
# -r, --recurs: do a recursive cleaning
|
||
# -f, --force: do not ask for confirmation (use with care)
|
||
# -s, --shell: do nothing and display what will be executed
|
||
clean()
|
||
{
|
||
local recursive=${CLEAN_DEFAULT_RECURSIVE:-0} force=0 outshell=0
|
||
|
||
# Define short and long options
|
||
local PARSED
|
||
PARSED=$(getopt -o hrsf --long help,recurs,shell,force -n 'clean' -- "$@")
|
||
# shellcheck disable=SC2181 # getopt return code is checked immediately after
|
||
if [[ $? -ne 0 ]]; then
|
||
disp E "Invalid options, use \"clean --help\" to display usage."
|
||
return 1
|
||
fi
|
||
|
||
# Restore the getopt-normalised (shell-quoted) argument list
|
||
local -a _pargs=()
|
||
_getopt_split _pargs "$PARSED"
|
||
set -- "${_pargs[@]}"
|
||
|
||
while true; do
|
||
case "$1" in
|
||
-r|--recurs)
|
||
recursive=1
|
||
shift
|
||
;;
|
||
-h|--help)
|
||
printf "clean: erase backup files in the given directories.\n\n"
|
||
printf "Usage: clean [option] [directory1] [...[directoryX]]\n"
|
||
printf "Options:\n"
|
||
printf "\t-h, --help\t\tDisplay that help screen\n"
|
||
printf "\t-r, --recurs\t\tDo a recursive cleaning\n"
|
||
printf "\t-f, --force\t\tDo not ask for confirmation (use with care)\n"
|
||
printf "\t-s, --shell\t\tDo nothing and display what will be executed\n"
|
||
printf "\n"
|
||
return 0
|
||
;;
|
||
-s|--shell)
|
||
outshell=1
|
||
shift
|
||
;;
|
||
-f|--force)
|
||
force=1
|
||
shift
|
||
;;
|
||
--)
|
||
shift
|
||
break
|
||
;;
|
||
*)
|
||
disp E "Invalid parameter, use \"clean --help\" to display options list"
|
||
return 1
|
||
;;
|
||
esac
|
||
done
|
||
|
||
# Handle remaining arguments as directories
|
||
local dirlist=("$@")
|
||
[[ ${#dirlist[@]} -eq 0 ]] && dirlist=(".")
|
||
|
||
local findopt=() rmopt=()
|
||
(( ! recursive )) && findopt=(-maxdepth 1)
|
||
(( ! force )) && rmopt=(-i)
|
||
|
||
local dir f
|
||
for dir in "${dirlist[@]}"; do
|
||
# "./" keeps a name starting with "-" from being read as a find option.
|
||
# -H follows a symlink given as argument (a link to a directory).
|
||
[[ $dir == -* ]] && dir="./$dir"
|
||
find -H "$dir" "${findopt[@]}" -type f \( -name "*~" -o -name "#*#" -o -name "*.bak" -o -name ".~*#" \) -print0 |
|
||
while IFS= read -r -d '' f; do
|
||
if (( outshell )); then
|
||
# %q prints a re-usable (shell-quoted) form of the name
|
||
if (( ${#rmopt[@]} )); then
|
||
printf 'rm %s -- %q\n' "${rmopt[*]}" "$f"
|
||
else
|
||
printf 'rm -- %q\n' "$f"
|
||
fi
|
||
elif (( force )); then
|
||
# No confirmation: rm reads no stdin, so leave it attached
|
||
# to the find pipe (a /dev/tty redirect would fail when there
|
||
# is no controlling terminal, e.g. cron).
|
||
rm -- "$f" || disp E "Failed to remove \"$f\"."
|
||
else
|
||
# Interactive rm -i: read the answer from the terminal, not
|
||
# from the find pipe that feeds this loop's stdin.
|
||
rm -i -- "$f" </dev/tty || disp E "Failed to remove \"$f\"."
|
||
fi
|
||
done
|
||
unset f
|
||
done
|
||
}
|
||
export -f clean
|
||
# ------------------------------------------------------------------------------
|
||
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Create a directory then goes inside
|
||
# Usage: mcd <directory>
|
||
mcd()
|
||
{
|
||
if [[ "$1" == "-h" || "$1" == "--help" ]]; then
|
||
printf "mcd: Create a directory and enter it.\n\n"
|
||
printf "Usage: mcd <directory>\n"
|
||
printf "Options:\n"
|
||
printf "\t-h, --help\t\tDisplay this help screen\n"
|
||
return 0
|
||
fi
|
||
|
||
if (( $# != 1 )); then
|
||
disp E "Missing parameter. Use \"mcd --help\" to display usage."
|
||
return 1
|
||
fi
|
||
if ! mkdir -pv -- "$1"; then
|
||
disp E "Failed to create directory \"$1\"."
|
||
return 1
|
||
fi
|
||
if ! cd -- "$1"; then
|
||
disp E "Failed to change to directory \"$1\"."
|
||
return 1
|
||
fi
|
||
}
|
||
export -f mcd
|
||
# ------------------------------------------------------------------------------
|
||
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Rename files and directories to replace spaces with another character
|
||
# Usage: rmspc [options]
|
||
# Options:
|
||
# -h, --help: display help screen
|
||
# -r, --recursive: treat subdirectories of the given directory
|
||
# -c, --subst-char: change the replacement character (default is underscore)
|
||
# -v, --verbose: display more details (recursive mode only)
|
||
# -s, --shell: do nothing and display commands that would be executed
|
||
rmspc()
|
||
{
|
||
local recurs=0 verb=0 shell=0
|
||
local substchar="${RMSPC_DEFAULT_CHAR:-_}" substchar_set=0
|
||
local mvopt=()
|
||
local PARSED
|
||
|
||
PARSED=$(getopt -o hrc::vs --long help,recursive,subst-char::,verbose,shell -n 'rmspc' -- "$@")
|
||
# shellcheck disable=SC2181 # getopt return code is checked immediately after
|
||
if [[ $? -ne 0 ]]; then
|
||
disp E "Invalid options, use \"rmspc --help\" to display usage."
|
||
return 1
|
||
fi
|
||
# Restore the getopt-normalised (shell-quoted) argument list
|
||
local -a _pargs=()
|
||
_getopt_split _pargs "$PARSED"
|
||
set -- "${_pargs[@]}"
|
||
|
||
while true; do
|
||
case "$1" in
|
||
-h|--help)
|
||
printf "rmspc: remove spaces from all filenames in current directories\n\n"
|
||
printf "Usage: rmspc [option]\n\n"
|
||
printf "Options:\n"
|
||
printf "\t-h, --help\t\tDisplay that help screen\n"
|
||
printf "\t-r, --recursive\t\tTreat subdirectories of the given directory\n"
|
||
printf "\t-c, --subst-char[=C]\tChange the replacement character (default is underscore)\n"
|
||
printf "\t-v, --verbose\t\tDisplay more details (recursive mode only)\n"
|
||
printf "\t-s, --shell\t\tDo nothing and display commands that would be executed\n\n"
|
||
printf "Note: the character must be attached (-c- or --subst-char=-). If the option\n"
|
||
printf " is given without a character, spaces are replaced with nothing\n"
|
||
printf " (concatenation). An existing file is never overwritten.\n"
|
||
return 0
|
||
;;
|
||
-r|--recursive)
|
||
recurs=1
|
||
shift
|
||
;;
|
||
-c|--subst-char)
|
||
substchar_set=1
|
||
substchar="$2"
|
||
shift 2
|
||
;;
|
||
-v|--verbose)
|
||
verb=1
|
||
shift
|
||
;;
|
||
-s|--shell)
|
||
shell=1
|
||
shift
|
||
;;
|
||
--)
|
||
shift
|
||
break
|
||
;;
|
||
*)
|
||
disp E "Invalid parameter, use \"rmspc --help\" to display options list"
|
||
return 1
|
||
;;
|
||
esac
|
||
done
|
||
|
||
# getopt only attaches an optional argument written as -cX or --subst-char=X;
|
||
# "-c X" leaves X behind as a positional argument, which rmspc does not take
|
||
# (it works on the current directory) and would otherwise silently ignore.
|
||
if [[ $# -gt 0 ]]; then
|
||
disp E "Unexpected argument '$1'. The replacement character must be attached: -c$1 or --subst-char=$1"
|
||
return 1
|
||
fi
|
||
|
||
[[ "$substchar" == "none" ]] && substchar=""
|
||
# A slash would turn the new name into a path
|
||
if [[ "$substchar" == */* ]]; then
|
||
disp E "The replacement character must not contain a slash."
|
||
return 1
|
||
fi
|
||
(( verb )) && mvopt=(-v)
|
||
|
||
# Restore the caller's nullglob setting on exit instead of forcing it off.
|
||
local _had_nullglob=0
|
||
shopt -q nullglob && _had_nullglob=1
|
||
shopt -s nullglob
|
||
local f
|
||
for f in *; do
|
||
# Symlinks to directories are not followed (no escape from the tree,
|
||
# no loops).
|
||
if (( recurs )) && [[ -d "$f" && ! -L "$f" ]]; then
|
||
(
|
||
local lastdir=$f
|
||
if (( verb )); then
|
||
disp I "Entering directory $(pwd)/$f ..."
|
||
fi
|
||
pushd "$f" >/dev/null || return 1
|
||
|
||
# Build the recursive argument list by value: the ${var:+-flag}
|
||
# idiom would expand on the *string* "0" (non-empty) and wrongly
|
||
# force -v/-s on every recursive call.
|
||
local rargs=(-r)
|
||
(( verb )) && rargs+=(-v)
|
||
(( shell )) && rargs+=(-s)
|
||
# Attached form, mandatory for an optional argument (see above)
|
||
(( substchar_set )) && rargs+=(--subst-char="$substchar")
|
||
rmspc "${rargs[@]}"
|
||
|
||
popd >/dev/null || return 1
|
||
# "if" rather than "(( verb )) && ...": the subshell status is
|
||
# that of its last command and must stay 0 when verb is off.
|
||
if (( verb )); then
|
||
disp I "Leaving directory $(pwd)/$lastdir"
|
||
fi
|
||
)
|
||
fi
|
||
|
||
if [[ "$f" == *" "* ]]; then
|
||
# Quoted replacement: an unquoted "&" would stand for the match
|
||
local newf="${f// /"$substchar"}"
|
||
[[ "$f" == "$newf" ]] && continue
|
||
# Never overwrite (mv would silently replace an existing file)
|
||
if [[ -e "$newf" || -L "$newf" ]]; then
|
||
disp E "Cannot rename \"$f\": \"$newf\" already exists."
|
||
continue
|
||
fi
|
||
if (( shell )); then
|
||
if (( ${#mvopt[@]} )); then
|
||
printf 'mv %s -- %q %q\n' "${mvopt[*]}" "$f" "$newf"
|
||
else
|
||
printf 'mv -- %q %q\n' "$f" "$newf"
|
||
fi
|
||
else
|
||
mv -n "${mvopt[@]}" -- "$f" "$newf" || {
|
||
disp E "Failed renaming \"$f\" to \"$newf\"."
|
||
continue
|
||
}
|
||
fi
|
||
fi
|
||
done
|
||
unset f
|
||
(( _had_nullglob )) || shopt -u nullglob
|
||
}
|
||
export -f rmspc
|
||
# ------------------------------------------------------------------------------
|
||
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Display statistics about a file tree
|
||
# Usage: file_stats [options] [path]
|
||
# Options:
|
||
# -H, --human Human readable sizes
|
||
# -d, --details Display details (min/max/average/median)
|
||
# -m, --average Display only average size
|
||
# -M, --median Display only median size
|
||
# -c, --count Display only count of files
|
||
# -t, --total Display only total size
|
||
# -a, --all Display all stats in human readable format (shortcut for -H -d)
|
||
# -x, --ext [ext] Filter by extension (e.g. -x log for .log files)
|
||
# -X, --ext-list [list] Filter by multiple extensions (e.g. -X log,txt)
|
||
# --min [size] Minimum size (e.g., 10M)
|
||
# --max [size] Maximum size (e.g., 100M)
|
||
file_stats()
|
||
{
|
||
local human=0 details=0 only_avg=0 only_med=0 only_count=0 only_total=0
|
||
local path="." show_all=1 ext_filter="" ext_list="" min_size="" max_size=""
|
||
|
||
local PARSED
|
||
# Short: H, d, m, M, c, t, a, x:, X:
|
||
# Long: human, details, average, median, count, total, all, ext:, ext-list:, min:, max:, help
|
||
PARSED=$(getopt -o HdmMctax:X:h --long human,details,average,median,count,total,all,ext:,ext-list:,min:,max:,help -n 'file_stats' -- "$@")
|
||
# shellcheck disable=SC2181 # getopt return code is checked immediately after
|
||
if [[ $? -ne 0 ]]; then
|
||
disp E "Invalid options, use \"file_stats --help\" to display usage."
|
||
return 1
|
||
fi
|
||
# Restore the getopt-normalised (shell-quoted) argument list
|
||
local -a _pargs=()
|
||
_getopt_split _pargs "$PARSED"
|
||
set -- "${_pargs[@]}"
|
||
|
||
while true; do
|
||
case "$1" in
|
||
-h|--help)
|
||
printf "Usage: file_stats [options] [path]\n\n"
|
||
printf "Options:\n"
|
||
printf "\t-H, --human\t\tHuman readable sizes\n"
|
||
printf "\t-d, --details\t\tShow detailed histogram\n"
|
||
printf "\t-m, --average\t\tShow only average size\n"
|
||
printf "\t-M, --median\t\tShow only median size\n"
|
||
printf "\t-c, --count\t\tShow only file count\n"
|
||
printf "\t-t, --total\t\tShow only total size\n"
|
||
printf "\t-a, --all\t\tShow all (human + details)\n"
|
||
printf "\t-x, --ext [ext]\t\tFilter by extension\n"
|
||
printf "\t-X, --ext-list [list]\tFilter by comma-separated list\n"
|
||
printf "\t--min [size]\t\tMinimum size (e.g., 10M)\n"
|
||
printf "\t--max [size]\t\tMaximum size (e.g., 100M)\n"
|
||
return 0 ;;
|
||
-H|--human)
|
||
human=1
|
||
shift
|
||
;;
|
||
-d|--details)
|
||
details=1
|
||
shift
|
||
;;
|
||
-m|--average)
|
||
only_avg=1
|
||
show_all=0
|
||
shift
|
||
;;
|
||
-M|--median)
|
||
only_med=1
|
||
show_all=0
|
||
shift
|
||
;;
|
||
-c|--count)
|
||
only_count=1
|
||
show_all=0
|
||
shift
|
||
;;
|
||
-t|--total)
|
||
only_total=1
|
||
show_all=0
|
||
shift
|
||
;;
|
||
-a|--all)
|
||
human=1
|
||
details=1
|
||
shift
|
||
;;
|
||
-x|--ext)
|
||
ext_filter="${2#.}"
|
||
shift 2
|
||
;;
|
||
-X|--ext-list)
|
||
ext_list="$2"
|
||
shift 2
|
||
;;
|
||
--min)
|
||
min_size="$2"
|
||
shift 2
|
||
;;
|
||
--max)
|
||
max_size="$2"
|
||
shift 2
|
||
;;
|
||
--)
|
||
shift
|
||
break
|
||
;;
|
||
*)
|
||
disp E "Invalid option: $1"
|
||
return 1
|
||
;;
|
||
esac
|
||
done
|
||
|
||
[[ -n "$1" ]] && path="$1"
|
||
if [[ ! -e "$path" ]]; then
|
||
disp E "file_stats: no such file or directory: ${path}"
|
||
return 1
|
||
fi
|
||
# "./" keeps a name starting with "-" from being read as a find option
|
||
[[ $path == -* ]] && path="./$path"
|
||
|
||
# Prepare find filters
|
||
local find_cmd=(find "$path" -type f)
|
||
|
||
# Single extension filter (case-insensitive). When combined with
|
||
# --ext-list, a file must satisfy both, as find ANDs the tests.
|
||
if [[ -n "$ext_filter" ]]; then
|
||
find_cmd+=(-iname "*.$ext_filter")
|
||
fi
|
||
|
||
# Extension list filter: ( -iname *.a -o -iname *.b ... )
|
||
local exts
|
||
if [[ -n "$ext_list" ]]; then
|
||
IFS=',' read -ra exts <<< "$ext_list"
|
||
find_cmd+=('(')
|
||
local i
|
||
for i in "${!exts[@]}"; do
|
||
[[ $i -ne 0 ]] && find_cmd+=(-o)
|
||
find_cmd+=(-iname "*.${exts[$i]}")
|
||
done
|
||
unset i
|
||
find_cmd+=(')')
|
||
fi
|
||
unset exts
|
||
|
||
# Minimum/maximum size filters (evaluated in bytes)
|
||
if [[ -n "$min_size" || -n "$max_size" ]]; then
|
||
if ! command -v numfmt >/dev/null 2>&1; then
|
||
disp E "file_stats: --min/--max require 'numfmt' (GNU coreutils). Please install it."
|
||
return 1
|
||
fi
|
||
fi
|
||
local bytes
|
||
if [[ -n "$min_size" ]]; then
|
||
bytes=$(numfmt --from=iec "$min_size" 2>/dev/null) || {
|
||
disp E "file_stats: invalid --min size: ${min_size}"
|
||
return 1
|
||
}
|
||
# find's "-size +N" means "strictly more than N bytes"; the minimum
|
||
# is inclusive, hence N-1 (nothing to filter for a minimum of 0).
|
||
(( bytes > 0 )) && find_cmd+=(-size +"$(( bytes - 1 ))"c)
|
||
fi
|
||
if [[ -n "$max_size" ]]; then
|
||
bytes=$(numfmt --from=iec "$max_size" 2>/dev/null) || {
|
||
disp E "file_stats: invalid --max size: ${max_size}"
|
||
return 1
|
||
}
|
||
# "-size -N" means "less than N bytes": N+1 makes the maximum inclusive.
|
||
find_cmd+=(-size -"$(( bytes + 1 ))"c)
|
||
fi
|
||
|
||
# Execution: one size per line, sorted ascending (the median needs it),
|
||
# then summarised by awk. The path goes through ENVIRON, as awk -v would
|
||
# interpret backslashes in it.
|
||
"${find_cmd[@]}" -printf "%s\n" 2>/dev/null | sort -n | \
|
||
FILE_STATS_PATH="$path" \
|
||
awk -v human="$human" -v details="$details" -v only_avg="$only_avg" \
|
||
-v only_med="$only_med" -v only_count="$only_count" \
|
||
-v only_total="$only_total" -v show_all="$show_all" '
|
||
# Convert function (i and units are local: extra parameters)
|
||
function human_readable(x, i, units) {
|
||
split("B KiB MiB GiB TiB", units)
|
||
i = 1
|
||
while (x >= 1024 && i < 5) {
|
||
x /= 1024
|
||
i++
|
||
}
|
||
return sprintf("%.2f %s", x, units[i])
|
||
}
|
||
|
||
# Display function
|
||
function out(label, val, is_size) {
|
||
if (human == 1 && is_size == 1) val = human_readable(val)
|
||
printf "%-20s : %s\n", label, val
|
||
}
|
||
|
||
{
|
||
sizes[NR] = $1
|
||
total += $1
|
||
if (min == "" || $1 < min) min = $1
|
||
if (max == "" || $1 > max) max = $1
|
||
# Histogram bucket b holds sizes in [1024^b, 1024^(b+1)); bucket 0
|
||
# also takes empty files. Computed with an integer loop: a
|
||
# log() ratio can fall just below an exact power of 1024.
|
||
v = $1; b = 0
|
||
while (v >= 1024) { v = int(v / 1024); b++ }
|
||
bucket[b]++
|
||
if (b > maxb) maxb = b
|
||
}
|
||
|
||
END {
|
||
path = ENVIRON["FILE_STATS_PATH"]
|
||
count = NR
|
||
if (count == 0) {
|
||
print "No files found."
|
||
exit
|
||
}
|
||
|
||
average = total / count
|
||
|
||
# Median calculation: exact using sorted array values
|
||
if (count % 2 == 1) {
|
||
median = sizes[(count + 1) / 2]
|
||
} else {
|
||
idx = count / 2
|
||
median = (sizes[idx] + sizes[idx + 1]) / 2
|
||
}
|
||
|
||
if (only_avg) out("Average size", average, 1)
|
||
else if (only_med) out("Median size", median, 1)
|
||
else if (only_count) out("Number of files", count, 0)
|
||
else if (only_total) out("Total size", total, 1)
|
||
else {
|
||
if (show_all || human || details) {
|
||
printf "Statistics for \"%s\"\n", path
|
||
printf "-------------------------\n"
|
||
}
|
||
out("Number of files", count, 0)
|
||
out("Total size", total, 1)
|
||
out("Average size", average, 1)
|
||
out("Median size", median, 1)
|
||
out("Minimum size", min, 1)
|
||
out("Maximum size", max, 1)
|
||
}
|
||
if (details) {
|
||
print "\nSize histogram:"
|
||
|
||
# Walk the buckets in ascending order (awk "for (b in array)" gives
|
||
# no defined order); empty buckets are skipped.
|
||
for (b = 0; b <= maxb; b++) {
|
||
if (!(b in bucket)) continue
|
||
# Pre-calculate label parts
|
||
# 1024^0 = 1 (B), 1024^1 = 1K, etc.
|
||
low = (b == 0) ? 0 : (1024^b)
|
||
high = 1024^(b+1)
|
||
|
||
label = sprintf("%-9s – %-9s",
|
||
(b == 0) ? "0" : human_readable(low),
|
||
human_readable(high))
|
||
|
||
printf "%-25s : %6d files\n", label, bucket[b]
|
||
}
|
||
}
|
||
}'
|
||
}
|
||
export -f file_stats
|
||
# ------------------------------------------------------------------------------
|
||
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Find the biggest files in a directory tree
|
||
# Usage: findbig [options] [directory]
|
||
# Options:
|
||
# -h : display help screen
|
||
# -d : display details (ls -l) for each file
|
||
# -x : do not cross filesystem boundaries
|
||
# -l : limit : number of files to return (default is 10)
|
||
findbig()
|
||
{
|
||
local details=0 limit=${FINDBIG_DEFAULT_LIMIT:-10} one_fs=0
|
||
|
||
local PARSED
|
||
PARSED=$(getopt -o hdl:x --long help,details,limit:,one-fs -n 'findbig' -- "$@")
|
||
# shellcheck disable=SC2181 # getopt return code is checked immediately after
|
||
if [[ $? -ne 0 ]]; then
|
||
disp E "Invalid options, use \"findbig --help\" to display usage."
|
||
return 1
|
||
fi
|
||
# Restore the getopt-normalised (shell-quoted) argument list
|
||
local -a _pargs=()
|
||
_getopt_split _pargs "$PARSED"
|
||
set -- "${_pargs[@]}"
|
||
|
||
while true; do
|
||
case "$1" in
|
||
-h|--help)
|
||
printf "findbig: Find the N biggest files in a directory tree.\n\n"
|
||
printf "Usage: findbig [options] [directory]\n\n"
|
||
printf "Options:\n"
|
||
printf "\t-h, --help\t\tDisplay this help screen\n"
|
||
printf "\t-d, --details\t\tShow detailed file info (ls -ld)\n"
|
||
printf "\t-l, --limit N\t\tNumber of files to return (default: 10)\n"
|
||
printf "\t-x, --one-fs\t\tDo not cross filesystem boundaries\n"
|
||
return 0
|
||
;;
|
||
-d|--details)
|
||
details=1
|
||
shift
|
||
;;
|
||
-l|--limit)
|
||
limit="$2"
|
||
shift 2
|
||
;;
|
||
-x|--one-fs)
|
||
one_fs=1
|
||
shift
|
||
;;
|
||
--)
|
||
shift
|
||
break
|
||
;;
|
||
*)
|
||
disp E "Invalid option: $1"
|
||
return 1
|
||
;;
|
||
esac
|
||
done
|
||
|
||
# Checked here so that the configured default (FINDBIG_DEFAULT_LIMIT) is
|
||
# validated as well as -l.
|
||
if [[ ! "$limit" =~ ^[1-9][0-9]*$ ]]; then
|
||
disp E "Invalid limit '${limit}': must be a positive integer."
|
||
return 1
|
||
fi
|
||
|
||
local dir="${1:-.}"
|
||
# "./" keeps a name starting with "-" from being read as a find option
|
||
[[ $dir == -* ]] && dir="./$dir"
|
||
|
||
# Prepare find arguments in an array for cleaner handling
|
||
local find_args=(-L "$dir")
|
||
(( one_fs )) && find_args+=(-xdev)
|
||
find_args+=(-type f)
|
||
|
||
# Logic: find files, print size and path, sort numeric reverse, take N
|
||
local _fd
|
||
_fd=$(command -v fd 2>/dev/null || command -v fdfind 2>/dev/null)
|
||
|
||
if [[ -n "$_fd" ]]; then
|
||
local _fd_args=(--follow --no-ignore --hidden --absolute-path -t f)
|
||
(( one_fs )) && _fd_args+=(--one-file-system)
|
||
_fd_args+=(. "$dir")
|
||
if (( details )); then
|
||
local line
|
||
"$_fd" "${_fd_args[@]}" --exec stat --printf="%s %n\n" 2>/dev/null \
|
||
| sort -rn | head -n "$limit" \
|
||
| while IFS= read -r line; do
|
||
local path="${line#* }"
|
||
ls -ld -- "$path"
|
||
done
|
||
unset line
|
||
else
|
||
"$_fd" "${_fd_args[@]}" --exec stat --printf="%s %n\n" 2>/dev/null \
|
||
| sort -rn | head -n "$limit"
|
||
fi
|
||
elif (( details )); then
|
||
local line
|
||
find "${find_args[@]}" -printf "%s %p\n" 2>/dev/null | sort -rn | head -n "$limit" |
|
||
while IFS= read -r line; do
|
||
local path="${line#* }"
|
||
ls -ld -- "$path"
|
||
done
|
||
unset line
|
||
else
|
||
find "${find_args[@]}" -printf "%s %p\n" 2>/dev/null | sort -rn | head -n "$limit"
|
||
fi
|
||
}
|
||
export -f findbig
|
||
# ------------------------------------------------------------------------------
|
||
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Find empty files in a directory tree
|
||
# Usage: findzero [options] [directory]
|
||
# Options:
|
||
# -h : display help screen
|
||
# -d : display details (ls -l) for each file
|
||
# -x : do not cross filesystem boundaries
|
||
# --delete : delete empty files and display their paths
|
||
findzero()
|
||
{
|
||
local delete=0 details=0 one_fs=0
|
||
|
||
local PARSED
|
||
# o: options, long: long equivalents
|
||
PARSED=$(getopt -o hdx --long help,details,one-fs,delete -n 'findzero' -- "$@")
|
||
# shellcheck disable=SC2181 # getopt return code is checked immediately after
|
||
if [[ $? -ne 0 ]]; then
|
||
disp E "Invalid options, use \"findzero --help\" to display usage."
|
||
return 1
|
||
fi
|
||
# Restore the getopt-normalised (shell-quoted) argument list
|
||
local -a _pargs=()
|
||
_getopt_split _pargs "$PARSED"
|
||
set -- "${_pargs[@]}"
|
||
|
||
while true; do
|
||
case "$1" in
|
||
-h|--help)
|
||
printf "findzero: Find or delete empty files in a directory tree.\n\n"
|
||
printf "Usage: findzero [options] [directory]\n\n"
|
||
printf "Options:\n"
|
||
printf "\t-h, --help\t\tDisplay this help screen\n"
|
||
printf "\t-d, --details\t\tShow detailed file info (ls -ls)\n"
|
||
printf "\t-x, --one-fs\t\tDo not cross filesystem boundaries\n"
|
||
printf "\t--delete\t\tActually remove the empty files\n"
|
||
return 0 ;;
|
||
-d|--details)
|
||
details=1
|
||
shift
|
||
;;
|
||
-x|--one-fs)
|
||
one_fs=1
|
||
shift
|
||
;;
|
||
--delete)
|
||
delete=1
|
||
shift
|
||
;;
|
||
--)
|
||
shift
|
||
break
|
||
;;
|
||
*)
|
||
disp E "Invalid option: $1"
|
||
return 1
|
||
;;
|
||
esac
|
||
done
|
||
|
||
local dir="${1:-.}"
|
||
# "./" keeps a name starting with "-" from being read as a find option
|
||
[[ $dir == -* ]] && dir="./$dir"
|
||
|
||
# Symlinks are followed to report/count empty targets, but never when
|
||
# deleting: with -L, a link to an empty file matches and "delete" would
|
||
# remove the link itself.
|
||
local follow=1
|
||
(( delete )) && follow=0
|
||
|
||
local find_args=()
|
||
(( follow )) && find_args+=("-L")
|
||
find_args+=("$dir" "-type" "f" "-empty")
|
||
|
||
(( one_fs )) && find_args+=("-xdev")
|
||
|
||
# Execution logic
|
||
local _fd
|
||
_fd=$(command -v fd 2>/dev/null || command -v fdfind 2>/dev/null)
|
||
|
||
if [[ -n "$_fd" ]]; then
|
||
local _fd_args=(--no-ignore --hidden --absolute-path -t f --size -1b)
|
||
(( follow )) && _fd_args+=(--follow)
|
||
(( one_fs )) && _fd_args+=(--one-file-system)
|
||
_fd_args+=(. "$dir")
|
||
# fd's "--size -1b" means "at most 1 byte", so it also lists files of
|
||
# exactly one byte. Every result is therefore re-checked with [[ -s ]]
|
||
# (true when the size is not zero) and skipped if not empty: deleting a
|
||
# one-byte file would be data loss.
|
||
if (( delete )); then
|
||
disp W "Deleting empty files in $dir..."
|
||
local f
|
||
"$_fd" "${_fd_args[@]}" 2>/dev/null | while IFS= read -r f; do
|
||
[[ -s "$f" ]] && continue
|
||
printf "%s\n" "$f"
|
||
rm -f -- "$f"
|
||
done
|
||
unset f
|
||
elif (( details )); then
|
||
local f
|
||
"$_fd" "${_fd_args[@]}" 2>/dev/null | while IFS= read -r f; do
|
||
[[ -s "$f" ]] && continue
|
||
ls -ls -- "$f"
|
||
done
|
||
unset f
|
||
else
|
||
local f
|
||
"$_fd" "${_fd_args[@]}" 2>/dev/null | while IFS= read -r f; do
|
||
if [[ ! -s "$f" ]]; then printf "%s\n" "$f"; fi
|
||
done
|
||
unset f
|
||
fi
|
||
elif (( delete )); then
|
||
disp W "Deleting empty files in $dir..."
|
||
find "${find_args[@]}" -delete -print
|
||
elif (( details )); then
|
||
find "${find_args[@]}" -ls
|
||
else
|
||
find "${find_args[@]}"
|
||
fi
|
||
}
|
||
export -f findzero
|
||
# ------------------------------------------------------------------------------
|
||
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Find dead symbolic links in a directory tree
|
||
# Usage: finddead [options] [directory]
|
||
# Options:
|
||
# -h : display help screen
|
||
# -d : display details (ls -l) for each link
|
||
# -x : do not cross filesystem boundaries
|
||
# --delete : delete dead links and display their paths
|
||
finddead()
|
||
{
|
||
local delete=0 details=0 one_fs=0
|
||
|
||
local PARSED
|
||
PARSED=$(getopt -o hdx --long help,details,one-fs,delete -n 'finddead' -- "$@")
|
||
# shellcheck disable=SC2181 # getopt return code is checked immediately after
|
||
if [[ $? -ne 0 ]]; then
|
||
disp E "Invalid options, use \"finddead --help\" to display usage."
|
||
return 1
|
||
fi
|
||
# Restore the getopt-normalised (shell-quoted) argument list
|
||
local -a _pargs=()
|
||
_getopt_split _pargs "$PARSED"
|
||
set -- "${_pargs[@]}"
|
||
|
||
while true; do
|
||
case "$1" in
|
||
-h|--help)
|
||
printf "finddead: Find or delete dead/broken symbolic links.\n\n"
|
||
printf "Usage: finddead [options] [directory]\n\n"
|
||
printf "Options:\n"
|
||
printf "\t-h, --help\t\tDisplay this help screen\n"
|
||
printf "\t-d, --details\t\tShow detailed symlink info (ls -ls)\n"
|
||
printf "\t-x, --one-fs\t\tDo not cross filesystem boundaries\n"
|
||
printf "\t--delete\t\tActually remove the dead links\n"
|
||
return 0 ;;
|
||
-d|--details)
|
||
details=1
|
||
shift
|
||
;;
|
||
-x|--one-fs)
|
||
one_fs=1
|
||
shift
|
||
;;
|
||
--delete)
|
||
delete=1
|
||
shift
|
||
;;
|
||
--)
|
||
shift
|
||
break
|
||
;;
|
||
*)
|
||
disp E "Invalid option: $1"
|
||
return 1
|
||
;;
|
||
esac
|
||
done
|
||
|
||
local dir="${1:-.}"
|
||
# "./" keeps a name starting with "-" from being read as a find option
|
||
[[ $dir == -* ]] && dir="./$dir"
|
||
# -xtype l searches for links that do not point to an existing file
|
||
local find_args=("$dir" "-xtype" "l")
|
||
(( one_fs )) && find_args+=("-xdev")
|
||
|
||
# Execution logic
|
||
local _fd
|
||
_fd=$(command -v fd 2>/dev/null || command -v fdfind 2>/dev/null)
|
||
|
||
if [[ -n "$_fd" ]]; then
|
||
# fd -t l lists all symlinks; post-filter broken ones (target unreachable via -e)
|
||
local _fd_args=(--no-ignore --hidden --absolute-path -t l)
|
||
(( one_fs )) && _fd_args+=(--one-file-system)
|
||
_fd_args+=(. "$dir")
|
||
if (( delete )); then
|
||
disp W "Deleting dead symlinks in $dir..."
|
||
local f
|
||
"$_fd" "${_fd_args[@]}" 2>/dev/null | while IFS= read -r f; do
|
||
if [[ ! -e "$f" ]]; then printf "%s\n" "$f"; rm -f -- "$f"; fi
|
||
done
|
||
unset f
|
||
elif (( details )); then
|
||
local f
|
||
"$_fd" "${_fd_args[@]}" 2>/dev/null | while IFS= read -r f; do
|
||
if [[ ! -e "$f" ]]; then ls -ls -- "$f"; fi
|
||
done
|
||
unset f
|
||
else
|
||
local f
|
||
"$_fd" "${_fd_args[@]}" 2>/dev/null | while IFS= read -r f; do
|
||
if [[ ! -e "$f" ]]; then printf "%s\n" "$f"; fi
|
||
done
|
||
unset f
|
||
fi
|
||
elif (( delete )); then
|
||
disp W "Deleting dead symlinks in $dir..."
|
||
find "${find_args[@]}" -delete -print
|
||
elif (( details )); then
|
||
find "${find_args[@]}" -ls
|
||
else
|
||
find "${find_args[@]}"
|
||
fi
|
||
}
|
||
export -f finddead
|
||
# ------------------------------------------------------------------------------
|
||
|
||
|
||
# EOF
|