filefct bug fix

This commit is contained in:
2026-10-13 12:31:51 +02:00
parent b1071ab4b9
commit df5dca96c6
+171 -69
View File
@@ -37,7 +37,9 @@
load_conf "filefct"
# ------------------------------------------------------------------------------
# Expand wildcards in a file/directory list and quote the results
# 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()
{
@@ -49,9 +51,8 @@ expandlist()
disp E "Invalid options, use \"expandlist --help\" to display usage."
return 1
fi
local -a _pargs=()
readarray -td '' _pargs < <(printf '%s' "$PARSED" | xargs printf '%s\0')
set -- "${_pargs[@]}"
# Restore the getopt-normalised (shell-quoted) argument list
eval set -- "$PARSED"
while true; do
case "$1" in
@@ -60,7 +61,7 @@ expandlist()
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"
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
;;
@@ -83,15 +84,25 @@ expandlist()
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 _saved_nullglob
_saved_nullglob=$(shopt -p nullglob)
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 # We actually want the word splitting
# shellcheck disable=SC2206 # Intentional glob expansion, no splitting (IFS is empty)
expanded=( $item )
else
expanded=( "$item" )
@@ -106,13 +117,19 @@ expandlist()
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
shopt -u nullglob
eval "$_saved_nullglob"
printf '%s\n' "$result"
}
export -f expandlist
@@ -140,9 +157,8 @@ clean()
return 1
fi
local -a _pargs=()
readarray -td '' _pargs < <(printf '%s' "$PARSED" | xargs printf '%s\0')
set -- "${_pargs[@]}"
# Restore the getopt-normalised (shell-quoted) argument list
eval set -- "$PARSED"
while true; do
case "$1" in
@@ -190,13 +206,17 @@ clean()
local dir f
for dir in "${dirlist[@]}"; do
find "$dir" "${findopt[@]}" -type f \( -name "*~" -o -name "#*#" -o -name "*.bak" -o -name ".~*#" \) -print0 |
# "./" 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 -- "%s"\n' "${rmopt[*]}" "$f"
printf 'rm %s -- %q\n' "${rmopt[*]}" "$f"
else
printf 'rm -- "%s"\n' "$f"
printf 'rm -- %q\n' "$f"
fi
elif (( force )); then
# No confirmation: rm reads no stdin, so leave it attached
@@ -233,11 +253,11 @@ mcd()
disp E "Missing parameter. Use \"mcd --help\" to display usage."
return 1
fi
if ! mkdir -pv "$1"; then
if ! mkdir -pv -- "$1"; then
disp E "Failed to create directory \"$1\"."
return 1
fi
if ! cd "$1"; then
if ! cd -- "$1"; then
disp E "Failed to change to directory \"$1\"."
return 1
fi
@@ -268,9 +288,8 @@ rmspc()
disp E "Invalid options, use \"rmspc --help\" to display usage."
return 1
fi
local -a _pargs=()
readarray -td '' _pargs < <(printf '%s' "$PARSED" | xargs printf '%s\0')
set -- "${_pargs[@]}"
# Restore the getopt-normalised (shell-quoted) argument list
eval set -- "$PARSED"
while true; do
case "$1" in
@@ -280,11 +299,12 @@ rmspc()
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\tChange the replacement character (default is underscore)\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: if the --subst-char option is given without parameters, spaces will be\n"
printf " replaced with nothing (concatenation).\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)
@@ -315,16 +335,36 @@ rmspc()
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 _saved_nullglob
_saved_nullglob=$(shopt -p nullglob)
shopt -s nullglob
local f
for f in *; do
if (( recurs )) && [[ -d "$f" ]]; then
# Symlinks to directories are not followed (no escape from the tree,
# no loops).
if (( recurs )) && [[ -d "$f" && ! -L "$f" ]]; then
(
local lastdir=$f
(( verb )) && disp I "Entering directory $(pwd)/$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}
@@ -333,25 +373,36 @@ rmspc()
local rargs=(-r)
(( verb )) && rargs+=(-v)
(( shell )) && rargs+=(-s)
(( substchar_set )) && rargs+=(-c "$substchar")
# Attached form, mandatory for an optional argument (see above)
(( substchar_set )) && rargs+=(--subst-char="$substchar")
rmspc "${rargs[@]}"
popd >/dev/null || return 1
(( verb )) && disp I "Leaving directory $(pwd)/$lastdir"
# "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
local newf="${f// /${substchar}}"
# 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 -- "%s" "%s"\n' "${mvopt[*]}" "$f" "$newf"
printf 'mv %s -- %q %q\n' "${mvopt[*]}" "$f" "$newf"
else
printf 'mv -- "%s" "%s"\n' "$f" "$newf"
printf 'mv -- %q %q\n' "$f" "$newf"
fi
else
mv "${mvopt[@]}" -- "$f" "$newf" || {
mv -n "${mvopt[@]}" -- "$f" "$newf" || {
disp E "Failed renaming \"$f\" to \"$newf\"."
continue
}
@@ -359,7 +410,7 @@ rmspc()
fi
done
unset f
shopt -u nullglob
eval "$_saved_nullglob"
}
export -f rmspc
# ------------------------------------------------------------------------------
@@ -369,7 +420,7 @@ export -f rmspc
# Display statistics about a file tree
# Usage: file_stats [options] [path]
# Options:
# -H, --human Human readable sizes\n"
# -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
@@ -394,9 +445,8 @@ file_stats()
disp E "Invalid options, use \"file_stats --help\" to display usage."
return 1
fi
local -a _pargs=()
readarray -td '' _pargs < <(printf '%s' "$PARSED" | xargs printf '%s\0')
set -- "${_pargs[@]}"
# Restore the getopt-normalised (shell-quoted) argument list
eval set -- "$PARSED"
while true; do
case "$1" in
@@ -476,16 +526,23 @@ file_stats()
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
# 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
# Extension list filter: ( -iname *.a -o -iname *.b ... )
local exts
if [[ -n "$ext_list" ]]; then
IFS=',' read -ra exts <<< "$ext_list"
@@ -507,20 +564,35 @@ file_stats()
return 1
fi
fi
local bytes
if [[ -n "$min_size" ]]; then
find_cmd+=(-size +"$(numfmt --from=iec "$min_size")"c)
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
find_cmd+=(-size -"$(( $(numfmt --from=iec "$max_size") + 1 ))"c)
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
# 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" -v path="$path" '
# Convert function
function human_readable(x) {
-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) {
@@ -541,14 +613,17 @@ file_stats()
total += $1
if (min == "" || $1 < min) min = $1
if (max == "" || $1 > max) max = $1
if ($1 == 0) bucket[0]++
else {
b = int(log($1)/log(1024))
bucket[b]++
}
# 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."
@@ -584,8 +659,10 @@ file_stats()
if (details) {
print "\nSize histogram:"
# Use a separate array for the loop to avoid collision
for (b in bucket) {
# 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)
@@ -595,7 +672,6 @@ file_stats()
(b == 0) ? "0" : human_readable(low),
human_readable(high))
# We store buckets in an array, access them by index b
printf "%-25s : %6d files\n", label, bucket[b]
}
}
@@ -624,9 +700,8 @@ findbig()
disp E "Invalid options, use \"findbig --help\" to display usage."
return 1
fi
local -a _pargs=()
readarray -td '' _pargs < <(printf '%s' "$PARSED" | xargs printf '%s\0')
set -- "${_pargs[@]}"
# Restore the getopt-normalised (shell-quoted) argument list
eval set -- "$PARSED"
while true; do
case "$1" in
@@ -646,10 +721,6 @@ findbig()
;;
-l|--limit)
limit="$2"
[[ "$limit" =~ ^[0-9]+$ ]] || {
disp E "Invalid limit: must be a positive integer."
return 1
}
shift 2
;;
-x|--one-fs)
@@ -667,7 +738,16 @@ findbig()
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")
@@ -731,9 +811,8 @@ findzero()
disp E "Invalid options, use \"findzero --help\" to display usage."
return 1
fi
local -a _pargs=()
readarray -td '' _pargs < <(printf '%s' "$PARSED" | xargs printf '%s\0')
set -- "${_pargs[@]}"
# Restore the getopt-normalised (shell-quoted) argument list
eval set -- "$PARSED"
while true; do
case "$1" in
@@ -770,7 +849,18 @@ findzero()
done
local dir="${1:-.}"
local find_args=("-L" "$dir" "-type" "f" "-empty")
# "./" 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")
@@ -779,13 +869,19 @@ findzero()
_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 --size -1b)
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
@@ -793,11 +889,16 @@ findzero()
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
"$_fd" "${_fd_args[@]}" 2>/dev/null
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..."
@@ -831,9 +932,8 @@ finddead()
disp E "Invalid options, use \"finddead --help\" to display usage."
return 1
fi
local -a _pargs=()
readarray -td '' _pargs < <(printf '%s' "$PARSED" | xargs printf '%s\0')
set -- "${_pargs[@]}"
# Restore the getopt-normalised (shell-quoted) argument list
eval set -- "$PARSED"
while true; do
case "$1" in
@@ -870,6 +970,8 @@ finddead()
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")
@@ -887,19 +989,19 @@ finddead()
disp W "Deleting dead symlinks in $dir..."
local f
"$_fd" "${_fd_args[@]}" 2>/dev/null | while IFS= read -r f; do
[[ -e "$f" ]] || { printf "%s\n" "$f"; rm -f -- "$f"; }
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
[[ -e "$f" ]] || ls -ls -- "$f"
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
[[ -e "$f" ]] || printf "%s\n" "$f"
if [[ ! -e "$f" ]]; then printf "%s\n" "$f"; fi
done
unset f
fi