#!/usr/bin/env bash
# FileTree history
# 2018-02-10 initial version
# 2020-06-10 added -a option
# ????-??-?? gzip result
# 2021-03-22 put current date into output filename
# 2021-04-06 code refactoring with more functions and input sanitisation
# 2021-12-22 prefer zstd over gzip
# 2022-01-23 don't create symlink if there is no previous file
# 2022-01-30 added xz to compressors
# 2022-12-08 some cleanup, added -C and -K options
# 2022-12-09 bug fixes
# 2023-07-28 added -m and -s options, some code simplification
die() {
echo "${@}" > /dev/stderr
exit 1
}
usage() {
cat <<-EOF
_Usage: $(basename "$0") [-a] [-c COMP] [-K] [-o NAME] [-s] [DIR [DIR]]
_A wrapper to tree, it writes the content of DIR into a text file.
_The file is named after DIR and the current date, and symlink to the
_most recent version is set. The file is automatically compressed to
_zstd, xz or gzip, whichever is available in that order.
_Options:
_ -a access attributes: owner, group, permissions
_ -c compression: zstd, xz, gz, NO (in that order for auto-select)
_ -K do not keep backup of existing file in case of overwriting
_ -o The destination where to write the trees.
_ Default: .
_ If it is a directory: write in there.
_ If it is a filename: use that as base. If not,
_ use the name of the source directory as name base.
_ -s write simpler output with fewer information
_EOF
}
test_writable() {
touch "$1" 2>/dev/null || die "Cannot create file in $(dirname "$1")."
}
# run tree and redirect output to destination file
call_to_tree() {
local OUTPATH="$OUTDIR/$OUTNAME"
local DATED_PATH="$OUTPATH-$TODAY"
declare TREE_ARGS
TREE_ARGS+=("$@")
TREE_ARGS+=("-o")
TREE_ARGS+=("$DATED_PATH")
local EXT
local PACK
local CREATE_SYMLINK=no
if [ "$COMPRESS" = "" ]; then
if command -v zstd > /dev/null; then
COMPRESS="zstd"
elif command -v xz > /dev/null; then
COMPRESS="xz"
elif command -v gzip > /dev/null; then
COMPRESS="gz"
else
COMPRESS="NO"
fi
fi
case "$COMPRESS" in
"zstd")
PACK="zstd --rm -q -13"
EXT=".zst"
;;
"xz")
PACK="xz"
EXT=".xz"
;;
"gz")
PACK="gzip"
EXT=".gz"
;;
esac
ls "$OUTPATH-"* > /dev/null 2>&1 && CREATE_SYMLINK=yes
# pack yet unpacked existing file
if [ "$COMPRESS" != "no" ]; then
[ -f "$DATED_PATH" ] && [ ! -f "$DATED_PATH$EXT" ] && $PACK "$DATED_PATH"
fi
# move away existing file
if [ -f "$DATED_PATH$EXT" ]; then
if [ "$KEEP_OLD" = "no" ]; then
rm -f "$DATED_PATH$EXT"
else
mv -f "$DATED_PATH$EXT" "$DATED_PATH.old$EXT"
fi
fi
test_writable "$DATED_PATH"
echo "Writing $MODE tree to $(realpath "$DATED_PATH")$EXT"
BEGIN_TIME="$SECONDS"
"${TREE_ARGS[@]}"
END_TIME="$SECONDS"
echo >> "$DATED_PATH"
df -BM . | awk 'NR==2 { print "Size: " $2 " Usage: " $3 " Available: " $4; }' >> "$DATED_PATH"
#echo "Duration: $((END_TIME - BEGIN_TIME)) seconds." >> "$DATED_PATH"
[ "$PACK" ] && $PACK "$DATED_PATH"
if [ "$CREATE_SYMLINK" = yes ]; then
if [ -e "$OUTPATH" ] && [ ! -L "$OUTPATH" ]; then
echo "Cannot set symlink, filename already taken."
else
echo "Setting symlink $OUTPATH -> $(basename "$DATED_PATH")"
ln -sfn "$(basename "$DATED_PATH$EXT")" "$OUTPATH$EXT"
fi
fi
echo "Reading of directory took $((END_TIME - BEGIN_TIME)) seconds."
}
# parse arguments
unset ACCESSRIGHTS
unset COMPRESS
unset KEEP_OLD
unset MEDIA
MODE="detailed"
while getopts "c:ahKo:s" OPTION
do
case $OPTION in
c) case "$OPTARG" in
NO|xz|zstd|gz) COMPRESS="$OPTARG" ;;
*) die "The given compression '$OPTARG' is invalid." ;;
esac ;;
K) KEEP_OLD=no ;;
a) ACCESSRIGHTS=yes ;;
h) usage; exit 0 ;;
o) OUTARG="$OPTARG" ;;
s) MODE="simple" ;;
?) die "Unknown option '$OPTION'." ;;
esac
done
shift $((OPTIND-1)); OPTIND=1
TODAY="$(date +%F)"
declare DIRS
if [ "$#" -eq 0 ]; then
for SUBDIR in "/run/media/$USER/"*; do
if mountpoint -q "$SUBDIR"; then
printf "Detected mounted storage medium at '%s'. Read it? [Yn] " "$SUBDIR"
read -r ANSWER
case "$ANSWER" in
y|Y|"") DIRS+=("$SUBDIR") ;;
esac
fi
echo
done
if [ ! "$DIRS" ]; then
echo "No mounted media found. Using current directory instead." > /dev/stderr
echo
DIRS+=("$(pwd)")
fi
else
while [ "$1" ]; do
DIRS+=("$1")
shift
done
fi
# input sanitisation and derivation of destination paths
[ "$OUTARG" ] && OUTARG="$(realpath "$OUTARG")" || OUTARG="$(pwd)"
[ -d "$OUTARG" ] && OUTDIR="$OUTARG" || OUTDIR="$(dirname "$OUTARG")"
for DIR in "${DIRS[@]}"; do
if [ ! -d "$DIR" ]; then
echo "The given directory does not exist." >/dev/stderr
continue
fi
if [ -d "$OUTARG" ]; then
OUTNAME="$(basename "$(realpath "$DIR")")" # using `realpath` to catch `videotree .`
[ "$OUTNAME" = "/" ] && OUTNAME=root
else
OUTNAME="$(basename "$OUTARG")"
fi
cd "$DIR" || exit
if [ "$MODE" = "detailed" ]; then
# write a very verbose file with permission and owner information
call_to_tree tree -af --dirsfirst --du -DFins --timefmt "%Y-%m-%d %T" ${ACCESSRIGHTS:+-pug}
else
# write a smaller version with only file size information
call_to_tree tree -ax --dirsfirst --du -n -h
fi
done
echo "Done."