From 6bcdd800f606998f2065cea5a37e4be3dd62ba69 Mon Sep 17 00:00:00 2001 From: Adam Djellouli Date: Sat, 3 Oct 2026 23:23:28 +0200 Subject: [PATCH 1/5] Fix CI: shebang placement, beautysh formatting, trailing newlines - run_audiocraft.sh / run_videogen.sh: remove blank lines before the shebang (SC1128), mark executable, reformat with 4-space indents - install_signal.sh: reformat to repo indent style - backup.sh: collapse multi-line subshells that beautysh mis-indents --- src/backup.sh | 16 ++--- src/install_signal.sh | 159 +++++++++++++++++++++--------------------- src/run_audiocraft.sh | 129 +++++++++++++++++----------------- src/run_videogen.sh | 83 +++++++++++----------- 4 files changed, 189 insertions(+), 198 deletions(-) mode change 100755 => 100644 src/backup.sh mode change 100755 => 100644 src/install_signal.sh diff --git a/src/backup.sh b/src/backup.sh old mode 100755 new mode 100644 index 8957ab0..7d1d6f5 --- a/src/backup.sh +++ b/src/backup.sh @@ -183,26 +183,17 @@ canonical_path() { local path="$1" if [[ -d "$path" ]]; then - ( - cd "$path" >/dev/null 2>&1 && - pwd -P - ) + (cd "$path" >/dev/null 2>&1 && pwd -P) return fi if [[ -e "$path" ]]; then - ( - cd "$(dirname "$path")" >/dev/null 2>&1 && - printf '%s/%s\n' "$(pwd -P)" "$(basename "$path")" - ) + (cd "$(dirname "$path")" >/dev/null 2>&1 && printf '%s/%s\n' "$(pwd -P)" "$(basename "$path")") return fi if [[ -d "$(dirname "$path")" ]]; then - ( - cd "$(dirname "$path")" >/dev/null 2>&1 && - printf '%s/%s\n' "$(pwd -P)" "$(basename "$path")" - ) + (cd "$(dirname "$path")" >/dev/null 2>&1 && printf '%s/%s\n' "$(pwd -P)" "$(basename "$path")") return fi @@ -935,3 +926,4 @@ if [[ "$AUTO_MODE" == true || "$BACKUP_REQUESTED" == true ]]; then fi main_menu + diff --git a/src/install_signal.sh b/src/install_signal.sh old mode 100755 new mode 100644 index 753661f..8cdb9db --- a/src/install_signal.sh +++ b/src/install_signal.sh @@ -7,7 +7,7 @@ readonly KEYRING_PATH="/usr/share/keyrings/signal-desktop-keyring.gpg" readonly SOURCE_PATH="/etc/apt/sources.list.d/signal-desktop.sources" usage() { - cat <&2 - exit 1 + printf 'Error: %s\n' "$*" >&2 + exit 1 } require_command() { - command -v "$1" >/dev/null 2>&1 || die "Required command not found: $1" + command -v "$1" >/dev/null 2>&1 || die "Required command not found: $1" } setup_repository() ( - local temp_dir - temp_dir=$(mktemp -d) - trap 'rm -rf "$temp_dir"' EXIT - - echo "[1/3] Downloading and installing Signal's signing key" - curl --fail --location --silent --show-error "$KEY_URL" \ - | gpg --batch --yes --dearmor --output "$temp_dir/signal-desktop-keyring.gpg" - sudo install -Dm644 "$temp_dir/signal-desktop-keyring.gpg" "$KEYRING_PATH" - - echo "[2/3] Downloading and installing Signal's apt source" - curl --fail --location --silent --show-error \ - --output "$temp_dir/signal-desktop.sources" "$SOURCE_URL" - sudo install -Dm644 "$temp_dir/signal-desktop.sources" "$SOURCE_PATH" - - echo "[3/3] Updating apt metadata" - sudo apt-get update + local temp_dir + temp_dir=$(mktemp -d) + trap 'rm -rf "$temp_dir"' EXIT + + echo "[1/3] Downloading and installing Signal's signing key" + curl --fail --location --silent --show-error "$KEY_URL" \ + | gpg --batch --yes --dearmor --output "$temp_dir/signal-desktop-keyring.gpg" + sudo install -Dm644 "$temp_dir/signal-desktop-keyring.gpg" "$KEYRING_PATH" + + echo "[2/3] Downloading and installing Signal's apt source" + curl --fail --location --silent --show-error \ + --output "$temp_dir/signal-desktop.sources" "$SOURCE_URL" + sudo install -Dm644 "$temp_dir/signal-desktop.sources" "$SOURCE_PATH" + + echo "[3/3] Updating apt metadata" + sudo apt-get update ) install_signal() { - setup_repository - echo "Installing the newest Signal Desktop version" - sudo apt-get install -y signal-desktop - echo "Signal Desktop installation finished." + setup_repository + echo "Installing the newest Signal Desktop version" + sudo apt-get install -y signal-desktop + echo "Signal Desktop installation finished." } upgrade_signal() { - dpkg-query -W -f='${Status}' signal-desktop 2>/dev/null \ - | grep -q '^install ok installed$' \ - || die "Signal Desktop is not installed; run '$0 install' first." - - setup_repository - echo "Upgrading Signal Desktop to the newest available version" - sudo apt-get install -y --only-upgrade signal-desktop - echo "Signal Desktop upgrade finished." + dpkg-query -W -f='${Status}' signal-desktop 2>/dev/null \ + | grep -q '^install ok installed$' \ + || die "Signal Desktop is not installed; run '$0 install' first." + + setup_repository + echo "Upgrading Signal Desktop to the newest available version" + sudo apt-get install -y --only-upgrade signal-desktop + echo "Signal Desktop upgrade finished." } purge_signal() { - echo "Purging Signal Desktop" - if dpkg-query -W -f='${Status}' signal-desktop 2>/dev/null \ - | grep -q '^install ok installed$'; then - sudo apt-get purge -y signal-desktop - else - echo "Signal Desktop is not installed; skipping package removal." - fi - - echo "Removing Signal's apt source and signing key" - sudo rm -f -- "$SOURCE_PATH" "$KEYRING_PATH" - sudo apt-get update - echo "Signal Desktop purge finished. User data in ~/.config/Signal was kept." + echo "Purging Signal Desktop" + if dpkg-query -W -f='${Status}' signal-desktop 2>/dev/null \ + | grep -q '^install ok installed$'; then + sudo apt-get purge -y signal-desktop + else + echo "Signal Desktop is not installed; skipping package removal." + fi + + echo "Removing Signal's apt source and signing key" + sudo rm -f -- "$SOURCE_PATH" "$KEYRING_PATH" + sudo apt-get update + echo "Signal Desktop purge finished. User data in ~/.config/Signal was kept." } main() { - local action=${1:-install} - (( $# <= 1 )) || die "Too many arguments. Run '$0 help' for usage." - - case "$action" in - install|--install|-i) - require_command curl - require_command gpg - require_command sudo - require_command apt-get - install_signal - ;; - upgrade|--upgrade|-u) - require_command curl - require_command gpg - require_command sudo - require_command apt-get - require_command dpkg-query - require_command grep - upgrade_signal - ;; - purge|--purge|-p) - require_command sudo - require_command apt-get - require_command dpkg-query - require_command grep - purge_signal - ;; - help|--help|-h) - usage - ;; - *) - usage >&2 - die "Unknown command: $action" - ;; - esac + local action=${1:-install} + (( $# <= 1 )) || die "Too many arguments. Run '$0 help' for usage." + + case "$action" in + install|--install|-i) + require_command curl + require_command gpg + require_command sudo + require_command apt-get + install_signal + ;; + upgrade|--upgrade|-u) + require_command curl + require_command gpg + require_command sudo + require_command apt-get + require_command dpkg-query + require_command grep + upgrade_signal + ;; + purge|--purge|-p) + require_command sudo + require_command apt-get + require_command dpkg-query + require_command grep + purge_signal + ;; + help|--help|-h) + usage + ;; + *) + usage >&2 + die "Unknown command: $action" + ;; + esac } main "$@" + diff --git a/src/run_audiocraft.sh b/src/run_audiocraft.sh index 4c1ba8a..ca33f22 100644 --- a/src/run_audiocraft.sh +++ b/src/run_audiocraft.sh @@ -1,5 +1,3 @@ - - #!/usr/bin/env bash set -Eeuo pipefail @@ -21,13 +19,13 @@ KIND="${4:-auto}" # sfx = force AudioGen if [[ "$MODEL" == "sfx" || "$MODEL" == "audiogen" ]]; then - MODEL="facebook/audiogen-medium" - KIND="sfx" + MODEL="facebook/audiogen-medium" + KIND="sfx" fi if [[ "$MODEL" == "music" || "$MODEL" == "musicgen" ]]; then - MODEL="facebook/musicgen-medium" - KIND="music" + MODEL="facebook/musicgen-medium" + KIND="music" fi mkdir -p "$PROJECT_DIR" "$TOOLS_DIR" "$OUTPUT_DIR" @@ -41,7 +39,7 @@ echo "Kind: $KIND" echo "" if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then - cat <<'EOF' + cat <<'EOF' Usage: ./run_audiocraft.sh "prompt" duration model kind @@ -68,32 +66,32 @@ Recommended models: facebook/musicgen-large facebook/audiogen-medium EOF - exit 0 + exit 0 fi echo "Checking required system tools..." if ! command -v ffmpeg >/dev/null 2>&1 || ! command -v curl >/dev/null 2>&1; then - echo "Installing required system tools..." - sudo apt update - sudo apt install -y \ - curl \ - ffmpeg \ - pkg-config \ - build-essential \ - libavformat-dev \ - libavcodec-dev \ - libavdevice-dev \ - libavutil-dev \ - libavfilter-dev \ - libswscale-dev \ - libswresample-dev + echo "Installing required system tools..." + sudo apt update + sudo apt install -y \ + curl \ + ffmpeg \ + pkg-config \ + build-essential \ + libavformat-dev \ + libavcodec-dev \ + libavdevice-dev \ + libavutil-dev \ + libavfilter-dev \ + libswscale-dev \ + libswresample-dev fi if [ ! -x "$UV_BIN" ]; then - echo "Installing uv locally inside project..." - mkdir -p "$UV_DIR" - curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR="$UV_DIR" sh + echo "Installing uv locally inside project..." + mkdir -p "$UV_DIR" + curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR="$UV_DIR" sh fi export UV_CACHE_DIR="$PROJECT_DIR/.uv-cache" @@ -105,23 +103,23 @@ echo "Installing private Python 3.10 with uv..." RECREATE_VENV=0 if [ ! -x "$VENV_DIR/bin/python" ]; then - RECREATE_VENV=1 -else - CURRENT_VERSION="$("$VENV_DIR/bin/python" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" - - if [ "$CURRENT_VERSION" != "3.10" ]; then - echo "Existing venv uses Python $CURRENT_VERSION. Recreating with Python 3.10..." RECREATE_VENV=1 - elif ! "$VENV_DIR/bin/python" -m pip --version >/dev/null 2>&1; then - echo "Existing venv has no pip. Recreating with pip seeded..." - RECREATE_VENV=1 - fi +else + CURRENT_VERSION="$("$VENV_DIR/bin/python" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" + + if [ "$CURRENT_VERSION" != "3.10" ]; then + echo "Existing venv uses Python $CURRENT_VERSION. Recreating with Python 3.10..." + RECREATE_VENV=1 + elif ! "$VENV_DIR/bin/python" -m pip --version >/dev/null 2>&1; then + echo "Existing venv has no pip. Recreating with pip seeded..." + RECREATE_VENV=1 + fi fi if [ "$RECREATE_VENV" = "1" ]; then - rm -rf "$VENV_DIR" - echo "Creating private Python 3.10 venv with pip..." - "$UV_BIN" venv --seed --python 3.10 "$VENV_DIR" + rm -rf "$VENV_DIR" + echo "Creating private Python 3.10 venv with pip..." + "$UV_BIN" venv --seed --python 3.10 "$VENV_DIR" fi source "$VENV_DIR/bin/activate" @@ -137,8 +135,8 @@ python -m pip install --upgrade "pip<25" setuptools wheel packaging echo "" echo "Installing PyTorch CUDA 12.8 for RTX 50-series..." python -m pip install --upgrade \ - torch torchvision torchaudio \ - --index-url https://download.pytorch.org/whl/cu128 + torch torchvision torchaudio \ + --index-url https://download.pytorch.org/whl/cu128 echo "" echo "Installing AudioCraft without old pinned torch dependencies..." @@ -147,35 +145,35 @@ python -m pip install --no-deps audiocraft==1.3.0 echo "" echo "Installing AudioCraft runtime dependencies manually..." python -m pip install \ - "numpy<2.0.0" \ - "av==11.0.0" \ - einops \ - "flashy>=0.0.1" \ - "hydra-core>=1.1" \ - hydra_colorlog \ - julius \ - num2words \ - sentencepiece \ - "spacy==3.7.6" \ - huggingface_hub \ - tqdm \ - "transformers>=4.31.0,<4.58" \ - demucs \ - librosa \ - soundfile \ - gradio \ - torchmetrics \ - encodec \ - protobuf \ - pesq \ - pystoi \ - torchdiffeq + "numpy<2.0.0" \ + "av==11.0.0" \ + einops \ + "flashy>=0.0.1" \ + "hydra-core>=1.1" \ + hydra_colorlog \ + julius \ + num2words \ + sentencepiece \ + "spacy==3.7.6" \ + huggingface_hub \ + tqdm \ + "transformers>=4.31.0,<4.58" \ + demucs \ + librosa \ + soundfile \ + gradio \ + torchmetrics \ + encodec \ + protobuf \ + pesq \ + pystoi \ + torchdiffeq echo "" echo "Forcing PyTorch CUDA 12.8 again in case another package touched it..." python -m pip install --upgrade \ - torch torchvision torchaudio \ - --index-url https://download.pytorch.org/whl/cu128 + torch torchvision torchaudio \ + --index-url https://download.pytorch.org/whl/cu128 echo "" echo "Patching AudioCraft to run without xformers..." @@ -363,3 +361,4 @@ echo "" echo "Done." echo "Output folder:" echo "$OUTPUT_DIR" + diff --git a/src/run_videogen.sh b/src/run_videogen.sh index 8c0ecb5..971f577 100644 --- a/src/run_videogen.sh +++ b/src/run_videogen.sh @@ -1,5 +1,3 @@ - - #!/usr/bin/env bash set -Eeuo pipefail @@ -62,20 +60,20 @@ EOF echo "" if ! command -v ffmpeg >/dev/null 2>&1 || ! command -v curl >/dev/null 2>&1 || ! command -v git >/dev/null 2>&1; then - echo "Installing required system tools..." - sudo apt update - sudo apt install -y \ - curl \ - git \ - ffmpeg \ - pkg-config \ - build-essential + echo "Installing required system tools..." + sudo apt update + sudo apt install -y \ + curl \ + git \ + ffmpeg \ + pkg-config \ + build-essential fi if [ ! -x "$UV_BIN" ]; then - echo "Installing uv locally inside project..." - mkdir -p "$UV_DIR" - curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR="$UV_DIR" sh + echo "Installing uv locally inside project..." + mkdir -p "$UV_DIR" + curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR="$UV_DIR" sh fi echo "Installing private Python 3.10 with uv..." @@ -84,23 +82,23 @@ echo "Installing private Python 3.10 with uv..." RECREATE_VENV=0 if [ ! -x "$VENV_DIR/bin/python" ]; then - RECREATE_VENV=1 -else - CURRENT_VERSION="$("$VENV_DIR/bin/python" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" - - if [ "$CURRENT_VERSION" != "3.10" ]; then - echo "Existing venv uses Python $CURRENT_VERSION. Recreating with Python 3.10..." RECREATE_VENV=1 - elif ! "$VENV_DIR/bin/python" -m pip --version >/dev/null 2>&1; then - echo "Existing venv has no pip. Recreating with pip seeded..." - RECREATE_VENV=1 - fi +else + CURRENT_VERSION="$("$VENV_DIR/bin/python" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" + + if [ "$CURRENT_VERSION" != "3.10" ]; then + echo "Existing venv uses Python $CURRENT_VERSION. Recreating with Python 3.10..." + RECREATE_VENV=1 + elif ! "$VENV_DIR/bin/python" -m pip --version >/dev/null 2>&1; then + echo "Existing venv has no pip. Recreating with pip seeded..." + RECREATE_VENV=1 + fi fi if [ "$RECREATE_VENV" = "1" ]; then - rm -rf "$VENV_DIR" - echo "Creating private Python 3.10 venv with pip..." - "$UV_BIN" venv --seed --python 3.10 "$VENV_DIR" + rm -rf "$VENV_DIR" + echo "Creating private Python 3.10 venv with pip..." + "$UV_BIN" venv --seed --python 3.10 "$VENV_DIR" fi source "$VENV_DIR/bin/activate" @@ -116,24 +114,24 @@ python -m pip install --upgrade "pip<25" setuptools wheel packaging echo "" echo "Installing PyTorch CUDA 12.8 for RTX 50-series..." python -m pip install --upgrade \ - torch torchvision torchaudio \ - --index-url https://download.pytorch.org/whl/cu128 + torch torchvision torchaudio \ + --index-url https://download.pytorch.org/whl/cu128 echo "" echo "Installing video generation dependencies..." python -m pip install --upgrade \ - accelerate \ - transformers \ - sentencepiece \ - protobuf \ - safetensors \ - "huggingface_hub[hf_transfer]" \ - hf_transfer \ - imageio \ - imageio-ffmpeg \ - pillow \ - numpy \ - ftfy + accelerate \ + transformers \ + sentencepiece \ + protobuf \ + safetensors \ + "huggingface_hub[hf_transfer]" \ + hf_transfer \ + imageio \ + imageio-ffmpeg \ + pillow \ + numpy \ + ftfy echo "" echo "Installing latest Diffusers from GitHub for LTX offloading support..." @@ -142,8 +140,8 @@ python -m pip install --upgrade git+https://github.com/huggingface/diffusers.git echo "" echo "Forcing PyTorch CUDA 12.8 again in case another package touched it..." python -m pip install --upgrade \ - torch torchvision torchaudio \ - --index-url https://download.pytorch.org/whl/cu128 + torch torchvision torchaudio \ + --index-url https://download.pytorch.org/whl/cu128 echo "" echo "Checking GPU support..." @@ -359,3 +357,4 @@ echo "" echo "Done." echo "Output folder:" echo "$OUTPUT_DIR" + From 455ab215078591171be73b9289e74e7f21364b64 Mon Sep 17 00:00:00 2001 From: Adam Djellouli Date: Sat, 3 Oct 2026 23:38:05 +0200 Subject: [PATCH 2/5] Audit and fix existing scripts Go through every script in src/ and fix bugs, misleading help text and leftover cruft while keeping each script's interface and style. Notable fixes: - random_password.sh always failed with no output; squash_branch.sh, system_info.sh, dead_code.sh and fetch_github_repos_names.sh were broken outright - resize_to_a4.sh overwrote originals by default (now writes ./resized) - strip_python_comments.sh mangled code and strings; strip_digits.sh deleted whole words instead of digits - web_block.sh --clear removed localhost entries; all actions now stay inside the managed section of the hosts file - backup.sh: no plaintext archive left on encryption failure, rsync errors propagate, passphrase no longer visible in ps, retention is scoped per host - extract.sh wrote .gz output over the directory path; convert_to_mp4.sh could overwrite its own input - in-place editors (last_line_empty, remove_*_whitespace, beautify, ...) now preserve file permissions and skip .git and binary files - beautify_script.sh --check now fails on beautysh parse errors - disk_usage.sh, clear_cache.sh, purge_and_reinstall_nodejs.sh, cpu_temp.sh, network_bandwidth.sh: option handling and locale fixes - leading-zero numbers no longer parsed as octal across math scripts - eval on user input removed (clear_cache.sh, empty_trash.sh, sum_args.sh) - errors go to stderr with non-zero exit codes; missing dependencies are reported up front - restore the executable bit on scripts that had lost it --- .github/workflows/{blank.yml => ci.yml} | 0 src/adjust_volume.sh | 67 +- src/alias_all_the_scripts.sh | 29 +- src/are_anagrams.sh | 60 +- src/arith_mean.sh | 15 +- src/arithmetic_operations.sh | 33 +- src/backup.sh | 117 +- src/beautify_script.sh | 119 +- src/change_commit_date.sh | 95 +- src/check_apt_avail.sh | 5 +- src/check_if_root.sh | 34 +- src/check_os.sh | 14 +- src/christmas_tree.sh | 16 +- src/clear_cache.sh | 109 +- src/contributions_by_git_author.sh | 52 +- src/convert_to_gif.sh | 46 +- src/convert_to_mp4.sh | 19 +- src/correct_file_names.sh | 61 +- src/count_char.sh | 17 +- src/count_files.sh | 46 +- src/count_lines_of_code.sh | 26 +- src/cpu_temp.sh | 169 +-- src/cpu_usage.sh | 48 +- src/dead_code.sh | 142 ++- src/decimal_binary.sh | 43 +- src/digits.sh | 6 +- src/disk_usage.sh | 173 +-- src/display_weather.sh | 43 +- src/download_all_github_repos.sh | 1384 ++++++++++++----------- src/empty_trash.sh | 102 +- src/extend_video_height.sh | 39 +- src/extract.sh | 36 +- src/factorial.sh | 20 +- src/fetch_github_repos_names.sh | 90 +- src/firefox_profile_usb.sh | 20 +- src/for_loop.sh | 11 +- src/generate_books.sh | 65 +- src/gif_to_mp4_converter.sh | 54 +- src/hamming_distance.sh | 29 +- src/html_table_to_markdown.sh | 63 +- src/install_nvidia_and_prepare_steam.sh | 25 +- src/install_signal.sh | 10 + src/ip_info.sh | 58 +- src/is_palindrome.sh | 4 +- src/is_prime.sh | 37 +- src/last_line_empty.sh | 78 +- src/line_counter.sh | 10 +- src/lower.sh | 4 +- src/make_dir.sh | 27 +- src/make_short.sh | 157 ++- src/matrix_display.sh | 19 +- src/max_array.sh | 11 +- src/middle_line.sh | 24 +- src/min_array.sh | 12 +- src/month_to_number.sh | 45 +- src/network_bandwidth.sh | 169 +-- src/numbers_in_interval.sh | 22 +- src/orphans.sh | 88 +- src/pdf_page_extractor.sh | 72 +- src/prepend_text_to_file.sh | 22 +- src/program_on_port.sh | 31 +- src/progress_bar.sh | 45 +- src/prompt_for_answer.sh | 24 +- src/purge_and_reinstall_nodejs.sh | 59 +- src/purge_pip.sh | 128 ++- src/ram_memory.sh | 272 ++--- src/random_int.sh | 106 +- src/random_password.sh | 115 +- src/recently_modified_files.sh | 71 +- src/release_package.sh | 121 +- src/remove_branch.sh | 40 +- src/remove_carriage_return.sh | 67 +- src/remove_consecutive_blank_lines.sh | 30 +- src/remove_diacritics.sh | 49 +- src/remove_duplicate_lines.sh | 22 +- src/remove_duplicates_in_array.sh | 32 +- src/remove_empty_lines.sh | 22 +- src/remove_leading_whitespace.sh | 12 +- src/remove_n_last_commits.sh | 88 +- src/remove_trailing_whitespaces.sh | 85 +- src/rename_extension.sh | 66 +- src/replace_everywhere.sh | 50 +- src/reset_to_origin.sh | 57 +- src/resize_to_a4.sh | 155 +-- src/run_audiocraft.sh | 83 +- src/run_videogen.sh | 44 +- src/server_health_monitor.sh | 18 +- src/setup_obs_background_blur.sh | 27 +- src/sort_string.sh | 11 +- src/speed_up_video.sh | 37 +- src/sqrt.sh | 52 +- src/squash_branch.sh | 20 +- src/squash_n_last_commits.sh | 75 +- src/stretch_mp4_vertically.sh | 42 +- src/strip_cpp_comments.sh | 150 +-- src/strip_digits.sh | 25 +- src/strip_python_comments.sh | 118 +- src/sum_args.sh | 22 +- src/sum_smaller_numbers.sh | 8 +- src/swap_files.sh | 45 +- src/switch_slashes.sh | 52 +- src/system_info.sh | 70 +- src/time_execution.sh | 25 +- src/timer.sh | 16 +- src/upper.sh | 4 +- src/web_block.sh | 333 +++--- src/word_histogram.sh | 130 ++- src/xfce-default.sh | 22 +- src/youtube_to_mp3.sh | 125 +- src/zombies.sh | 34 +- 110 files changed, 4286 insertions(+), 3460 deletions(-) rename .github/workflows/{blank.yml => ci.yml} (100%) mode change 100644 => 100755 src/backup.sh mode change 100644 => 100755 src/install_signal.sh mode change 100644 => 100755 src/run_audiocraft.sh mode change 100644 => 100755 src/run_videogen.sh mode change 100644 => 100755 src/setup_obs_background_blur.sh diff --git a/.github/workflows/blank.yml b/.github/workflows/ci.yml similarity index 100% rename from .github/workflows/blank.yml rename to .github/workflows/ci.yml diff --git a/src/adjust_volume.sh b/src/adjust_volume.sh index 67b779d..47fe659 100755 --- a/src/adjust_volume.sh +++ b/src/adjust_volume.sh @@ -1,13 +1,13 @@ #!/usr/bin/env bash # Script Name: adjust_volume.sh -# Description: Adjusts the volume for all available PulseAudio sinks. -# The adjustment can be a percentage increase or decrease (e.g., +5%, -10%) -# or a predefined mode (e.g., "full", "mute", "unmute", "reset"). -# Usage: ./adjust_volume.sh -v [VALUE] +# Description: Adjusts the volume for all available PulseAudio/PipeWire sinks (via pactl). +# The adjustment can be relative (e.g., +5%, -10%), absolute (e.g., 50%) +# or a predefined mode: full (100%), mute, unmute, reset (unmute and set 100%). +# Usage: ./adjust_volume.sh -v VALUE # ./adjust_volume.sh -h # Options: -# -v [VALUE] Adjust volume by a specific percentage or set to a specific mode. +# -v VALUE Adjust volume by a specific percentage or set to a specific mode. # Valid modes: full, mute, unmute, reset. # -h Display this help message and exit. @@ -16,14 +16,12 @@ VALID_STRINGS=("full" "mute" "unmute" "reset") # Function to display help/usage information display_help() { - echo "Usage: $0 [OPTION] [VALUE]" + echo "Usage: $0 -v VALUE" echo echo "Options:" - echo " -v [VALUE] Adjust volume by a specific amount (e.g., +5%, -10%) or set to a specific mode" - echo " Valid modes: ${VALID_STRINGS[*]}" + echo " -v VALUE Change volume relatively (e.g., +5%, -10%), set it absolutely (e.g., 50%)" + echo " or apply a mode: full (100%), mute, unmute, reset (unmute + 100%)" echo " -h Display this help message and exit" - echo - exit 0 } # Function to validate volume adjustment input @@ -37,10 +35,10 @@ validate_input() { fi done - # Check if input is a number with + or - and within the range of -100 to +100 + # Check if input is a number with optional + or - and within the range of -100 to +100 if [[ "$input" =~ ^[+-]?[0-9]+%?$ ]]; then - local value="${input%?}" # Remove % if present - if ((value >= -100 && value <= 100)); then + local value="${input%\%}" # Remove % if present + if (( 10#${value#[+-]} <= 100 )); then return 0 fi fi @@ -49,6 +47,7 @@ validate_input() { } # Parse command-line arguments +input_value="" while getopts ":v:h" opt; do case ${opt} in v) @@ -56,30 +55,60 @@ while getopts ":v:h" opt; do ;; h) display_help + exit 0 ;; "?") echo "Invalid option: -$OPTARG" >&2 - display_help + display_help >&2 + exit 1 ;; ":") echo "Option -$OPTARG requires an argument." >&2 - display_help + display_help >&2 + exit 1 ;; esac done +if [[ -z "$input_value" ]]; then + display_help >&2 + exit 1 +fi + # Validate the input if ! validate_input "$input_value"; then echo "Invalid input: '$input_value'. Use -h for help." >&2 exit 1 fi +if ! command -v pactl >/dev/null 2>&1; then + echo "Error: pactl is not installed." >&2 + exit 1 +fi + +mapfile -t sinks < <(pactl list short sinks | cut -f1) +if [[ ${#sinks[@]} -eq 0 ]]; then + echo "Error: No audio sinks found." >&2 + exit 1 +fi + # Apply the volume adjustment to all sinks -for sink in $(pactl list short sinks | cut -f1); do - pactl set-sink-volume "$sink" "$input_value" +status=0 +for sink in "${sinks[@]}"; do + # Numbers get a % suffix: without it pactl treats them as raw volume values + case "$input_value" in + full) pactl set-sink-volume "$sink" 100% ;; + mute) pactl set-sink-mute "$sink" 1 ;; + unmute) pactl set-sink-mute "$sink" 0 ;; + reset) pactl set-sink-mute "$sink" 0 && pactl set-sink-volume "$sink" 100% ;; + *) pactl set-sink-volume "$sink" "${input_value%\%}%" ;; + esac || status=1 done -echo "Volume adjusted by $input_value for all sinks." +if [[ $status -ne 0 ]]; then + echo "Error: Failed to adjust the volume of some sinks." >&2 + exit 1 +fi -exit 0 +echo "Volume adjusted ($input_value) for all sinks." diff --git a/src/alias_all_the_scripts.sh b/src/alias_all_the_scripts.sh index afc8871..3b32148 100755 --- a/src/alias_all_the_scripts.sh +++ b/src/alias_all_the_scripts.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash -# Script Name: script_aliaser.sh +# Script Name: alias_all_the_scripts.sh # Description: Copies Bash scripts from a source directory to a destination directory, # sets execution permissions, and adds them as aliases in the user's .bash_aliases file. -# Usage: ./script_aliaser.sh -s [-d ] [-v] -# Example: ./script_aliaser.sh -s /path/to/source -d /path/to/destination -v +# Usage: ./alias_all_the_scripts.sh -s [-d ] [-v] +# Example: ./alias_all_the_scripts.sh -s /path/to/source -d /path/to/destination -v set -euo pipefail @@ -14,13 +14,14 @@ destination_dir="$HOME/.bash_scripts" alias_file="$HOME/.bash_aliases" usage() { + local exit_code="${1:-1}" echo "Usage: $0 -s [-d ] [-v]" echo "Options:" echo " -s Specify the source directory containing scripts (required)." echo " -d Specify the destination directory (default: $destination_dir)." echo " -v Enable verbose output." echo " -h Display this help message." - exit 1 + exit "$exit_code" } log() { @@ -43,33 +44,35 @@ main() { verbose=true ;; h) - usage + usage 0 ;; "?") echo "Invalid option: -$OPTARG" >&2 - usage + usage >&2 ;; ":") echo "Option -$OPTARG requires an argument." >&2 - usage + usage >&2 ;; esac done # Check if source directory is set if [ -z "${source_dir:-}" ]; then - echo "Error: Source directory is required." - usage + echo "Error: Source directory is required." >&2 + usage >&2 fi # Validate the source directory if [ ! -d "$source_dir" ]; then - echo "Error: Source directory '$source_dir' does not exist." + echo "Error: Source directory '$source_dir' does not exist." >&2 exit 1 fi # Create destination directory if it doesn't exist mkdir -p "$destination_dir" + # Aliases must use an absolute path to work from any directory + destination_dir="$(cd "$destination_dir" && pwd)" log "Copying scripts from '$source_dir' to '$destination_dir' and setting execution permissions..." @@ -95,15 +98,15 @@ main() { alias_name="${script_name%.*}" dest_file="$destination_dir/$script_name" - # Check if alias already exists - if grep -q "^alias $alias_name=" "$alias_file"; then + # Check if alias already exists (literal match, names may contain regex characters) + if awk -v prefix="alias $alias_name=" 'index($0, prefix) == 1 { found = 1 } END { exit !found }' "$alias_file"; then log "Alias '$alias_name' already exists, skipping." else # Add alias to .bash_aliases echo "alias $alias_name='$dest_file'" >> "$alias_file" log "Added alias: $alias_name -> $dest_file" fi - done < <(find "$destination_dir" -type f -name "*.sh" -print0) + done < <(find "$destination_dir" -maxdepth 1 -type f -name "*.sh" -print0) echo "Installation successful! Bash scripts have been installed and aliased." echo "Please source your .bashrc or .bash_aliases to apply changes: source $alias_file" diff --git a/src/are_anagrams.sh b/src/are_anagrams.sh index 6533413..1ea2e0e 100755 --- a/src/are_anagrams.sh +++ b/src/are_anagrams.sh @@ -13,6 +13,7 @@ # --stdin Read strings from standard input. # -v, --verbose Enable verbose output. # -h, --help Display this help message. +# Exit status: 0 if the strings are anagrams, 1 if they are not, 2 on usage errors. # Examples: # ./are_anagrams.sh -i "Listen" "Silent" # ./are_anagrams.sh -i -s -f strings.txt @@ -43,7 +44,7 @@ Options: -f, --file FILE Read strings from a file (one per line). --stdin Read strings from standard input. -v, --verbose Enable verbose output. - -h, --help Display this help message." + -h, --help Display this help message." } # Function for verbose logging @@ -74,6 +75,10 @@ while [[ "$#" -gt 0 ]]; do shift ;; -f|--file) + if [[ $# -lt 2 ]]; then + echo "Error: $1 requires a file argument." >&2 + exit 2 + fi READ_FROM_FILE=true INPUT_FILE="$2" shift 2 @@ -90,10 +95,15 @@ while [[ "$#" -gt 0 ]]; do usage exit 0 ;; + --) + shift + ARGS+=("$@") + break + ;; -*) - echo "Unknown option: $1" - usage - exit 1 + echo "Unknown option: $1" >&2 + usage >&2 + exit 2 ;; *) ARGS+=("$1") @@ -105,25 +115,21 @@ done # Collect input strings if [[ "$READ_FROM_FILE" = true ]]; then if [[ ! -f "$INPUT_FILE" ]]; then - echo "Error: File '$INPUT_FILE' not found." - exit 1 + echo "Error: File '$INPUT_FILE' not found." >&2 + exit 2 fi - while IFS= read -r line; do - INPUT_STRINGS+=("$line") - done < "$INPUT_FILE" + mapfile -t INPUT_STRINGS < "$INPUT_FILE" elif [[ "$READ_FROM_STDIN" = true ]]; then - while IFS= read -r line; do - INPUT_STRINGS+=("$line") - done + mapfile -t INPUT_STRINGS else INPUT_STRINGS+=("${ARGS[@]}") fi # Check if at least two strings are provided if [[ ${#INPUT_STRINGS[@]} -lt 2 ]]; then - echo "Error: At least two strings must be provided." - usage - exit 1 + echo "Error: At least two strings must be provided." >&2 + usage >&2 + exit 2 fi # Function to clean and sort a string @@ -132,34 +138,26 @@ sort_string() { # Remove spaces if needed if [[ "$IGNORE_SPACES" = true ]]; then - string=$(echo "$string" | tr -d '[:space:]') + string=$(printf '%s' "$string" | tr -d '[:space:]') fi # Remove punctuation if needed if [[ "$IGNORE_PUNCTUATION" = true ]]; then - string=$(echo "$string" | tr -d '[:punct:]') + string=$(printf '%s' "$string" | tr -d '[:punct:]') fi # Convert to lower case if needed if [[ "$IGNORE_CASE" = true ]]; then if [[ "$HANDLE_UNICODE" = true ]]; then - string=$(echo "$string" | awk '{print tolower($0)}') + # Bash's own lowercasing is multibyte-aware (awk/tr may not be) + string="${string,,}" else - string=$(echo "$string" | tr '[:upper:]' '[:lower:]') + string=$(printf '%s' "$string" | tr '[:upper:]' '[:lower:]') fi fi - # Split into characters, sort, and reassemble - if [[ "$HANDLE_UNICODE" = true ]]; then - # For Unicode, use sed and sort with appropriate locale - # Assume locale is set properly - sorted_string=$(echo "$string" | sed 's/./&\n/g' | LC_ALL=C sort | tr -d '\n') - else - # For ASCII, use grep -o . - sorted_string=$(echo "$string" | grep -o . | LC_ALL=C sort | tr -d '\n') - fi - - echo "$sorted_string" + # Split into characters (multibyte-aware in a UTF-8 locale), sort bytewise, and reassemble + printf '%s\n' "$string" | grep -o . | LC_ALL=C sort | tr -d '\n' } # Main comparison logic @@ -173,7 +171,7 @@ for ((i=1; i<${#INPUT_STRINGS[@]}; i++)); do log "Current string sorted: $current_string_sorted" if [[ "$first_string_sorted" != "$current_string_sorted" ]]; then echo "The strings are not anagrams." - exit 0 + exit 1 fi done diff --git a/src/arith_mean.sh b/src/arith_mean.sh index 9b73058..5761028 100755 --- a/src/arith_mean.sh +++ b/src/arith_mean.sh @@ -25,17 +25,22 @@ mean() { main() { # Check if at least one argument is given if [ $# -eq 0 ]; then - echo "Error: No arguments provided." - echo "Usage: arith_mean.sh list_of_numbers" - echo " list_of_numbers - A space-separated list of numbers" - echo "Example: ./arith_mean.sh 1 2 3 4 5" + echo "Error: No arguments provided." >&2 + echo "Usage: arith_mean.sh list_of_numbers" >&2 + echo " list_of_numbers - A space-separated list of numbers" >&2 + echo "Example: ./arith_mean.sh 1 2 3 4 5" >&2 + exit 1 + fi + + if ! command -v bc &> /dev/null; then + echo "Error: bc is required but it's not installed." >&2 exit 1 fi # Check if each argument is a valid number for i in "$@"; do if ! [[ $i =~ ^-?[0-9]+([.][0-9]+)?$ ]]; then - echo "Error: $i is not a number" + echo "Error: $i is not a number" >&2 exit 1 fi done diff --git a/src/arithmetic_operations.sh b/src/arithmetic_operations.sh index dca7d12..bd2ff6d 100755 --- a/src/arithmetic_operations.sh +++ b/src/arithmetic_operations.sh @@ -4,28 +4,43 @@ # Description: A calculator for arithmetic operations. This script evaluates a mathematical expression provided by the user. # Usage: arithmetic_operations.sh expression # expression - A mathematical expression with the operators: +, -, *, /, %, ^. Parentheses for grouping are also supported. +# Expressions containing % are evaluated with integer arithmetic (bc scale=0), +# because bc's % only yields a true remainder at scale 0. # Example: ./arithmetic_operations.sh "(2+2)*3/2^2" # Function: Evaluates the arithmetic expression and prints the result with 3 decimal precision calculate() { - # check if bc is installed - if ! command -v bc &> /dev/null - then - echo "bc is required but it's not installed. Aborting." + local expression="$1" + local scale=5 + local result + + if [[ "$expression" == *%* ]]; then + scale=0 + fi + + result=$(echo "scale=$scale; $expression" | BC_LINE_LENGTH=0 bc -l 2>&1) + + if ! [[ "$result" =~ ^-?[0-9]*\.?[0-9]+$ ]]; then + echo "Error: could not evaluate '$expression': ${result:-no result}" >&2 exit 1 fi - echo "scale=5; $1" | bc -l | awk '{printf "%.3f\n", $1}' + LC_ALL=C awk -v value="$result" 'BEGIN { printf "%.3f\n", value }' } # Function: Main function to control the flow of the script main() { # Check if exactly one argument is given if [ $# -ne 1 ]; then - echo "Error: Invalid number of arguments." - echo "Usage: arithmetic_operations.sh expression" - echo " expression - A mathematical expression with the operators: +, -, *, /, %, ^. Parentheses for grouping are also supported." - echo "Example: ./arithmetic_operations.sh \"(2+2)*3/2^2\"" + echo "Error: Invalid number of arguments." >&2 + echo "Usage: arithmetic_operations.sh expression" >&2 + echo " expression - A mathematical expression with the operators: +, -, *, /, %, ^. Parentheses for grouping are also supported." >&2 + echo "Example: ./arithmetic_operations.sh \"(2+2)*3/2^2\"" >&2 + exit 1 + fi + + if ! command -v bc &> /dev/null; then + echo "Error: bc is required but it's not installed. Aborting." >&2 exit 1 fi diff --git a/src/backup.sh b/src/backup.sh old mode 100644 new mode 100755 index 7d1d6f5..5d159ab --- a/src/backup.sh +++ b/src/backup.sh @@ -46,8 +46,10 @@ VERBOSE=false NO_COLOR=false CURRENT_ARTIFACT="" +PARTIAL_PATHS=() # files/dirs of the in-progress backup, removed if it fails LOCK_DIR="" BACKUP_SUCCEEDED=false +FAILED_SOURCES=0 ############################################################################### # Logging @@ -96,29 +98,45 @@ die() { ############################################################################### # Cleanup ############################################################################### +release_lock() { + if [[ -n "$LOCK_DIR" && -d "$LOCK_DIR" ]]; then + rmdir -- "$LOCK_DIR" 2>/dev/null || true + fi + LOCK_DIR="" +} + cleanup() { local exit_code=$? + local path="" - trap - EXIT INT TERM + trap - EXIT - if [[ $exit_code -ne 0 && "$BACKUP_SUCCEEDED" != true && -n "$CURRENT_ARTIFACT" && -e "$CURRENT_ARTIFACT" ]]; then - log_msg WARN "Removing incomplete backup artifact: $CURRENT_ARTIFACT" - rm -rf -- "$CURRENT_ARTIFACT" + # Remove every piece of an unfinished backup (snapshot dir, plain archive + # that was about to be encrypted, partial output), not just the last one. + if [[ $exit_code -ne 0 && "$BACKUP_SUCCEEDED" != true ]]; then + for path in "${PARTIAL_PATHS[@]}"; do + if [[ -e "$path" ]]; then + log_msg WARN "Removing incomplete backup artifact: $path" + rm -rf -- "$path" + fi + done fi - if [[ -n "$LOCK_DIR" && -d "$LOCK_DIR" ]]; then - rmdir -- "$LOCK_DIR" 2>/dev/null || true - fi + release_lock exit "$exit_code" } -trap cleanup EXIT INT TERM +# Signals exit with a non-zero status so the EXIT trap treats them as failures +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM ############################################################################### # Usage ############################################################################### print_usage() { + local IFS=' ' cat </dev/null 2>&1 && pwd -P) - return - fi - - if [[ -e "$path" ]]; then - (cd "$(dirname "$path")" >/dev/null 2>&1 && printf '%s/%s\n' "$(pwd -P)" "$(basename "$path")") + # A directory we cannot enter (no x permission) falls through to its parent + if [[ -d "$path" ]] && (cd "$path" >/dev/null 2>&1 && pwd -P); then return fi @@ -497,8 +510,9 @@ write_metadata() { } >"$manifest_path" } +# Returns 0 on success, 1 if the source is missing/unsupported, 2 if rsync failed. sync_source() { - local source="$1" + local source local snapshot_dir="$2" local relative_path destination_dir parent_dir local rsync_args=(--archive --human-readable) @@ -508,13 +522,16 @@ sync_source() { rsync_args+=("--exclude=$pattern") done + # Absolute path, so relative sources (e.g. ../x) cannot escape the snapshot + source="$(canonical_path "$1")" + if [[ -d "$source" ]]; then relative_path="${source#/}" destination_dir="$snapshot_dir/files/$relative_path" mkdir -p -- "$destination_dir" log_msg INFO "Backing up directory: $source" - rsync "${rsync_args[@]}" -- "$source/" "$destination_dir/" - return 0 + run_rsync "$source" "${rsync_args[@]}" -- "$source/" "$destination_dir/" + return fi if [[ -f "$source" ]]; then @@ -522,14 +539,33 @@ sync_source() { parent_dir="$snapshot_dir/files/$(dirname "$relative_path")" mkdir -p -- "$parent_dir" log_msg INFO "Backing up file: $source" - rsync "${rsync_args[@]}" -- "$source" "$parent_dir/" - return 0 + run_rsync "$source" "${rsync_args[@]}" -- "$source" "$parent_dir/" + return fi log_msg WARN "Skipping unsupported or missing source: $source" return 1 } +# run_rsync SOURCE RSYNC_ARGS...: exit code 24 (files vanished during transfer) +# is harmless for live data; any other failure is reported and returns 2. +run_rsync() { + local source="$1" rc=0 + shift + rsync "$@" || rc=$? + case "$rc" in + 0) return 0 ;; + 24) + log_msg WARN "Some files vanished while backing up: $source" + return 0 + ;; + *) + log_msg ERROR "rsync failed (exit $rc) while backing up: $source" + return 2 + ;; + esac +} + package_backup() { local snapshot_dir="$1" local backup_name="$2" @@ -540,11 +576,13 @@ package_backup() { if [[ "$COMPRESS" == true ]]; then archive_path="${TARGET_DIR%/}/${backup_name}.tar.gz" CURRENT_ARTIFACT="$archive_path" + PARTIAL_PATHS+=("$archive_path") log_msg INFO "Compressing backup to: $archive_path" tar -czf "$archive_path" -C "$(dirname "$snapshot_dir")" "$(basename "$snapshot_dir")" else archive_path="${TARGET_DIR%/}/${backup_name}.tar" CURRENT_ARTIFACT="$archive_path" + PARTIAL_PATHS+=("$archive_path") log_msg INFO "Packing backup to: $archive_path" tar -cf "$archive_path" -C "$(dirname "$snapshot_dir")" "$(basename "$snapshot_dir")" fi @@ -556,6 +594,7 @@ package_backup() { if [[ "$ENCRYPT" == true ]]; then encrypted_path="${artifact}.gpg" CURRENT_ARTIFACT="$encrypted_path" + PARTIAL_PATHS+=("$encrypted_path") log_msg INFO "Encrypting backup to: $encrypted_path" if [[ -n "$GPG_PASSPHRASE_FILE" ]]; then @@ -565,11 +604,12 @@ package_backup() { --output "$encrypted_path" \ "$artifact" else + # Pass the passphrase on a file descriptor so it never shows up in ps gpg --batch --yes --pinentry-mode loopback \ - --passphrase "$GPG_PASSPHRASE" \ + --passphrase-fd 3 \ --symmetric \ --output "$encrypted_path" \ - "$artifact" + "$artifact" 3<<<"$GPG_PASSPHRASE" fi rm -f -- "$artifact" @@ -597,9 +637,11 @@ apply_retention_policy() { weekly_cutoff=$(( now_epoch - (RETENTION_WEEKLY * 7 * 86400) )) monthly_cutoff=$(( now_epoch - (RETENTION_MONTHLY * 31 * 86400) )) + # Only this host's backups: several machines may share one destination and + # must not prune each other's backups. while IFS= read -r item; do [[ -n "$item" ]] && candidates+=("$item") - done < <(find "$target_dir" -mindepth 1 -maxdepth 1 \( -type f -o -type d \) -name 'backup_*' -printf '%f\n') + done < <(find "$target_dir" -mindepth 1 -maxdepth 1 \( -type f -o -type d \) -name "backup_*_${HOST_TAG}*" -printf '%f\n') [[ ${#candidates[@]} -gt 0 ]] || return 0 @@ -608,6 +650,7 @@ apply_retention_policy() { stamp="${base_name#backup_}" stamp="${stamp%%_*}" [[ "$stamp" =~ ^[0-9]{8}T[0-9]{6}Z$ ]] || continue + [[ "$base_name" == "backup_${stamp}_${HOST_TAG}" ]] || continue epoch="$(timestamp_to_epoch "$stamp")" entries+=("${epoch}"$'\t'"${name}") done @@ -647,7 +690,12 @@ apply_retention_policy() { } create_backup() { - local timestamp backup_name snapshot_dir source copied_count=0 + local timestamp backup_name snapshot_dir source copied_count=0 rc=0 + + # Reset per-run state (the interactive menu can run several backups) + BACKUP_SUCCEEDED=false + FAILED_SOURCES=0 + PARTIAL_PATHS=() validate_configuration create_lock @@ -656,13 +704,18 @@ create_backup() { backup_name="backup_${timestamp}_${HOST_TAG}" snapshot_dir="${TARGET_DIR%/}/${backup_name}" CURRENT_ARTIFACT="$snapshot_dir" + PARTIAL_PATHS+=("$snapshot_dir") mkdir -p -- "$snapshot_dir/files" write_metadata "$snapshot_dir" for source in "${SOURCE_DIRS[@]}"; do - if sync_source "$source" "$snapshot_dir"; then + rc=0 + sync_source "$source" "$snapshot_dir" || rc=$? + if [[ $rc -eq 0 ]]; then copied_count=$((copied_count + 1)) + elif [[ $rc -eq 2 ]]; then + FAILED_SOURCES=$((FAILED_SOURCES + 1)) fi done @@ -670,9 +723,15 @@ create_backup() { package_backup "$snapshot_dir" "$backup_name" BACKUP_SUCCEEDED=true + PARTIAL_PATHS=() - log_msg INFO "Backup created successfully: $CURRENT_ARTIFACT" + if [[ $FAILED_SOURCES -gt 0 ]]; then + log_msg WARN "Backup created with $FAILED_SOURCES failed source(s): $CURRENT_ARTIFACT" + else + log_msg INFO "Backup created successfully: $CURRENT_ARTIFACT" + fi apply_retention_policy "$TARGET_DIR" + release_lock } ############################################################################### @@ -758,7 +817,9 @@ configure_cron_job() { command+=("--exclude" "$value") done - cron_line="${minute} ${hour} * * * $(build_shell_command "${command[@]}")" + # '%' is special in crontab entries (it means newline) and must be escaped + cron_line="$(build_shell_command "${command[@]}")" + cron_line="${minute} ${hour} * * * ${cron_line//%/\\%}" (crontab -l 2>/dev/null || true; printf '%s\n' "$cron_line") | crontab - log_msg INFO "Cron job added: $cron_line" } @@ -922,6 +983,8 @@ parse_args "$@" if [[ "$AUTO_MODE" == true || "$BACKUP_REQUESTED" == true ]]; then load_defaults_if_needed create_backup + # Non-zero exit (e.g. for cron mail) when some sources could not be copied + [[ $FAILED_SOURCES -eq 0 ]] || exit 1 exit 0 fi diff --git a/src/beautify_script.sh b/src/beautify_script.sh index 5b8024a..1520582 100755 --- a/src/beautify_script.sh +++ b/src/beautify_script.sh @@ -2,9 +2,13 @@ # Script Name: beautify_script.sh # Description: Formats shell scripts using Beautysh and analyzes them with ShellCheck. -# Usage: ./beautify_script.sh [--check] +# Directories are searched recursively for *.sh files (.git is skipped). +# The original file mode is preserved when formatting in place. +# Usage: ./beautify_script.sh [--check] [path ...] # Options: # --check Only check if formatting is needed, do not modify files. +# Exits 1 if any file needs formatting, cannot be parsed by +# Beautysh (e.g. indent/outdent mismatch) or fails ShellCheck. # Directory or file to process. # Example: ./beautify_script.sh ./my_directory @@ -21,15 +25,47 @@ CYAN="$(tput setaf 6 2>/dev/null || true)" RESET="$(tput sgr0 2>/dev/null || true)" status=0 +tmp_file="" + +cleanup() { + [[ -n "$tmp_file" ]] && rm -f "$tmp_file" + return 0 +} +trap cleanup EXIT log() { - local color="$1"; shift + local color="$1" + shift echo -e "${color}$*${RESET}" } +err() { + log "$RED" "$@" >&2 +} + +usage() { + echo "Usage: beautify_script.sh [--check] [path ...]" >&2 +} + check_tools() { - command -v beautysh >/dev/null 2>&1 || { log "$RED" "[ERROR] beautysh is not installed."; exit 1; } - command -v shellcheck >/dev/null 2>&1 || { log "$RED" "[ERROR] shellcheck is not installed."; exit 1; } + local tool + for tool in beautysh shellcheck; do + if ! command -v "$tool" >/dev/null 2>&1; then + err "[ERROR] $tool is not installed." + exit 1 + fi + done +} + +run_shellcheck() { + local file="$1" + local checkonly="$2" + if ! shellcheck --exclude=SC1091,SC2001 "$file"; then + err "[SHELLCHECK FAIL] $file" + status=1 + elif [[ $checkonly -eq 0 ]]; then + log "$GREEN" "[SHELLCHECK PASS] $file" + fi } format_file() { @@ -37,69 +73,70 @@ format_file() { local checkonly="$2" log "$CYAN" "Processing: $file" - if [[ $checkonly -eq 1 ]]; then - # Check formatting - if ! diff -q <(beautysh - < "$file") "$file" >/dev/null; then + # Always format via stdin/stdout: beautysh reports parse problems + # (e.g. "indent/outdent mismatch") with a non-zero exit code, and writing + # the result back with 'cat >' keeps the original file's mode and inode. + if ! beautysh - < "$file" > "$tmp_file"; then + err "[FORMAT ERROR] $file (beautysh could not process it)" + status=1 + elif ! cmp -s "$tmp_file" "$file"; then + if [[ $checkonly -eq 1 ]]; then log "$YELLOW" "[NEEDS FORMAT] $file" status=1 - fi - # ShellCheck analysis - if ! shellcheck --exclude=SC1091,SC2001 "$file"; then - log "$RED" "[SHELLCHECK FAIL] $file" - status=1 - fi - else - # Format in place - if ! beautysh "$file"; then - log "$RED" "[FORMAT ERROR] $file" - status=1 - else + elif cat "$tmp_file" > "$file"; then log "$GREEN" "[FORMATTED] $file" - fi - # ShellCheck analysis - if ! shellcheck --exclude=SC1091,SC2001 "$file"; then - log "$RED" "[SHELLCHECK FAIL] $file" - status=1 else - log "$GREEN" "[SHELLCHECK PASS] $file" + err "[FORMAT ERROR] $file (could not write file)" + status=1 fi + elif [[ $checkonly -eq 0 ]]; then + log "$GREEN" "[ALREADY FORMATTED] $file" fi + + run_shellcheck "$file" "$checkonly" } process_path() { local path="$1" local checkonly="$2" - if [ -d "$path" ]; then + local file + if [[ -d "$path" ]]; then while IFS= read -r -d '' file; do format_file "$file" "$checkonly" - done < <(find "$path" -type f -name '*.sh' -print0) - elif [ -f "$path" ]; then + done < <(find "$path" -name .git -prune -o -type f -name '*.sh' -print0) + elif [[ -f "$path" ]]; then format_file "$path" "$checkonly" else - log "$RED" "[ERROR] '$path' is not a valid file or directory." + err "[ERROR] '$path' is not a valid file or directory." exit 1 fi } main() { - check_tools local checkonly=0 - if [[ $# -eq 0 ]]; then - log "$RED" "[ERROR] No path provided." - echo "Usage: beautify_script.sh [--check] " - exit 1 - fi - if [[ $1 == "--check" ]]; then + if [[ "${1:-}" == "--check" ]]; then checkonly=1 shift fi - process_path "$1" "$checkonly" - if [[ $status -eq 1 ]]; then - log "$RED" "\nSome files require formatting or have ShellCheck issues." + if [[ $# -eq 0 ]]; then + err "[ERROR] No path provided." + usage + exit 1 + fi + + check_tools + tmp_file="$(mktemp)" + + local path + for path in "$@"; do + process_path "$path" "$checkonly" + done + + if [[ $status -ne 0 ]]; then + err "\nSome files require formatting or have Beautysh/ShellCheck issues." exit 1 - else - log "$GREEN" "\nAll files are properly formatted and pass ShellCheck." fi + log "$GREEN" "\nAll files are properly formatted and pass ShellCheck." } main "$@" diff --git a/src/change_commit_date.sh b/src/change_commit_date.sh index ac54d5b..8c10e0c 100755 --- a/src/change_commit_date.sh +++ b/src/change_commit_date.sh @@ -6,12 +6,12 @@ # Usage: ./change_commit_date.sh [options] # Options: # amend-latest --date DD-MM-YYYY [--time HH:MM] [--tz +HHMM] -# shift --hours N [--days N] [--tz +HHMM] -# move --window day|night [--tz +HHMM] +# shift [--hours N] [--days N] [--tz +HHMM] +# move --to day|night [--day-window HH-HH] [--night-window HH-HH,HH-HH] [--tz +HHMM] # Examples: # ./change_commit_date.sh amend-latest --date 25-12-2022 # ./change_commit_date.sh shift --hours 7 -# ./change_commit_date.sh move --window night +# ./change_commit_date.sh move --to night set -euo pipefail @@ -29,13 +29,13 @@ if ! "$DATE_BIN" -d "@0" +%s >/dev/null 2>&1; then fi require_git_repo() { - git rev-parse --git-dir >/dev/null 2>&1 || { echo "Not a git repo."; exit 1; } + git rev-parse --git-dir >/dev/null 2>&1 || { echo "Not a git repo." >&2; exit 1; } } # Parse "+HHMM" or "-HHMM" into seconds (supports half-hours like +0530) tz_to_seconds() { local tz="$1" sign hh mm secs - [[ "$tz" =~ ^[+-][0-9]{4}$ ]] || { echo "Invalid tz offset '$tz' (use +HHMM/-HHMM)"; exit 1; } + [[ "$tz" =~ ^[+-][0-9]{4}$ ]] || { echo "Invalid tz offset '$tz' (use +HHMM/-HHMM)" >&2; exit 1; } sign="${tz:0:1}" hh=$((10#${tz:1:2})) mm=$((10#${tz:3:2})) @@ -50,38 +50,12 @@ rand_int() { echo $(( min + RANDOM % (max - min + 1) )) } -# Build a space-separated list of allowed hours from a window spec like "09-18" or "20-23,00-05" -expand_windows_to_hours() { - local spec="$1" part start end h hours=() - IFS=',' read -ra PARTS <<< "$spec" - for part in "${PARTS[@]}"; do - [[ "$part" =~ ^([0-1][0-9]|2[0-3])\-([0-1][0-9]|2[0-3])$ ]] \ - || { echo "Invalid window '$part' (use HH-HH,HH-HH)"; exit 1; } - start="${part%-*}"; end="${part#*-}" - start=$((10#$start)); end=$((10#$end)) - if (( start <= end )); then - for ((h=start; h<=end; h++)); do hours+=("$h"); done - else - # Wrap around midnight (e.g., 20-05) - for ((h=start; h<=23; h++)); do hours+=("$h"); done - for ((h=0; h<=end; h++)); do hours+=("$h"); done - fi - done - echo "${hours[*]}" -} - -# Turn Y-m-d H:M:S in the *local tz* into epoch seconds: -ymd_hms_to_epoch_in_tz() { - local y="$1" m="$2" d="$3" H="$4" M="$5" S="$6" tz="$7" - "$DATE_BIN" -d "${y}-${m}-${d} ${H}:${M}:${S} ${tz}" +%s -} - # ---------- Modes ---------- print_help() { cat <<'EOF' Usage: - commit_date_tools.sh [options] + change_commit_date.sh [options] Modes: amend-latest Set the latest commit to a specific date/time. @@ -113,7 +87,8 @@ EOF # ---------- Argument parsing ---------- MODE="${1:-}" -[[ -z "${MODE}" ]] && { print_help; exit 1; } +[[ -z "${MODE}" ]] && { print_help >&2; exit 1; } +[[ "$MODE" == "-h" || "$MODE" == "--help" ]] && { print_help; exit 0; } shift || true TZ_OFFSET="+0200" @@ -136,7 +111,7 @@ while (( "$#" )); do --day-window) DAY_WINDOW="${2:?}"; shift 2 ;; --night-window) NIGHT_WINDOW="${2:?}"; shift 2 ;; -h|--help) print_help; exit 0 ;; - *) echo "Unknown option: $1"; echo; print_help; exit 1 ;; + *) echo "Unknown option: $1" >&2; echo >&2; print_help >&2; exit 1 ;; esac done @@ -149,7 +124,7 @@ tz_to_seconds "$TZ_OFFSET" >/dev/null || exit 1 amend_latest() { [[ "$DATE_DDMMYYYY" =~ ^([0-2][0-9]|3[0-1])-([0][1-9]|1[0-2])-[0-9]{4}$ ]] \ - || { echo "Invalid --date. Use DD-MM-YYYY"; exit 1; } + || { echo "Invalid --date. Use DD-MM-YYYY" >&2; exit 1; } IFS='-' read -r DD MM YYYY <<< "$DATE_DDMMYYYY" if [[ -z "$TIME_HHMM" ]]; then @@ -158,7 +133,7 @@ amend_latest() { printf -v TIME_HHMM "%02d:%02d" "$HH" "$MMm" else [[ "$TIME_HHMM" =~ ^([0-1][0-9]|2[0-3]):([0-5][0-9])$ ]] \ - || { echo "Invalid --time. Use HH:MM"; exit 1; } + || { echo "Invalid --time. Use HH:MM" >&2; exit 1; } fi HH="${TIME_HHMM%:*}" @@ -167,30 +142,31 @@ amend_latest() { EPOCH="$("$DATE_BIN" -d "${YYYY}-${MM}-${DD} ${HH}:${MI}:${SS} ${TZ_OFFSET}" +%s)" - GIT_AUTHOR_DATE="${EPOCH} ${TZ_OFFSET}" \ - GIT_COMMITTER_DATE="${EPOCH} ${TZ_OFFSET}" \ - git commit --amend --no-edit + # --date sets the author date (the env var alone is ignored by --amend) + GIT_COMMITTER_DATE="${EPOCH} ${TZ_OFFSET}" \ + git commit --amend --no-edit --date="${EPOCH} ${TZ_OFFSET}" echo "✓ Amended latest commit date to ${YYYY}-${MM}-${DD} ${HH}:${MI}:${SS} ${TZ_OFFSET}" } shift_history() { + local int_re='^-?(0|[1-9][0-9]*)$' + [[ "$SHIFT_HOURS" =~ $int_re && "$SHIFT_DAYS" =~ $int_re ]] \ + || { echo "--hours and --days must be integers" >&2; exit 1; } local shift_secs=$(( SHIFT_DAYS*86400 + SHIFT_HOURS*3600 )) echo "Shifting ALL commits by ${SHIFT_DAYS} day(s) and ${SHIFT_HOURS} hour(s) [${shift_secs}s]; tz=${TZ_OFFSET}" if [[ "$shift_secs" -eq 0 ]]; then echo "Nothing to do (shift is zero)."; exit 0 fi - git filter-branch -f --tag-name-filter cat --env-filter " - shift_secs=${shift_secs} - tz='${TZ_OFFSET}' - to_epoch() { $DATE_BIN -d \"\$1\" +%s; } - - a_ep=\$(to_epoch \"\$GIT_AUTHOR_DATE\"); c_ep=\$(to_epoch \"\$GIT_COMMITTER_DATE\") - a_new=\$((a_ep + shift_secs)); c_new=\$((c_ep + shift_secs)) - export GIT_AUTHOR_DATE=\"\$a_new \$tz\" - export GIT_COMMITTER_DATE=\"\$c_new \$tz\" - " -- --branches --tags >/dev/null + # Inside the env-filter, git provides the dates as "@ ". + SHIFT_SECS_APPLY="$shift_secs" TZ_OFFSET_APPLY="$TZ_OFFSET" \ + git filter-branch -f --tag-name-filter cat --env-filter ' + a_ep=${GIT_AUTHOR_DATE#@}; a_ep=${a_ep%% *} + c_ep=${GIT_COMMITTER_DATE#@}; c_ep=${c_ep%% *} + export GIT_AUTHOR_DATE="$((a_ep + SHIFT_SECS_APPLY)) $TZ_OFFSET_APPLY" + export GIT_COMMITTER_DATE="$((c_ep + SHIFT_SECS_APPLY)) $TZ_OFFSET_APPLY" + ' -- --branches --tags >/dev/null echo "✓ Done. Remember to: git push --force-with-lease" } @@ -201,7 +177,7 @@ parse_windows_to_intervals() { IFS=',' read -ra PARTS <<< "$spec" for part in "${PARTS[@]}"; do [[ "$part" =~ ^([0-1][0-9]|2[0-3])\-([0-1][0-9]|2[0-3])$ ]] \ - || { echo "Invalid window segment '$part' (use HH-HH)"; exit 1; } + || { echo "Invalid window segment '$part' (use HH-HH)" >&2; exit 1; } local s="${BASH_REMATCH[1]}" e="${BASH_REMATCH[2]}" local ssec=$((10#$s * 3600)) local esec=$(( (10#$e + 1) * 3600 )) # end is exclusive @@ -244,7 +220,7 @@ parse_windows_to_intervals() { for ((i=0;i<${#WIN_STARTS[@]};i++)); do ALLOWED_LEN=$((ALLOWED_LEN + WIN_ENDS[i] - WIN_STARTS[i])) done - (( ALLOWED_LEN > 0 )) || { echo "Window has zero length"; exit 1; } + (( ALLOWED_LEN > 0 )) || { echo "Window has zero length" >&2; exit 1; } } # Map a position p in [0, ALLOWED_LEN-1] to absolute seconds since local midnight @@ -288,13 +264,12 @@ move_history() { local day day="$($DATE_BIN -u -d "@$local_ep" +%Y-%m-%d)" DAY_COUNT["$day"]=$(( ${DAY_COUNT["$day"]:-0} + 1 )) - done < <(git log --all --reverse --pretty=format:'%H %ct') + done < <(git log --all --reverse --pretty=tformat:'%H %ct') # 2) Second pass: assign evenly spaced times inside window per day, preserving order - local STATE + # STATE is global so the EXIT trap can still see it after this function returns STATE="$(mktemp -d)" - # Clean up when this function returns; safe with set -u - trap '[[ -n "${STATE:-}" ]] && rm -rf "$STATE"' RETURN + trap 'rm -rf "$STATE"' EXIT mkdir -p "$STATE/map" declare -A DAY_INDEX=() @@ -322,7 +297,7 @@ move_history() { printf '%s' "$new_epoch" > "$STATE/map/$sha" DAY_INDEX["$day"]=$(( idx + 1 )) - done < <(git log --all --reverse --pretty=format:'%H %ct') + done < <(git log --all --reverse --pretty=tformat:'%H %ct') # 3) Apply mapping in one pass MAPPING_DIR="$STATE/map" TZ_OFFSET_APPLY="$TZ_OFFSET" \ @@ -342,19 +317,19 @@ move_history() { case "$MODE" in amend-latest) - [[ -n "$DATE_DDMMYYYY" ]] || { echo "amend-latest requires --date DD-MM-YYYY"; exit 1; } + [[ -n "$DATE_DDMMYYYY" ]] || { echo "amend-latest requires --date DD-MM-YYYY" >&2; exit 1; } amend_latest ;; shift) shift_history ;; move) - [[ "$MOVE_TO" =~ ^(day|night)$ ]] || { echo "move requires --to day|night"; exit 1; } + [[ "$MOVE_TO" =~ ^(day|night)$ ]] || { echo "move requires --to day|night" >&2; exit 1; } move_history "$MOVE_TO" ;; *) - echo "Unknown mode: $MODE" - print_help + echo "Unknown mode: $MODE" >&2 + print_help >&2 exit 1 ;; esac diff --git a/src/check_apt_avail.sh b/src/check_apt_avail.sh index d596ca2..6dc5c48 100755 --- a/src/check_apt_avail.sh +++ b/src/check_apt_avail.sh @@ -7,13 +7,13 @@ check_apt_availability() { if ! command -v apt >/dev/null 2>&1; then - echo "The apt command is not accessible on this system." + echo "The apt command is not accessible on this system." >&2 exit 1 fi } print_usage() { - echo "Usage: check_apt_avail.sh" + echo "Usage: check_apt_avail.sh" >&2 } main() { @@ -23,6 +23,7 @@ main() { fi check_apt_availability + echo "The apt command is available." } main "$@" diff --git a/src/check_if_root.sh b/src/check_if_root.sh index f77a23e..b7a075d 100755 --- a/src/check_if_root.sh +++ b/src/check_if_root.sh @@ -2,8 +2,9 @@ # Script Name: check_if_root.sh # Description: This script verifies if it is being run as root and provides a relevant message if not. Optional logging is included. +# Exits 0 when running as root, 1 otherwise. # Usage: ./check_if_root.sh [--log | -l] -# Use --log or -l to enable logging. +# Use --log or -l to enable logging to /var/log/check_if_root.log. LOG_FILE="/var/log/check_if_root.log" LOG_ENABLED=0 @@ -12,27 +13,33 @@ for arg in "$@"; do case $arg in --log|-l) LOG_ENABLED=1 - shift # Remove --log or -l from processing ;; *) - # Unknown option + echo "Unknown option: $arg" >&2 + echo "Usage: $0 [--log | -l]" >&2 + exit 2 ;; esac done log_message() { if [ "${LOG_ENABLED}" -eq 1 ]; then - echo "$(date +"%Y-%m-%d %T"): $1" >> "${LOG_FILE}" + # Non-root users usually can't write to /var/log; don't let that abort the check. + if ! echo "$(date +"%Y-%m-%d %T"): $1" 2>/dev/null >> "${LOG_FILE}"; then + echo "Warning: cannot write to ${LOG_FILE}; logging disabled." >&2 + LOG_ENABLED=0 + fi fi } check_root() { log_message "Starting root check." - USER_ID="$(id -u)" - if [ "${USER_ID}" -ne 0 ]; then - echo "Error: This script must be run as root." - echo "You may try using: sudo bash $0" - log_message "Script attempted without root privileges by user ID ${USER_ID}." + local user_id + user_id="$(id -u)" + if [ "${user_id}" -ne 0 ]; then + echo "Error: This script must be run as root." >&2 + echo "You may try using: sudo bash $0" >&2 + log_message "Script attempted without root privileges by user ID ${user_id}." return 1 else echo "Script is running with root privileges." @@ -42,11 +49,10 @@ check_root() { } main() { - if check_root; then - # Additional code for root actions can go here - : - fi - local exit_status=$? + local exit_status=0 + # Additional code for root actions can go after a successful check. + check_root || exit_status=$? + if [ ${exit_status} -ne 0 ]; then log_message "Script exited with error." else diff --git a/src/check_os.sh b/src/check_os.sh index f98e26f..e745f33 100755 --- a/src/check_os.sh +++ b/src/check_os.sh @@ -3,13 +3,19 @@ # Script Name: check_os.sh # Description: Identifies the operating system of the current host and outputs the result. # Usage: ./check_os.sh [--json] [--log] +# --json Print the result as a JSON object. +# --log Also append the result to /var/log/check_os.log (if writable). LOG_FILE="/var/log/check_os.log" JSON_OUTPUT=0 LOG_ENABLED=0 log_action() { - [ $LOG_ENABLED -eq 1 ] && echo "$(date +"%Y-%m-%d %T"): $1" >> $LOG_FILE + if [ "$LOG_ENABLED" -eq 1 ]; then + if ! { echo "$(date +"%Y-%m-%d %T"): $1" >> "$LOG_FILE"; } 2>/dev/null; then + echo "Warning: cannot write to $LOG_FILE" >&2 + fi + fi } output() { @@ -44,7 +50,8 @@ check_os() { fi output "GNU/Linux" "$distro" "$version" ;; - MINGW32_NT* | MINGW64_NT*) + MINGW32_NT* | MINGW64_NT* | MSYS_NT* | CYGWIN_NT*) + local arch arch=$(uname -m) if [ "$arch" == "x86_64" ]; then output "Windows NT" "" "64-bit" @@ -67,7 +74,8 @@ while [ $# -gt 0 ]; do LOG_ENABLED=1 ;; *) - echo "Invalid option: $1" + echo "Invalid option: $1" >&2 + echo "Usage: $0 [--json] [--log]" >&2 exit 1 ;; esac diff --git a/src/christmas_tree.sh b/src/christmas_tree.sh index f4e8ac9..10dee44 100755 --- a/src/christmas_tree.sh +++ b/src/christmas_tree.sh @@ -10,9 +10,10 @@ draw_level() { local level_size=$1 local character=$2 + local offset=$3 # extra indentation so every level is centred on the trunk for ((i = 0; i < level_size; i++)); do - printf "%*s" $((level_size - i)) "" # print spaces + printf "%*s" $((offset + level_size - i)) "" # print spaces printf '%*s\n' $((2 * i + 1)) "" | tr ' ' "$character" # print characters done } @@ -29,20 +30,25 @@ draw_christmas_tree() { local character=$2 for ((i = 1; i <= tree_height; i++)); do - draw_level "$i" "$character" + draw_level "$i" "$character" $((tree_height - i)) done draw_trunk "$tree_height" "$character" # print the trunk } main() { if [ $# -ne 2 ]; then - echo "Must provide exactly two arguments: tree height and character to draw the tree!" + echo "Must provide exactly two arguments: tree height and character to draw the tree!" >&2 exit 1 fi - re='^[0-9]+$' + re='^[1-9][0-9]*$' if ! [[ $1 =~ $re ]]; then - echo "$1 is not a positive integer!" + echo "$1 is not a positive integer!" >&2 + exit 1 + fi + + if [ ${#2} -ne 1 ]; then + echo "The tree must be drawn with exactly one character!" >&2 exit 1 fi diff --git a/src/clear_cache.sh b/src/clear_cache.sh index 3aa6382..bcdd7b2 100755 --- a/src/clear_cache.sh +++ b/src/clear_cache.sh @@ -5,7 +5,7 @@ # Usage: clear_cache.sh [options] # # Options: -# -d, --directory DIR Specify cache directory (default: ~/.cache). Can be specified multiple times. +# -d, --directory DIR Specify cache directory (default: ~/.cache of the current user). Can be specified multiple times. # -a, --age DAYS Delete files older than DAYS days (default: 0, meaning all files). # -l, --log-file FILE Enable logging to specified log file. # -v, --verbose Enable verbose output. @@ -30,7 +30,7 @@ VERBOSE=false FORCE=false SIMULATE=false AGE=0 -CACHE_DIRS=("$HOME/.cache") +CACHE_DIRS=() USERS=() ALL_USERS=false SYSTEM_CACHE=false @@ -40,7 +40,7 @@ print_usage() { echo "Usage: $0 [options]" echo echo "Options:" - echo " -d, --directory DIR Specify cache directory (default: ~/.cache). Can be specified multiple times." + echo " -d, --directory DIR Specify cache directory (default: ~/.cache of the current user). Can be specified multiple times." echo " -a, --age DAYS Delete files older than DAYS days (default: 0, meaning all files)." echo " -l, --log-file FILE Enable logging to specified log file." echo " -v, --verbose Enable verbose output." @@ -73,24 +73,37 @@ confirm_deletion() { if [[ "$FORCE" == true ]]; then return 0 fi - read -r -p "Are you sure you want to delete these files? [y/N] " -n 1 -r + local reply="" + echo "Files will be deleted from:" + printf ' %s\n' "$@" + read -r -p "Are you sure you want to delete these files? [y/N] " -n 1 reply || true echo - [[ "$REPLY" =~ ^[Yy]$ ]] + [[ "$reply" =~ ^[Yy]$ ]] } # Function to get cache directories for a user get_cache_dirs_for_user() { local user="$1" local home_dir - home_dir=$(eval echo "~$user") - local cache_dirs=("$home_dir/.cache") - echo "${cache_dirs[@]}" + home_dir=$(getent passwd "$user" | cut -d: -f6) + if [[ -z "$home_dir" ]]; then + echo "Warning: user '$user' not found, skipping." >&2 + return 0 + fi + echo "$home_dir/.cache" } # Function to clear cache directories clear_cache() { local paths=("$@") local total_deleted=0 + # With an age of 0 every file is deleted; otherwise only files modified + # more than AGE days ago (-mmin avoids -mtime's whole-day rounding). + local age_filter=() + if [[ "$AGE" -gt 0 ]]; then + age_filter=(-mmin +"$((AGE * 1440))") + fi + for path in "${paths[@]}"; do if [[ ! -d "$path" ]]; then log_action "Cache directory '$path' does not exist." @@ -104,11 +117,13 @@ clear_cache() { if [[ "$SIMULATE" == true ]]; then log_action "Simulating clearing cache at '$path'." - find "$path" -depth -type f -mtime +"$AGE" + find "$path" -type f "${age_filter[@]}" || true else log_action "Clearing cache at '$path'." local deleted_files - deleted_files=$(find "$path" -depth -type f -mtime +"$AGE" -print -delete | wc -l) + if ! deleted_files=$(find "$path" -type f "${age_filter[@]}" -print -delete | wc -l); then + echo "Warning: some files in '$path' could not be deleted." >&2 + fi total_deleted=$((total_deleted + deleted_files)) log_action "Deleted $deleted_files items from '$path'." fi @@ -125,30 +140,30 @@ clear_cache() { while [[ $# -gt 0 ]]; do case "$1" in -d|--directory) - if [[ -n "$2" ]]; then + if [[ -n "${2:-}" ]]; then CACHE_DIRS+=("$2") shift 2 else - echo "Error: '--directory' requires a non-empty argument." + echo "Error: '--directory' requires a non-empty argument." >&2 exit 1 fi ;; -a|--age) - if [[ -n "$2" ]]; then + if [[ -n "${2:-}" ]]; then AGE="$2" shift 2 else - echo "Error: '--age' requires a non-empty argument." + echo "Error: '--age' requires a non-empty argument." >&2 exit 1 fi ;; -l|--log-file) - if [[ -n "$2" ]]; then + if [[ -n "${2:-}" ]]; then LOG_FILE="$2" LOG_ENABLED=true shift 2 else - echo "Error: '--log-file' requires a non-empty argument." + echo "Error: '--log-file' requires a non-empty argument." >&2 exit 1 fi ;; @@ -165,11 +180,11 @@ while [[ $# -gt 0 ]]; do shift ;; -u|--user) - if [[ -n "$2" ]]; then + if [[ -n "${2:-}" ]]; then USERS+=("$2") shift 2 else - echo "Error: '--user' requires a non-empty argument." + echo "Error: '--user' requires a non-empty argument." >&2 exit 1 fi ;; @@ -190,58 +205,70 @@ while [[ $# -gt 0 ]]; do break ;; -*) - echo "Unknown option: $1" - print_usage + echo "Unknown option: $1" >&2 + print_usage >&2 exit 1 ;; *) - # No more options, break - break + echo "Unexpected argument: $1" >&2 + print_usage >&2 + exit 1 ;; esac done +if [[ $# -gt 0 ]]; then + echo "Unexpected argument: $1" >&2 + exit 1 +fi + +if ! [[ "$AGE" =~ ^[0-9]+$ ]]; then + echo "Error: '--age' must be a non-negative integer." >&2 + exit 1 +fi + # Verify root privileges if necessary if [[ "$ALL_USERS" == true ]] || [[ "${#USERS[@]}" -gt 0 ]]; then if [[ "$EUID" -ne 0 ]]; then - echo "This option requires root privileges. Please run as root." + echo "This option requires root privileges. Please run as root." >&2 exit 1 fi fi -# Build list of cache directories -if [[ "${#CACHE_DIRS[@]}" -eq 0 ]]; then - if [[ "$ALL_USERS" == true ]]; then - # Get all users - mapfile -t USERS < <(awk -F: '{ if ($3 >= 1000 && $3 != 65534) print $1}' /etc/passwd) - elif [[ "${#USERS[@]}" -eq 0 ]]; then - # Default to current user - USERS+=("$USER") - fi - - for user in "${USERS[@]}"; do - mapfile -t user_cache_dirs < <(get_cache_dirs_for_user "$user") - CACHE_DIRS+=("${user_cache_dirs[@]}") - done +# Build list of cache directories: explicit directories plus the caches of +# the selected users (the current user when neither -d nor -u/-A is given) +if [[ "$ALL_USERS" == true ]]; then + # Get all users + mapfile -t USERS < <(awk -F: '{ if ($3 >= 1000 && $3 != 65534) print $1}' /etc/passwd) +elif [[ "${#USERS[@]}" -eq 0 && "${#CACHE_DIRS[@]}" -eq 0 ]]; then + # Default to current user + USERS+=("$(id -un)") fi +for user in "${USERS[@]}"; do + user_cache_dir=$(get_cache_dirs_for_user "$user") + if [[ -n "$user_cache_dir" ]]; then + CACHE_DIRS+=("$user_cache_dir") + fi +done + if [[ "$SYSTEM_CACHE" == true ]]; then if [[ "$(uname)" == "Linux" ]]; then CACHE_DIRS+=("/var/cache") elif [[ "$(uname)" == "Darwin" ]]; then CACHE_DIRS+=("/Library/Caches") else - echo "Unsupported system. Cannot determine system cache directories." + echo "Unsupported system. Cannot determine system cache directories." >&2 fi fi if [[ "${#CACHE_DIRS[@]}" -eq 0 ]]; then - echo "No cache directories specified and none found for users." + echo "No cache directories specified and none found for users." >&2 exit 1 fi -# Confirm deletion -if ! confirm_deletion; then +# Confirm deletion (a simulation deletes nothing, so it needs no confirmation) +if [[ "$SIMULATE" != true ]] && ! confirm_deletion "${CACHE_DIRS[@]}"; then echo "Cache clearing cancelled." log_action "Cache clearing cancelled by user." exit 0 diff --git a/src/contributions_by_git_author.sh b/src/contributions_by_git_author.sh index 4d7b8a2..877510f 100755 --- a/src/contributions_by_git_author.sh +++ b/src/contributions_by_git_author.sh @@ -1,58 +1,60 @@ #!/usr/bin/env bash # Script Name: contributions_by_git_author.sh -# Description: This script counts the number of commits by each author in a Git repository. +# Description: This script counts the number of commits by each author in a Git repository +# (run it from inside the repository). With a username, it counts only the +# commits whose author name matches it exactly. +# Actions are logged to /var/log/contributions_by_git_author.log when writable. # Usage: ./contributions_by_git_author.sh [username] # Example: ./contributions_by_git_author.sh -# Example: ./contributions_by_git_author.sh JohnDoe +# Example: ./contributions_by_git_author.sh "John Doe" LOG_FILE="/var/log/contributions_by_git_author.log" -LOG_ENABLED=1 log_action() { - [ $LOG_ENABLED -eq 1 ] && echo "$(date +"%Y-%m-%d %T"): $1" >> $LOG_FILE + if [ -w "$LOG_FILE" ] || [ -w "$(dirname "$LOG_FILE")" ]; then + echo "$(date +"%Y-%m-%d %T"): $1" >> "$LOG_FILE" + fi } # Function to process git log process_git_log() { - echo "Processing git log..." log_action "Processing git log for all authors." git log --pretty="%an" | sort | uniq -c | sort -nr | - awk '{print $2,$3": "$1" commits"}' + awk '{count = $1; sub(/^ *[0-9]+ /, ""); print $0 ": " count " commits"}' } # Function to process git log for a specific user process_git_log_for_user() { - echo "Processing git log for user $1..." + local count log_action "Processing git log for user $1." - git log --pretty="%an" | - grep -c "$1" | - awk -v user="$1" '{print user": "$1" commits"}' + count=$(git log --pretty="%an" | grep -Fxc -- "$1") + echo "$1: $count commits" } main() { - if [[ $# -eq 0 ]] ; then + if [[ $# -gt 1 ]]; then + echo "Usage: $0 [username]" >&2 + exit 1 + fi + + if ! git rev-parse --git-dir > /dev/null 2>&1; then + echo "Error: not inside a git repository." >&2 + exit 1 + fi + + if [[ $# -eq 0 ]]; then # No username provided, get commit counts for all authors - echo "Getting commit counts per author..." - log_action "No user specified. Retrieving commit counts for all authors." - git_log_output=$(process_git_log) - echo "Commit counts per author retrieved." - log_action "Commit counts for all authors retrieved." + echo "Commit counts per author:" + process_git_log else # Username provided, get commit count for that user - echo "Getting commit count for user $1..." - log_action "User specified: $1. Retrieving commit count for user." - git_log_output=$(process_git_log_for_user "$1") - echo "Commit count for user $1 retrieved." - log_action "Commit count for user $1 retrieved." + echo "Commit count for user $1:" + process_git_log_for_user "$1" fi - - # Print the final output - echo "Final output:" - echo "$git_log_output" log_action "Final output displayed." } diff --git a/src/convert_to_gif.sh b/src/convert_to_gif.sh index 09713f9..8eb53bb 100755 --- a/src/convert_to_gif.sh +++ b/src/convert_to_gif.sh @@ -1,40 +1,42 @@ #!/usr/bin/env bash # Script Name: convert_to_gif.sh -# Description: Converts a given video file to a GIF. +# Description: Converts a given video file to a GIF, written to the current directory +# as .gif. Frames larger than 200 megapixels are scaled down. +# Actions are logged to /var/log/convert_to_gif.log when it is writable. # Usage: ./convert_to_gif.sh # Example: ./convert_to_gif.sh video.mp4 LOG_FILE="/var/log/convert_to_gif.log" LOG_ENABLED=1 -# Function to log actions +# Function to log actions (silently skipped if the log file isn't writable) log_action() { - [ $LOG_ENABLED -eq 1 ] && echo "$(date +"%Y-%m-%d %T"): $1" >> $LOG_FILE + if [ "$LOG_ENABLED" -eq 1 ]; then + { echo "$(date +"%Y-%m-%d %T"): $1" >> "$LOG_FILE"; } 2>/dev/null || true + fi } -# Function to check if a command exists -command_exists() { - type "$1" &> /dev/null +# Print an error to stderr and log it +error() { + echo "Error: $1" >&2 + log_action "Error: $1" } -# Check for required dependencies -if ! command_exists ffmpeg; then - echo "Error: ffmpeg is not installed. Please install it and try again." - log_action "Error: ffmpeg is not installed." - exit 1 -fi - # Usage function usage() { - echo "Usage: $0 " - log_action "Displayed usage information." + echo "Usage: $0 " >&2 } +# Check for required dependencies +if ! command -v ffmpeg &> /dev/null; then + error "ffmpeg is not installed. Please install it and try again." + exit 1 +fi + # Check if an argument was provided if [ "$#" -ne 1 ]; then usage - log_action "Error: No file path provided." exit 1 fi @@ -42,20 +44,19 @@ FILE_PATH=$1 # Check if the file exists if [ ! -f "$FILE_PATH" ]; then - echo "Error: File does not exist." - log_action "Error: File does not exist at path $FILE_PATH." + error "File does not exist: $FILE_PATH" exit 1 fi # Get the file name without extension -BASENAME=$(basename "$FILE_PATH") +BASENAME=$(basename -- "$FILE_PATH") FILENAME="${BASENAME%.*}" # Set the output file name OUTPUT_FILE="${FILENAME}.gif" -# Maximum dimension for 200 megapixels -MAX_DIMENSION=$(echo "sqrt(200*1000000)" | bc) +# Maximum dimension for 200 megapixels: floor(sqrt(200 * 1000000)) +MAX_DIMENSION=14142 # Convert the file with resize if necessary log_action "Starting conversion of $FILE_PATH to $OUTPUT_FILE." @@ -63,8 +64,7 @@ if ffmpeg -i "$FILE_PATH" -vf "scale='min(iw,$MAX_DIMENSION)':'min(ih,$MAX_DIMEN echo "Successfully converted $FILE_PATH to $OUTPUT_FILE" log_action "Successfully converted $FILE_PATH to $OUTPUT_FILE." else - echo "Error: Conversion failed." - log_action "Error: Conversion failed for $FILE_PATH." + error "Conversion failed for $FILE_PATH." exit 1 fi diff --git a/src/convert_to_mp4.sh b/src/convert_to_mp4.sh index b49cf54..a795dca 100755 --- a/src/convert_to_mp4.sh +++ b/src/convert_to_mp4.sh @@ -78,7 +78,9 @@ Converts to MP4 using ffmpeg. - can be an absolute or relative path. - Output file is created in the same directory as - with the same base name and extension ".mp4". + with the same base name and extension ".mp4". If is + already an .mp4 file, the output is named "_converted.mp4" + so the input is never overwritten. Examples: $SCRIPT_NAME video.avi @@ -92,18 +94,21 @@ convert_to_mp4() { local input_path="$1" # Resolve to an absolute path - local input_dir input_base abs_input + local input_dir input_base abs_dir abs_input input_dir=$(dirname "$input_path") input_base=$(basename "$input_path") - abs_input="$(cd "$input_dir" && pwd)/$input_base" + abs_dir="$(cd "$input_dir" && pwd)" + abs_input="$abs_dir/$input_base" # Derive output file path (same directory, .mp4 extension) local filename_no_ext output_file filename_no_ext="${input_base%.*}" - output_file="${input_dir}/${filename_no_ext}.mp4" + output_file="$abs_dir/${filename_no_ext}.mp4" - # Resolve output to absolute, too (cosmetic, for messages) - output_file="$(cd "$input_dir" && pwd)/${filename_no_ext}.mp4" + # Never let ffmpeg -y overwrite the file it is reading from + if [ "$output_file" = "$abs_input" ]; then + output_file="$abs_dir/${filename_no_ext}_converted.mp4" + fi log "Starting conversion: '$abs_input' -> '$output_file'" @@ -131,7 +136,7 @@ main() { fi if [ "$#" -ne 1 ]; then - usage + usage >&2 log "Incorrect number of arguments: expected 1, got $#: $*" exit 1 fi diff --git a/src/correct_file_names.sh b/src/correct_file_names.sh index 78ec2a0..d16b73c 100755 --- a/src/correct_file_names.sh +++ b/src/correct_file_names.sh @@ -1,14 +1,17 @@ #!/usr/bin/env bash # Script Name: correct_file_names.sh -# Description: This script corrects file names in a specified directory by replacing all non-alphanumeric characters -# (excluding dots) with underscores, converting repeated underscores to a single underscore, and making the names lowercase. -# Additionally, it provides an option to include hidden directories. -# Usage: correct_file_names.sh [-a] [-e ] -# -a: Include hidden directories (default is false). -# -e: Comma-separated list of file names to exclude from modification (default is 'README.md'). -# : The directory containing the files to be corrected. -# Example: ./correct_file_names.sh -a -e README.md,path/to/file.txt path/to/directory +# Description: This script recursively corrects file and directory names in a specified directory by replacing +# all non-alphanumeric characters (excluding dots and underscores) with underscores, converting +# repeated underscores to a single underscore, trimming leading/trailing underscores and making +# the names lowercase. If the new name is taken, a numeric suffix (_1, _2, ...) is added. +# Additionally, it provides an option to include hidden files and directories. +# Usage: correct_file_names.sh [-a] [-e ] +# -a: Include hidden files and directories (default is false). +# -e: Comma-separated list of exact file/directory names (not paths) to exclude +# from modification (default is 'README.md'). +# : The directory containing the files to be corrected, or a single file. +# Example: ./correct_file_names.sh -a -e README.md,LICENSE path/to/directory sanitize_basename() { # Transform only a basename (no path separators) according to the rules @@ -16,12 +19,12 @@ sanitize_basename() { # - squeeze consecutive underscores # - lowercase # - trim leading/trailing underscores - local name="$1" - name=$(echo "$name" | sed -e 's/[^a-zA-Z0-9._]/_/g') - name=$(echo "$name" | tr -s '_') - name=$(echo "$name" | tr '[:upper:]' '[:lower:]') - name=$(echo "$name" | sed -e 's/^_//g' -e 's/_$//g') - printf '%s' "$name" + # LC_ALL=C: treat every non-ASCII byte as "non-alphanumeric" + local name + name=$(printf '%s' "$1" | LC_ALL=C sed -e 's/[^a-zA-Z0-9._]/_/g') + name=$(printf '%s' "$name" | LC_ALL=C tr -s '_' | LC_ALL=C tr '[:upper:]' '[:lower:]') + name=${name#_} + printf '%s' "${name%_}" } ensure_unique_target() { @@ -54,9 +57,9 @@ maybe_rename_path() { local base base=$(basename -- "$old_path") - # Exclusion check is against the basename only + # Exclusion check is an exact match against the basename only for excluded in "${excluded_files[@]}"; do - if [[ $base =~ $excluded ]]; then + if [[ $base == "$excluded" ]]; then printf '%s' "$old_path" return 0 fi @@ -71,6 +74,13 @@ maybe_rename_path() { return 0 fi + # Names made only of special characters would end up empty (or just dots) + if [[ -z "${sanitized//./}" ]]; then + echo "Skipping '$old_path': no usable characters in its name." >&2 + printf '%s' "$old_path" + return 0 + fi + # Avoid collisions inside the same directory local target_base target_base=$(ensure_unique_target "$parent" "$sanitized") @@ -94,8 +104,15 @@ find_files() { find_args=("$dir" -mindepth 1 -maxdepth 1 -not -name '.*' -print0) fi - # Iterate immediate children; rename, then recurse into directories using their new path - find "${find_args[@]}" | while IFS= read -r -d $'\0' entry; do + # Collect the immediate children first (so renaming can't disturb the listing), + # then rename each one and recurse into directories using their new path + local -a entries=() + local entry + while IFS= read -r -d '' entry; do + entries+=("$entry") + done < <(find "${find_args[@]}") + + for entry in "${entries[@]}"; do local new_entry new_entry=$(maybe_rename_path "$entry") if [ -d "$new_entry" ]; then @@ -117,6 +134,10 @@ main() { e) IFS=',' read -r -a excluded_files <<< "$OPTARG" ;; + ":") + echo "Option -$OPTARG requires an argument." >&2 + exit 1 + ;; "?") echo "Invalid option: -$OPTARG" >&2 exit 1 @@ -126,7 +147,7 @@ main() { shift $((OPTIND - 1)) if [ $# -eq 0 ]; then - echo "Usage: $0 [-a] [-e ] " + echo "Usage: $0 [-a] [-e ] " >&2 exit 1 fi @@ -139,7 +160,7 @@ main() { # Single file rename maybe_rename_path "$path" >/dev/null else - echo "$path is not a valid path!" + echo "$path is not a valid path!" >&2 exit 1 fi } diff --git a/src/count_char.sh b/src/count_char.sh index f6f5f26..df45aaf 100755 --- a/src/count_char.sh +++ b/src/count_char.sh @@ -13,26 +13,23 @@ count_char() { local string="$1" local character="$2" - # Using awk, FS sets the field separator to the character. - # NF returns the number of fields (i.e., the number of occurrences of the character) - # Subtract 1 because awk splits the string into fields, - # thus creating an extra field. Subtracting 1 gives the number of occurrences of the character. - local count=0 - count=$(awk -F"$character" '{print NF-1}' <<< "$string") - - echo "$count" + # Delete every occurrence of the character (quoting the pattern makes it + # literal, so '*', '.', ' ' etc. work), then compare the lengths. + local stripped="${string//"$character"/}" + + echo $(( ${#string} - ${#stripped} )) } main() { # Check if exactly 2 arguments are provided if [[ $# -ne 2 ]]; then - echo "Usage: count_char.sh [string] [character]" + echo "Usage: count_char.sh [string] [character]" >&2 exit 1 fi # Check if the character argument has exactly one character if [[ ${#2} -ne 1 ]]; then - echo "Please provide a single character as the second argument." + echo "Please provide a single character as the second argument." >&2 exit 1 fi diff --git a/src/count_files.sh b/src/count_files.sh index 1414a94..973819d 100755 --- a/src/count_files.sh +++ b/src/count_files.sh @@ -3,10 +3,11 @@ # Script Name: count_files.sh # Description: Counts the number of directories, files, and total count in a specified directory, # with options to filter by extension and set traversal depth. -# Usage: count_files.sh [--directory ] [--depth ] [--extension ] [--help] +# The starting directory itself is not counted. +# Usage: count_files.sh [-d|--directory ] [-p|--depth ] [-e|--extension ] [-h|--help] # --directory (optional) - the directory to count files in (default: current directory) # --depth (optional) - the maximum depth of directory traversal (default: unlimited) -# --extension (optional) - only count files with the specified extension +# --extension (optional) - only count files with the specified extension (e.g. txt) # --help (optional) - display this help message # Example: # ./count_files.sh @@ -28,7 +29,6 @@ Examples: $0 $0 --directory /path/to/dir --depth 2 --extension txt EOF - exit 0 } count_files() { @@ -37,7 +37,7 @@ count_files() { local extension="$3" if [[ ! -d "$dir" ]]; then - echo "Error: Directory '$dir' does not exist." + echo "Error: Directory '$dir' does not exist." >&2 exit 1 fi @@ -45,16 +45,18 @@ count_files() { [[ -n "$depth" ]] && echo "Depth: $depth" [[ -n "$extension" ]] && echo "Filtering by extension: .$extension" - local find_cmd=(find "$dir") - [[ -n "$depth" ]] && find_cmd+=(-maxdepth "$depth") - [[ -n "$extension" ]] && find_cmd+=(-name "*.$extension") - find_cmd+=(-type f) + local find_opts=(-mindepth 1) + [[ -n "$depth" ]] && find_opts+=(-maxdepth "$depth") + local name_filter=() + [[ -n "$extension" ]] && name_filter=(-name "*.$extension") + + # Print one character per match so file names containing newlines count once. local num_files - num_files=$( "${find_cmd[@]}" | wc -l ) + num_files=$(find "$dir" "${find_opts[@]}" -type f "${name_filter[@]}" -printf '.' | wc -c) local num_dirs - num_dirs=$( find "$dir" ${depth:+-maxdepth "$depth"} -type d | wc -l ) + num_dirs=$(find "$dir" "${find_opts[@]}" -type d -printf '.' | wc -c) local total_count total_count=$((num_dirs + num_files)) @@ -71,7 +73,11 @@ main() { local extension="" # Parse options - options=$(getopt -o d:p:e:h --long directory:,depth:,extension:,help -n "$0" -- "$@") + local options + if ! options=$(getopt -o d:p:e:h --long directory:,depth:,extension:,help -n "$0" -- "$@"); then + show_help >&2 + exit 1 + fi eval set -- "$options" while true; do @@ -90,18 +96,32 @@ main() { ;; -h|--help) show_help + exit 0 ;; --) shift break ;; *) - echo "Invalid option: $1" - show_help + echo "Invalid option: $1" >&2 + show_help >&2 + exit 1 ;; esac done + if [[ $# -gt 0 ]]; then + echo "Unexpected argument: $1" >&2 + show_help >&2 + exit 1 + fi + + if [[ -n "$depth" && ! "$depth" =~ ^[0-9]+$ ]]; then + echo "Error: Depth must be a non-negative integer." >&2 + exit 1 + fi + extension="${extension#.}" + count_files "$dir" "$depth" "$extension" } diff --git a/src/count_lines_of_code.sh b/src/count_lines_of_code.sh index f3d419f..8d8c3ed 100755 --- a/src/count_lines_of_code.sh +++ b/src/count_lines_of_code.sh @@ -1,16 +1,17 @@ #!/usr/bin/env bash # Script Name: count_lines_of_code.sh -# Description: Counts the total number of lines of code in a given git repository. -# Usage: count_lines_of_code.sh repository_path -# repository_path - the path to a git repository; -# if no path is specified, the current working directory is used. +# Description: Counts the total number of lines in the files tracked by a git +# repository (current working tree; binary files are skipped). +# Usage: count_lines_of_code.sh [repository_path] +# repository_path - the path to a git repository (or a directory inside one); +# if no path is specified, the current working directory is used. # Example: ./count_lines_of_code.sh path/to/repository get_repository_path() { local path=${1:-"."} - if [ ! -d "$path" ] || [ ! -d "$path/.git" ]; then - echo "Error: '$path' is not a valid Git repository directory." + if [ ! -d "$path" ] || ! git -C "$path" rev-parse --is-inside-work-tree > /dev/null 2>&1; then + echo "Error: '$path' is not a valid Git repository directory." >&2 exit 1 fi echo "$path" @@ -19,13 +20,22 @@ get_repository_path() { count_lines_of_code() { local repository_path="$1" local lines - lines=$(git -C "$repository_path" ls-files | xargs wc -l | awk '/total/{print $1}') + # git grep -c '' prints "file:count" for every tracked text file (-I skips binaries). + lines=$(git -C "$repository_path" grep -I -c '' -- . | awk -F: '{ total += $NF } END { print total + 0 }') echo "Total lines of code: $lines" } main() { + if [ $# -gt 1 ]; then + echo "Usage: $0 [repository_path]" >&2 + exit 1 + fi + if ! command -v git > /dev/null 2>&1; then + echo "Error: this script requires 'git'." >&2 + exit 1 + fi local repository_path - repository_path="$(get_repository_path "$@")" + repository_path="$(get_repository_path "$@")" || exit 1 count_lines_of_code "$repository_path" } diff --git a/src/cpu_temp.sh b/src/cpu_temp.sh index 0558412..ef1c834 100755 --- a/src/cpu_temp.sh +++ b/src/cpu_temp.sh @@ -10,7 +10,7 @@ # -u, --unit UNIT Specify temperature unit: C (Celsius), F (Fahrenheit), K (Kelvin). Default is C. # -j, --json Output in JSON format. # -l, --log-file FILE Log output to specified file. -# -m, --monitor INTERVAL Monitor CPU temperature at specified interval in seconds. +# -m, --monitor INTERVAL Monitor CPU temperature at specified interval in seconds (0 = single reading). # -o, --output FILE Save output to specified file. # -V, --version Display script version and exit. # @@ -43,7 +43,7 @@ Options: -u, --unit UNIT Specify temperature unit: C (Celsius), F (Fahrenheit), K (Kelvin). Default is C. -j, --json Output in JSON format. -l, --log-file FILE Log output to specified file. - -m, --monitor INTERVAL Monitor CPU temperature at specified interval in seconds. + -m, --monitor INTERVAL Monitor CPU temperature at specified interval in seconds (0 = single reading). -o, --output FILE Save output to specified file. -V, --version Display script version and exit. @@ -59,69 +59,80 @@ print_version() { echo "$0 version $VERSION" } -# Function for logging +# Function for logging (verbose messages go to stderr so they never mix with JSON output) log_action() { local message="$1" if [[ "$LOG_ENABLED" == true ]]; then echo "$(date +"%Y-%m-%d %T"): $message" >> "$LOG_FILE" fi if [[ "$VERBOSE" == true ]]; then - echo "$message" + echo "$message" >&2 fi } -# Function to convert temperature units +# Function to convert a Celsius temperature to the selected unit (UNIT is validated at startup) convert_temp() { local temp_c="$1" - local unit="$2" - local temp_output - case "$unit" in - C|c) - temp_output="$temp_c" - ;; - F|f) - temp_output=$(echo "scale=2; ($temp_c * 9/5) + 32" | bc) - ;; - K|k) - temp_output=$(echo "scale=2; $temp_c + 273.15" | bc) - ;; - *) - echo "Invalid unit: $unit" - exit 1 - ;; + case "$UNIT" in + C) LC_ALL=C awk -v t="$temp_c" 'BEGIN { printf "%.1f\n", t }' ;; + F) LC_ALL=C awk -v t="$temp_c" 'BEGIN { printf "%.1f\n", t * 9 / 5 + 32 }' ;; + K) LC_ALL=C awk -v t="$temp_c" 'BEGIN { printf "%.2f\n", t + 273.15 }' ;; esac +} - echo "$temp_output" +# Print the first readable integer sensor value (millidegrees Celsius) as degrees Celsius +read_sensor() { + local file raw + for file in "$@"; do + [[ -r "$file" ]] || continue + # Some sensors fail on read (e.g. EIO); just try the next one + raw=$(cat "$file" 2>/dev/null) || continue + [[ "$raw" =~ ^-?[0-9]+$ ]] || continue + log_action "Using sensor: $file" + LC_ALL=C awk -v r="$raw" 'BEGIN { print (r > 1000 || r < -1000) ? r / 1000 : r }' + return 0 + done + return 1 } # Function to get the CPU temperature on Linux get_linux_cpu_temp() { - local temp_paths=( - "/sys/class/thermal/thermal_zone*/temp" - "/sys/class/hwmon/hwmon*/temp1_input" - ) + local dir name + local -a cpu_sensors=() other_sensors=() - for path in "${temp_paths[@]}"; do - for file in $path; do - if [[ -r "$file" ]]; then - local temp_raw - temp_raw=$(cat "$file") - if [[ "$temp_raw" -gt 1000 ]]; then - temp_c=$(echo "scale=2; $temp_raw / 1000" | bc) - else - temp_c="$temp_raw" - fi - local temp - temp=$(convert_temp "$temp_c" "$UNIT") - echo "$temp" - return - fi - done + # Prefer sensors known to belong to the CPU over e.g. ACPI, NVMe or GPU sensors + for dir in /sys/class/hwmon/hwmon*; do + [[ -r "$dir/name" ]] || continue + name=$(cat "$dir/name" 2>/dev/null) || continue + case "$name" in + coretemp | k10temp | zenpower | cpu_thermal | cpu-thermal | soc_thermal) + cpu_sensors+=("$dir/temp1_input") + ;; + *) + other_sensors+=("$dir/temp1_input") + ;; + esac + done + for dir in /sys/class/thermal/thermal_zone*; do + [[ -r "$dir/type" ]] || continue + name=$(cat "$dir/type" 2>/dev/null) || continue + case "$name" in + x86_pkg_temp | cpu* | CPU* | soc*) + cpu_sensors+=("$dir/temp") + ;; + *) + other_sensors+=("$dir/temp") + ;; + esac done - echo "Could not find a valid temperature file in common paths." >&2 - exit 1 + local temp_c + if ! temp_c=$(read_sensor "${cpu_sensors[@]}" "${other_sensors[@]}"); then + echo "Could not find a valid temperature file in common paths." >&2 + exit 1 + fi + convert_temp "$temp_c" } # Function to get the CPU temperature on macOS @@ -137,9 +148,7 @@ get_macos_cpu_temp() { echo "Could not retrieve CPU temperature. Ensure you have the necessary permissions." >&2 exit 1 fi - local temp - temp=$(convert_temp "$temp_c" "$UNIT") - echo "$temp" + convert_temp "$temp_c" } # Function to get the CPU temperature on FreeBSD @@ -147,10 +156,8 @@ get_freebsd_cpu_temp() { if sysctl hw.acpi.thermal.tz0.temperature >/dev/null 2>&1; then local temp_str temp_str=$(sysctl hw.acpi.thermal.tz0.temperature | awk '{print $2}') - local temp_c=${temp_str%.*} - local temp - temp=$(convert_temp "$temp_c" "$UNIT") - echo "$temp" + local temp_c=${temp_str%C} + convert_temp "$temp_c" else echo "Could not retrieve CPU temperature on FreeBSD." >&2 exit 1 @@ -195,7 +202,7 @@ while [[ $# -gt 0 ]]; do UNIT="$2" shift 2 else - echo "Error: --unit requires a value." + echo "Error: --unit requires a value." >&2 exit 1 fi ;; @@ -209,7 +216,7 @@ while [[ $# -gt 0 ]]; do LOG_ENABLED=true shift 2 else - echo "Error: --log-file requires a value." + echo "Error: --log-file requires a value." >&2 exit 1 fi ;; @@ -218,7 +225,7 @@ while [[ $# -gt 0 ]]; do MONITOR_INTERVAL="$2" shift 2 else - echo "Error: --monitor requires an interval in seconds." + echo "Error: --monitor requires an interval in seconds." >&2 exit 1 fi ;; @@ -227,7 +234,7 @@ while [[ $# -gt 0 ]]; do OUTPUT_FILE="$2" shift 2 else - echo "Error: --output requires a file path." + echo "Error: --output requires a file path." >&2 exit 1 fi ;; @@ -236,40 +243,48 @@ while [[ $# -gt 0 ]]; do exit 0 ;; *) - echo "Unknown option: $1" - print_usage + echo "Unknown option: $1" >&2 + print_usage >&2 exit 1 ;; esac done +# Validate options +UNIT=$(echo "$UNIT" | tr '[:lower:]' '[:upper:]') +if [[ ! "$UNIT" =~ ^[CFK]$ ]]; then + echo "Error: Invalid unit '$UNIT'. Use C, F or K." >&2 + exit 1 +fi +if [[ ! "$MONITOR_INTERVAL" =~ ^[0-9]+$ ]]; then + echo "Error: --monitor interval must be a non-negative integer (seconds)." >&2 + exit 1 +fi + # Redirect output to file if specified if [[ -n "$OUTPUT_FILE" ]]; then exec > >(tee -a "$OUTPUT_FILE") fi -# Main execution function -main() { - if [[ "$MONITOR_INTERVAL" -gt 0 ]]; then - while true; do - temp=$(get_cpu_temp) - if [[ "$OUTPUT_JSON" == true ]]; then - echo "{\"cpu_temperature\": \"$temp\", \"unit\": \"$UNIT\"}" - else - echo "CPU Temperature: $temp°$UNIT" - fi - log_action "CPU Temperature: $temp°$UNIT" - sleep "$MONITOR_INTERVAL" - done +# Print one temperature reading +report_temp() { + local temp + temp=$(get_cpu_temp) + if [[ "$OUTPUT_JSON" == true ]]; then + echo "{\"cpu_temperature\": \"$temp\", \"unit\": \"$UNIT\"}" else - temp=$(get_cpu_temp) - if [[ "$OUTPUT_JSON" == true ]]; then - echo "{\"cpu_temperature\": \"$temp\", \"unit\": \"$UNIT\"}" - else - echo "CPU Temperature: $temp°$UNIT" - fi - log_action "CPU Temperature: $temp°$UNIT" + echo "CPU Temperature: $temp°$UNIT" fi + log_action "CPU Temperature: $temp°$UNIT" +} + +# Main execution function +main() { + report_temp + while [[ "$MONITOR_INTERVAL" -gt 0 ]]; do + sleep "$MONITOR_INTERVAL" + report_temp + done } main diff --git a/src/cpu_usage.sh b/src/cpu_usage.sh index 24acadd..789c067 100755 --- a/src/cpu_usage.sh +++ b/src/cpu_usage.sh @@ -4,7 +4,7 @@ # Description: Displays the current CPU usage with options for per-core details and process filtering. # Usage: ./cpu_usage.sh [ -p PROCESS_NAME ] [ -u USERNAME ] [ -n NUMBER ] [ -f FORMAT ] [ -i INTERVAL ] [ -c ] [ -h ] # Options: -# -p PROCESS_NAME Display CPU usage for processes matching PROCESS_NAME. +# -p PROCESS_NAME Display CPU usage for processes whose name matches PROCESS_NAME (case-insensitive regex). # -u USERNAME Display top CPU consuming processes for USERNAME. # -n NUMBER Number of processes to display (default is 10). # -f FORMAT Output format: text (default) or json. @@ -12,12 +12,12 @@ # -c Display per-core CPU usage. # -h Show help message. -# Display usage information +# Display usage information and exit with the given status (default 0) usage() { cat </dev/null) + idle=$(top -bn1 | grep -i "Cpu(s)" | sed -n 's/.*[ ,:]\([0-9.][0-9.]*\)[[:space:]]*id,.*/\1/p') if [[ -n "$idle" ]]; then TOTAL_CPU_USAGE=$(awk -v idle="$idle" 'BEGIN {printf "%.2f%%", 100 - idle}') else @@ -105,22 +105,23 @@ get_top_processes() { get_top_processes_for_user() { local user="$1" local number="$2" + # Let ps do the filtering: its USER column truncates long names, so matching on it is unreliable. if [[ "$OS_TYPE" == "Linux" ]]; then - ps -eo user,pid,%cpu,comm --sort=-%cpu | awk -v usr="$user" '$1==usr' | head -n $((number)) + ps -u "$user" -o user,pid,%cpu,comm --sort=-%cpu | head -n $((number + 1)) elif [[ "$OS_TYPE" == "macOS" ]]; then - ps -Ao user,pid,%cpu,comm -r | awk -v usr="$user" '$1==usr' | head -n $((number)) + ps -U "$user" -o user,pid,%cpu,comm -r | head -n $((number + 1)) else echo "Unsupported OS type." fi } -# Get CPU usage for processes matching a specific name +# Get CPU usage for processes matching a specific name (including header) get_process_cpu_usage() { local proc="$1" if [[ "$OS_TYPE" == "Linux" ]]; then - ps -eo user,pid,%cpu,comm | awk -v proc="$proc" 'tolower($4) ~ tolower(proc)' + ps -eo user,pid,%cpu,comm | awk -v proc="$proc" 'NR == 1 || tolower($4) ~ tolower(proc)' elif [[ "$OS_TYPE" == "macOS" ]]; then - ps -Ao user,pid,%cpu,comm | awk -v proc="$proc" 'tolower($4) ~ tolower(proc)' + ps -Ao user,pid,%cpu,comm | awk -v proc="$proc" 'NR == 1 || tolower($4) ~ tolower(proc)' else echo "Unsupported OS type." fi @@ -128,19 +129,19 @@ get_process_cpu_usage() { # Output process list in JSON format; skips the header line and handles multi-word commands. output_json() { - local data - data=$(cat) # Read from STDIN - echo "$data" | sed '1d' | awk ' + awk ' BEGIN { print "[" sep = "" } - { - # Reconstruct command field (from 4th field onward) + NR > 1 && NF >= 4 { + # Reconstruct command field (from 4th field onward) and escape it for JSON cmd = $4 for(i = 5; i <= NF; i++) { cmd = cmd " " $i } + gsub(/\\/, "\\\\", cmd) + gsub(/"/, "\\\"", cmd) printf "%s {\"user\": \"%s\", \"pid\": %s, \"cpu\": %s, \"command\": \"%s\"}", sep, $1, $2, $3, cmd sep = ",\n" } @@ -154,6 +155,10 @@ main() { check_required_commands detect_os + # Use '.' as decimal separator and untranslated headers (e.g. mpstat's "Average") + # so the output of top/ps/mpstat can be parsed and emitted as valid JSON. + export LC_ALL=C + # Default parameter values local process="" local user="" @@ -172,7 +177,8 @@ main() { i) interval="${OPTARG}" ;; c) per_core=1 ;; h) usage ;; - *) echo "Invalid option: -${OPTARG}" >&2; usage ;; + :) echo "Option -${OPTARG} requires an argument." >&2; usage 1 >&2 ;; + *) echo "Invalid option: -${OPTARG}" >&2; usage 1 >&2 ;; esac done shift $((OPTIND - 1)) @@ -186,6 +192,14 @@ main() { echo "Error: -i INTERVAL must be an integer." >&2 exit 1 fi + if [[ -n "$user" ]] && ! id "$user" &>/dev/null; then + echo "Error: user '$user' does not exist." >&2 + exit 1 + fi + if [[ "$format" != "text" && "$format" != "json" ]]; then + echo "Error: -f FORMAT must be 'text' or 'json'." >&2 + exit 1 + fi # Main monitoring loop while true; do diff --git a/src/dead_code.sh b/src/dead_code.sh index 17e598f..55fb44a 100755 --- a/src/dead_code.sh +++ b/src/dead_code.sh @@ -4,17 +4,22 @@ # Description: Searches Python files for function and class definitions with low usage counts. # Supports exclusions, multiple output formats, and verbose logging. # Usage: ./dead_code.sh [-n threshold] [-d directory] [-e path1,path2] [-v] [-f format] [-o output] +# [-p] [-l length] [--include-dunder] [-h] # Example: ./dead_code.sh -n 3 -d /path/to/project -e tests,venv,.git -v -f json -o report.json set -euo pipefail IFS=$'\n\t' -# Color codes for output -readonly RED='\033[0;31m' -readonly GREEN='\033[0;32m' -readonly YELLOW='\033[1;33m' -readonly BLUE='\033[0;34m' -readonly NC='\033[0m' # No Color +# Color codes for log messages (stderr), only when it is a terminal +if [ -t 2 ]; then + readonly RED='\033[0;31m' + readonly GREEN='\033[0;32m' + readonly YELLOW='\033[1;33m' + readonly BLUE='\033[0;34m' + readonly NC='\033[0m' # No Color +else + readonly RED='' GREEN='' YELLOW='' BLUE='' NC='' +fi # Default values THRESHOLD=2 @@ -41,16 +46,16 @@ Usage: $0 [OPTIONS] Find potentially dead code in Python projects by analyzing function and class usage. OPTIONS: - -n threshold Minimum occurrences threshold (default: 2) - -d directory Directory to search in (default: current directory) - -e paths Comma-separated list of paths to exclude (supports wildcards) - -v Enable verbose mode - -f format Output format: text, json, csv (default: text) - -o file Output file (default: stdout) - -p Include private methods/functions (names starting with _) - -l length Minimum name length to consider (default: 2) - --include-dunder Include dunder methods (__init__, __str__, etc.) - -h Display this help message + -n, --threshold N Report names with fewer than N occurrences (default: 2) + -d, --directory DIR Directory to search in (default: current directory) + -e, --exclude PATHS Comma-separated list of paths to exclude (supports wildcards) + -v, --verbose Enable verbose mode + -f, --format FORMAT Output format: text, json, csv (default: text) + -o, --output FILE Output file (default: stdout) + -p, --include-private Include private methods/functions (names starting with _) + -l, --min-length N Minimum name length to consider (default: 2) + --include-dunder Include dunder methods (__init__, __str__, etc.) + -h, --help Display this help message EXAMPLES: $0 -n 3 -d /path/to/project -e tests,venv,.git -v @@ -59,7 +64,8 @@ EXAMPLES: NOTES: - Functions/classes used in decorators, metaclasses, or string references may be flagged - - Test files and their contents are excluded by default + - Occurrences are counted per line and include the definition itself + - Test-like names (test_*, *_test, *Test*) are always ignored; use -e to skip test files - Private methods are excluded unless -p flag is used - Results should be manually verified before removing code EOF @@ -84,7 +90,7 @@ log_info() { } validate_inputs() { - if [[ ! "$THRESHOLD" =~ ^[0-9]+$ ]] || [ "$THRESHOLD" -lt 0 ]; then + if [[ ! "$THRESHOLD" =~ ^[0-9]+$ ]]; then log_error "Threshold must be a non-negative integer" exit 1 fi @@ -116,6 +122,15 @@ validate_inputs() { parse_arguments() { while [[ $# -gt 0 ]]; do + # Options that take a value must have one + case $1 in + -n|--threshold|-d|--directory|-e|--exclude|-f|--format|-o|--output|-l|--min-length) + if [[ $# -lt 2 ]]; then + log_error "Option $1 requires an argument" + exit 1 + fi + ;; + esac case $1 in -n|--threshold) THRESHOLD="$2" @@ -159,16 +174,17 @@ parse_arguments() { ;; *) log_error "Unknown option: $1" - usage + usage >&2 exit 1 ;; esac done } -# Function to build find command with exclusions -build_find_command() { +# Function to find all Python files (NUL-separated) +find_python_files() { local find_cmd=("find" "$DIRECTORY") + local excluded_path # Add exclusions if [ "${#EXCLUDED_PATHS[@]}" -gt 0 ]; then @@ -193,20 +209,6 @@ build_find_command() { find_cmd+=("-type" "f" "-name" "*.py" "-print0") - printf '%s\0' "${find_cmd[@]}" -} - -# Function to find all Python files -find_python_files() { - local find_cmd_str - find_cmd_str=$(build_find_command) - - # Convert null-separated string back to array and execute - local find_cmd=() - while IFS= read -r -d '' element; do - find_cmd+=("$element") - done <<< "$find_cmd_str" - "${find_cmd[@]}" } @@ -237,54 +239,45 @@ should_ignore_name() { return 1 # don't ignore } -# Enhanced function to extract function and class names with better regex +# Extracts function and class names from all Python files into the global +# DEFINITIONS array ("name:type", unique, sorted) and updates STATS. +# Runs in the current shell (not a subshell) so the STATS counters are kept. extract_definitions() { - local file - declare -A definitions + local file name + declare -A definitions=() for file in "${PYTHON_FILES[@]}"; do log_verbose "Processing file: $file" STATS[total_files]=$((STATS[total_files] + 1)) # Extract function definitions (including async functions) - while IFS= read -r line; do - if [[ -n "$line" ]]; then - definitions["$line"]="function" - STATS[total_functions]=$((STATS[total_functions] + 1)) - fi - done < <(grep -Eho '^\s*(async\s+)?def\s+([a-zA-Z_][a-zA-Z0-9_]*)' "$file" | \ - sed -E 's/^\s*(async\s+)?def\s+//' || true) + while IFS= read -r name; do + definitions["$name"]="function" + STATS[total_functions]=$((STATS[total_functions] + 1)) + done < <(grep -Eho '^\s*(async\s+)?def\s+[a-zA-Z_][a-zA-Z0-9_]*' "$file" | sed -E 's/^\s*(async\s+)?def\s+//' || true) # Extract class definitions - while IFS= read -r line; do - if [[ -n "$line" ]]; then - definitions["$line"]="class" - STATS[total_classes]=$((STATS[total_classes] + 1)) - fi - done < <(grep -Eho '^\s*class\s+([a-zA-Z_][a-zA-Z0-9_]*)' "$file" | \ - sed -E 's/^\s*class\s+//' | cut -d'(' -f1 || true) + while IFS= read -r name; do + definitions["$name"]="class" + STATS[total_classes]=$((STATS[total_classes] + 1)) + done < <(grep -Eho '^\s*class\s+[a-zA-Z_][a-zA-Z0-9_]*' "$file" | sed -E 's/^\s*class\s+//' || true) done - # Output unique definitions with their types + local unsorted=() for name in "${!definitions[@]}"; do - echo "$name:${definitions[$name]}" - done | sort + unsorted+=("$name:${definitions[$name]}") + done + + DEFINITIONS=() + if [ "${#unsorted[@]}" -gt 0 ]; then + mapfile -t DEFINITIONS < <(printf '%s\n' "${unsorted[@]}" | sort) + fi } -# Function to count occurrences with improved accuracy +# Counts the lines (across all Python files) on which the name appears as a whole word count_occurrences() { local name="$1" - local count=0 - local file - - for file in "${PYTHON_FILES[@]}"; do - # More sophisticated counting that considers context - local file_count - file_count=$(grep -Ec "(^|[^a-zA-Z0-9_])${name}([^a-zA-Z0-9_]|$)" "$file" || echo 0) - count=$((count + file_count)) - done - - echo "$count" + { grep -Eh "(^|[^a-zA-Z0-9_])${name}([^a-zA-Z0-9_]|$)" "${PYTHON_FILES[@]}" || true; } | wc -l } # Output functions for different formats @@ -296,7 +289,7 @@ output_text() { return fi - echo -e "${YELLOW}Potentially Dead Code Report${NC}" + echo "Potentially Dead Code Report" echo "==================================" echo @@ -319,7 +312,8 @@ output_json() { echo "{" echo " \"timestamp\": \"$(date -u +"%Y-%m-%dT%H:%M:%SZ")\"," echo " \"threshold\": $THRESHOLD," - echo " \"directory\": \"$DIRECTORY\"," + local dir_json="${DIRECTORY//\\/\\\\}" + echo " \"directory\": \"${dir_json//\"/\\\"}\"," echo " \"statistics\": {" echo " \"files_processed\": ${STATS[total_files]}," echo " \"functions_found\": ${STATS[total_functions]}," @@ -381,12 +375,11 @@ main() { # Extract definitions and analyze log_verbose "Extracting function and class definitions..." local results=() + local definition name type - while IFS= read -r definition; do - if [[ -z "$definition" ]]; then - continue - fi + extract_definitions + for definition in "${DEFINITIONS[@]}"; do IFS=':' read -r name type <<< "$definition" if should_ignore_name "$name"; then @@ -402,8 +395,7 @@ main() { results+=("$name:$type:$count") STATS[dead_code_items]=$((STATS[dead_code_items] + 1)) fi - - done < <(extract_definitions) + done # Output results local output_func diff --git a/src/decimal_binary.sh b/src/decimal_binary.sh index 12a70c7..3da75b5 100755 --- a/src/decimal_binary.sh +++ b/src/decimal_binary.sh @@ -1,72 +1,67 @@ #!/usr/bin/env bash -# Script Name: decimal_binary +# Script Name: decimal_binary.sh # Description: Converts decimal numbers to their binary representation and vice versa. -# Usage: DecimalBinaryConverter.sh [-d2b|-b2d] [number] +# Usage: decimal_binary.sh [-d2b|-b2d] [number] # [-d2b|-b2d] - Flag to specify the conversion direction (-d2b for decimal to binary, -b2d for binary to decimal). -# [number] - The number to be converted. +# [number] - The number to be converted (non-negative). # Example: ./decimal_binary.sh -d2b 123 # ./decimal_binary.sh -b2d 1111011 # Print usage function print_usage() { - echo "Usage: $0 [-d2b|-b2b] [number]" + echo "Usage: $0 [-d2b|-b2d] [number]" echo "Converts decimal numbers to their binary representation and vice versa." } -# Validation of input function +# Validation of input function: $1 number, $2 allowed-digits regex, $3 label validate_input() { - re='^[0-9]+$' - if ! [[ $1 =~ $re ]]; then - echo "Error: Input is not a positive integer!" + if ! [[ $1 =~ $2 ]]; then + echo "Error: '$1' is not a valid $3 number!" >&2 exit 1 fi } # Conversion of decimal to binary function decimal_to_binary() { - local number=$1 - local remainder + local number=$((10#$1)) # force base 10 so leading zeros are not read as octal local binary_representation="" - while [ "$number" -gt 0 ] - do - remainder=$(( number % 2)) - binary_representation="$remainder$binary_representation" - number=$(( number / 2)) + while (( number > 0 )); do + binary_representation="$(( number % 2 ))$binary_representation" + number=$(( number / 2 )) done - echo "$binary_representation" + echo "${binary_representation:-0}" } # Conversion of binary to decimal function binary_to_decimal() { - local binary=$1 - echo "$((2#$binary))" + echo "$((2#$1))" } # Main function main() { if [ $# -ne 2 ]; then - echo "Error: Must provide exactly two arguments!" - print_usage + echo "Error: Must provide exactly two arguments!" >&2 + print_usage >&2 exit 1 fi local operation=$1 local number=$2 - validate_input "$number" - if [ "$operation" == "-d2b" ]; then + validate_input "$number" '^[0-9]+$' decimal echo "Conversion of decimal number $number to binary:" decimal_to_binary "$number" elif [ "$operation" == "-b2d" ]; then + validate_input "$number" '^[01]+$' binary echo "Conversion of binary number $number to decimal:" binary_to_decimal "$number" else - echo "Error: Invalid operation $operation!" - print_usage + echo "Error: Invalid operation $operation!" >&2 + print_usage >&2 exit 1 fi } diff --git a/src/digits.sh b/src/digits.sh index c5d6e6f..a3add51 100755 --- a/src/digits.sh +++ b/src/digits.sh @@ -20,14 +20,14 @@ print_digits() { main() { if [ "$#" -ne 1 ]; then - echo "Usage: $0 number" - echo " number - the number (digits only) to display the digits of" + echo "Usage: $0 number" >&2 + echo " number - the number (digits only) to display the digits of" >&2 exit 1 fi # Validate input: ensure that it contains only digits. if ! [[ "$1" =~ ^[0-9]+$ ]]; then - echo "Invalid input. Please provide a positive integer (digits only)." + echo "Invalid input. Please provide a positive integer (digits only)." >&2 exit 1 fi diff --git a/src/disk_usage.sh b/src/disk_usage.sh index c16349a..4374957 100755 --- a/src/disk_usage.sh +++ b/src/disk_usage.sh @@ -14,7 +14,7 @@ # -a, --all Include all filesystems (including tmpfs, udev, etc.). # -s, --sort FIELD Sort output by field (filesystem, size, used, avail, use%, mount). # -r, --reverse Reverse the sort order. -# -o, --output FILE Save output to specified file. +# -o, --output FILE Save output to specified file (overwritten) and print it. # --json Output in JSON format. # --csv Output in CSV format. # --no-header Do not display header row. @@ -60,7 +60,7 @@ Options: -a, --all Include all filesystems (including tmpfs, udev, etc.). -s, --sort FIELD Sort output by field (filesystem, size, used, avail, use%, mount). -r, --reverse Reverse the sort order. - -o, --output FILE Save output to specified file. + -o, --output FILE Save output to specified file (overwritten) and print it. --json Output in JSON format. --csv Output in CSV format. --no-header Do not display header row. @@ -90,85 +90,82 @@ log_action() { fi } +# Sort tab-separated rows according to SORT_FIELD / REVERSE_SORT +sort_rows() { + local key="" + case "$SORT_FIELD" in + filesystem) key="1,1" ;; + size) key="2,2h" ;; + used) key="3,3h" ;; + avail) key="4,4h" ;; + use%) key="5,5n" ;; + mount) key="6,6" ;; + esac + + # A key with its own modifiers ignores a global -r, so attach r to the key + if [[ -n "$key" && "$REVERSE_SORT" == true ]]; then + LC_ALL=C sort -t $'\t' -k "${key}r" + elif [[ -n "$key" ]]; then + LC_ALL=C sort -t $'\t' -k "$key" + elif [[ "$REVERSE_SORT" == true ]]; then + LC_ALL=C sort -r + else + cat + fi +} + # Function to list disk partitions and usage list_disks() { - local df_options="-h" + # -P: one line per filesystem, -T: include the filesystem type column + local df_args=(-P -T -h) if [[ "$INCLUDE_ALL" == true ]]; then - df_options="$df_options -a" + df_args+=(-a) fi - local df_output - df_output=$(df "$df_options") - - # Exclude unwanted filesystems - local awk_script='NR>1' - if [[ "$INCLUDE_ALL" == false ]]; then - awk_script="$awk_script && \$1 !~ /^tmpfs/ && \$1 !~ /^udev/ && \$1 !~ /^devtmpfs/" - fi - if [[ -n "$FILESYSTEM_TYPE" ]]; then - awk_script="$awk_script && \$1 ~ /$FILESYSTEM_TYPE/" - fi - if [[ -n "$DISK_PATTERN" ]]; then - awk_script="$awk_script && \$1 ~ /$DISK_PATTERN/" - fi - if [[ -n "$EXCLUDE_PATTERN" ]]; then - awk_script="$awk_script && \$1 !~ /$EXCLUDE_PATTERN/" - fi - - # Prepare sort options - local sort_options="" - if [[ -n "$SORT_FIELD" ]]; then - local field_number - case "$SORT_FIELD" in - filesystem) field_number=1 ;; - size) field_number=2 ;; - used) field_number=3 ;; - avail) field_number=4 ;; - use%) field_number=5 ;; - mount) field_number=6 ;; - *) - echo "Invalid sort field: $SORT_FIELD" - exit 1 - ;; - esac - sort_options="-k${field_number}" - fi - if [[ "$REVERSE_SORT" == true ]]; then - sort_options="$sort_options -r" - fi + # Filter the rows (patterns are passed as awk variables, not spliced into + # the program) and emit tab-separated fields: + # filesystem, size, used, avail, use%, mount point (which may contain spaces) + # LC_ALL=C keeps "." as the decimal separator (sizes like 6.2M stay CSV-safe). + # df exits non-zero when a single mount cannot be read (common with -a); + # report it on stderr but still show the rest + local awk_vars=(-v all="$INCLUDE_ALL" -v type="$FILESYSTEM_TYPE" -v pat="$DISK_PATTERN" -v excl="$EXCLUDE_PATTERN") + { LC_ALL=C df "${df_args[@]}" || true; } | awk "${awk_vars[@]}" ' + NR == 1 { next } + all != "true" && ($2 == "tmpfs" || $2 == "devtmpfs" || $1 ~ /^udev/) { next } + type != "" && $2 != type { next } + pat != "" && $1 !~ pat { next } + excl != "" && $1 ~ excl { next } + { + mount = $7 + for (i = 8; i <= NF; i++) mount = mount " " $i + printf "%s\t%s\t%s\t%s\t%s\t%s\n", $1, $3, $4, $5, $6, mount + }' | sort_rows | format_rows +} - # Output formatting - local output_format +# Format tab-separated rows as JSON, CSV or an aligned table +format_rows() { if [[ "$OUTPUT_JSON" == true ]]; then - output_format="json" + awk -F'\t' ' + function esc(s) { gsub(/\\/, "\\\\", s); gsub(/"/, "\\\"", s); return s } + BEGIN { ORS=""; print "[" } + { + if (NR > 1) print "," + printf "{ \"filesystem\": \"%s\", \"size\": \"%s\", \"used\": \"%s\", \"avail\": \"%s\", \"use%%\": \"%s\", \"mount\": \"%s\" }", esc($1), $2, $3, $4, $5, esc($6) + } + END { print "]\n" }' elif [[ "$OUTPUT_CSV" == true ]]; then - output_format="csv" + if [[ "$NO_HEADER" == false ]]; then + echo "Filesystem,Size,Used,Avail,Use%,Mounted on" + fi + awk -F'\t' -v OFS=',' '{ print $1, $2, $3, $4, $5, $6 }' else - output_format="plain" - fi - - # Process and output the data - echo "$df_output" | awk "$awk_script" | \ { - if [[ "$output_format" == "json" ]]; then - awk 'BEGIN { ORS=""; print "[" } - { - if (NR > 1) print "," - printf "{ \"filesystem\": \"%s\", \"size\": \"%s\", \"used\": \"%s\", \"avail\": \"%s\", \"use%%\": \"%s\", \"mount\": \"%s\" }", $1, $2, $3, $4, $5, $6 - } - END { print "]" }' - elif [[ "$output_format" == "csv" ]]; then - if [[ "$NO_HEADER" == false ]]; then - echo "Filesystem,Size,Used,Avail,Use%,Mounted on" - fi - awk '{ printf "%s,%s,%s,%s,%s,%s\n", $1, $2, $3, $4, $5, $6 }' - else if [[ "$NO_HEADER" == false ]]; then - echo "Filesystem Size Used Avail Use% Mounted on" + printf 'Filesystem\tSize\tUsed\tAvail\tUse%%\tMounted on\n' fi - column -t - fi - } | sort "$sort_options" + cat + } | column -t -s $'\t' + fi } # Parse command-line arguments @@ -192,7 +189,7 @@ while [[ $# -gt 0 ]]; do DISK_PATTERN="$2" shift 2 else - echo "Error: --pattern requires a value." + echo "Error: --pattern requires a value." >&2 exit 1 fi ;; @@ -201,7 +198,7 @@ while [[ $# -gt 0 ]]; do EXCLUDE_PATTERN="$2" shift 2 else - echo "Error: --exclude requires a value." + echo "Error: --exclude requires a value." >&2 exit 1 fi ;; @@ -210,7 +207,7 @@ while [[ $# -gt 0 ]]; do FILESYSTEM_TYPE="$2" shift 2 else - echo "Error: --type requires a value." + echo "Error: --type requires a value." >&2 exit 1 fi ;; @@ -223,7 +220,7 @@ while [[ $# -gt 0 ]]; do SORT_FIELD="$2" shift 2 else - echo "Error: --sort requires a field." + echo "Error: --sort requires a field." >&2 exit 1 fi ;; @@ -236,7 +233,7 @@ while [[ $# -gt 0 ]]; do OUTPUT_FILE="$2" shift 2 else - echo "Error: --output requires a file path." + echo "Error: --output requires a file path." >&2 exit 1 fi ;; @@ -258,25 +255,37 @@ while [[ $# -gt 0 ]]; do LOG_ENABLED=true shift 2 else - echo "Error: --log-file requires a file path." + echo "Error: --log-file requires a file path." >&2 exit 1 fi ;; *) - echo "Unknown option: $1" - print_usage + echo "Unknown option: $1" >&2 + print_usage >&2 exit 1 ;; esac done -# Redirect output to file if specified -if [[ -n "$OUTPUT_FILE" ]]; then - exec > >(tee -a "$OUTPUT_FILE") +case "$SORT_FIELD" in + ""|filesystem|size|used|avail|use%|mount) ;; + *) + echo "Invalid sort field: $SORT_FIELD" >&2 + exit 1 + ;; +esac + +if [[ "$OUTPUT_JSON" != true && "$OUTPUT_CSV" != true ]] && ! command -v column >/dev/null 2>&1; then + echo "Error: 'column' is required for table output (or use --csv / --json)." >&2 + exit 1 fi -# Execute the main function -list_disks +# Execute the main function, saving the output to a file if specified +if [[ -n "$OUTPUT_FILE" ]]; then + list_disks | tee "$OUTPUT_FILE" +else + list_disks +fi # Log the action log_action "Disk usage information displayed." diff --git a/src/display_weather.sh b/src/display_weather.sh index 5881514..a1680e1 100755 --- a/src/display_weather.sh +++ b/src/display_weather.sh @@ -2,47 +2,58 @@ # Script Name: display_weather.sh # Description: Fetches and displays the current weather for a specified city using wttr.in. +# Without a city, wttr.in guesses the location from your IP address. # Usage: ./display_weather.sh [CITY] # Example: ./display_weather.sh London +# ./display_weather.sh "New York" # Function to display usage usage() { - echo "Usage: $0 [CITY]" + echo "Usage: $0 [CITY]" >&2 exit 1 } -# Check for missing arguments -if [ "$#" -ne 1 ]; then +# Check for too many arguments +if [ "$#" -gt 1 ]; then usage fi -# Define your city -CITY="$1" +if ! command -v curl >/dev/null 2>&1; then + echo "Error: this script requires 'curl'." >&2 + exit 1 +fi + +CITY="${1:-}" -# Fetch the weather data, making curl follow any redirects -WEATHER_DATA=$(curl -s -L "http://wttr.in/$CITY?format=3") +# Fetch the weather as "Location: Condition Temperature" (wttr.in expects '+' for spaces), +# making curl follow any redirects and fail on HTTP errors. +if ! WEATHER_DATA=$(curl -fsSL -G --data-urlencode "format=%l: %C %t" "https://wttr.in/${CITY// /+}"); then + echo "Error: failed to fetch weather data for '${CITY:-your location}'." >&2 + exit 1 +fi -# Parsing the data -CONDITION=$(echo "$WEATHER_DATA" | awk -F ':' '{print $2}') +# Parsing the data (the condition text follows the first ':') +CONDITION="${WEATHER_DATA#*:}" # Display the City Name -echo -e "\e[1m\e[95mCity: $CITY\e[0m" +echo -e "\e[1m\e[95mCity: ${CITY:-auto-detected}\e[0m" echo "-----------------------------------" # Use a case statement to display the appropriate weather icon and color +shopt -s nocasematch case $CONDITION in - *Clear*) + *Clear*|*Sunny*) echo -e "\e[93m☀️ $WEATHER_DATA\e[0m" ;; - *Rain*|*Drizzle*) + *Rain*|*Drizzle*|*Shower*) echo -e "\e[94m🌧️ $WEATHER_DATA\e[0m" ;; - *Cloud*) - echo -e "\e[37m☁️ $WEATHER_DATA\e[0m" - ;; - *Snow*) + *Snow*|*Sleet*|*Ice*) echo -e "\e[96m❄️ $WEATHER_DATA\e[0m" ;; + *Cloud*|*Overcast*) + echo -e "\e[37m☁️ $WEATHER_DATA\e[0m" + ;; *) echo -e "\e[95m🌀 $WEATHER_DATA\e[0m" # Default case ;; diff --git a/src/download_all_github_repos.sh b/src/download_all_github_repos.sh index 5c3143d..c335835 100755 --- a/src/download_all_github_repos.sh +++ b/src/download_all_github_repos.sh @@ -80,6 +80,7 @@ DISCOVER_SOURCE_VALUE="" DISCOVER_OWNER_FILTER="" MIRROR_TOTAL=0 MIRROR_PROGRESS_FILE="" +WORK_TMPDIR="" ############################################################################### # Usage @@ -151,9 +152,9 @@ Usage: download_all_github_repos.sh backup [OPTIONS] Required: --manifest FILE JSON or CSV manifest created by discover. --dest DIR Backup destination root. - --config FILE Load defaults from a JSON config file. CLI flags override. Backup behavior: + --config FILE Load defaults from a JSON config file. CLI flags override. --mode mirror|clone|archive|sparse Override per-repository manifest mode. --protocol https|ssh Clone protocol for git modes. Default: https. @@ -255,9 +256,11 @@ check_backup_dependencies() { check_common_dependencies require_command git require_command tar - if [[ -n "$LOCK_FILE" ]]; then - require_command flock - fi + require_command flock +} + +need_arg() { + [[ $# -ge 2 && -n "$2" ]] || die "$1 requires a value." } is_positive_integer() { @@ -297,7 +300,8 @@ config_arg_from_args() { config_string() { local filter="$1" - jq -r "$filter // empty" "$CONFIG_FILE" + # Not "$filter // empty": that would also drop an explicit false. + jq -r "($filter) | if . == null then empty else . end" "$CONFIG_FILE" } set_config_string() { @@ -638,6 +642,7 @@ write_json_manifest() { if [[ -n "$existing" && -f "$existing" ]]; then manifest_to_json "$existing" > "$existing_arg" else + [[ -z "$existing" ]] || warn "Input manifest not found, starting fresh: $existing" printf '{"repositories":[]}\n' > "$existing_arg" fi @@ -686,9 +691,10 @@ json_manifest_to_csv() { local manifest="$1" local output="$2" - { - printf 'enabled,full_name,clone_url,ssh_url,html_url,default_branch,private,fork,archived,mode,paths,exclude_paths\n' - jq -r ' + # Built entirely in jq so empty cells keep their column position. + jq -r ' + "enabled,full_name,clone_url,ssh_url,html_url,default_branch,private,fork,archived,mode,paths,exclude_paths", + ( .repositories[] | [ ((if has("enabled") then .enabled else true end) | tostring), @@ -704,50 +710,46 @@ json_manifest_to_csv() { ((.paths // []) | join(";")), ((.exclude_paths // []) | join(";")) ] - | @tsv - ' "$manifest" | while IFS=$'\t' read -r enabled full_name clone_url ssh_url html_url default_branch private fork archived mode paths exclude_paths; do - printf '%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s\n' \ - "$enabled" "$full_name" "$clone_url" "$ssh_url" "$html_url" "$default_branch" \ - "$private" "$fork" "$archived" "$mode" "$paths" "$exclude_paths" - done -} > "$output" + | join(",") + ) + ' "$manifest" > "$output" } csv_manifest_to_json() { -local manifest="$1" -local objects -objects="$(mktemp)" - -tail -n +2 "$manifest" | while IFS=, read -r enabled full_name clone_url ssh_url html_url default_branch private fork archived mode paths exclude_paths _extra; do - [[ -z "${full_name:-}" ]] && continue - - enabled="$(normalize_bool "$(trim_csv_cell "${enabled:-true}")")" - private="$(normalize_bool "$(trim_csv_cell "${private:-false}")")" - fork="$(normalize_bool "$(trim_csv_cell "${fork:-false}")")" - archived="$(normalize_bool "$(trim_csv_cell "${archived:-false}")")" - - full_name="$(trim_csv_cell "${full_name:-}")" - clone_url="$(trim_csv_cell "${clone_url:-}")" - ssh_url="$(trim_csv_cell "${ssh_url:-}")" - html_url="$(trim_csv_cell "${html_url:-}")" - default_branch="$(trim_csv_cell "${default_branch:-}")" - mode="$(trim_csv_cell "${mode:-mirror}")" - paths="$(trim_csv_cell "${paths:-}")" - exclude_paths="$(trim_csv_cell "${exclude_paths:-}")" - - jq -cn \ - --argjson enabled "$enabled" \ - --arg full_name "$full_name" \ - --arg clone_url "$clone_url" \ - --arg ssh_url "$ssh_url" \ - --arg html_url "$html_url" \ - --arg default_branch "$default_branch" \ - --argjson private "$private" \ - --argjson fork "$fork" \ - --argjson archived "$archived" \ - --arg mode "$mode" \ - --argjson paths "$(json_string_array_from_semicolon "$paths")" \ - --argjson exclude_paths "$(json_string_array_from_semicolon "$exclude_paths")" ' + local manifest="$1" + local objects + objects="$(mktemp)" + + tail -n +2 "$manifest" | while IFS=, read -r enabled full_name clone_url ssh_url html_url default_branch private fork archived mode paths exclude_paths _extra || [[ -n "${enabled:-}" ]]; do + [[ -z "${full_name:-}" ]] && continue + + enabled="$(normalize_bool "$(trim_csv_cell "${enabled:-true}")")" + private="$(normalize_bool "$(trim_csv_cell "${private:-false}")")" + fork="$(normalize_bool "$(trim_csv_cell "${fork:-false}")")" + archived="$(normalize_bool "$(trim_csv_cell "${archived:-false}")")" + + full_name="$(trim_csv_cell "${full_name:-}")" + clone_url="$(trim_csv_cell "${clone_url:-}")" + ssh_url="$(trim_csv_cell "${ssh_url:-}")" + html_url="$(trim_csv_cell "${html_url:-}")" + default_branch="$(trim_csv_cell "${default_branch:-}")" + mode="$(trim_csv_cell "${mode:-mirror}")" + paths="$(trim_csv_cell "${paths:-}")" + exclude_paths="$(trim_csv_cell "${exclude_paths:-}")" + + jq -cn \ + --argjson enabled "$enabled" \ + --arg full_name "$full_name" \ + --arg clone_url "$clone_url" \ + --arg ssh_url "$ssh_url" \ + --arg html_url "$html_url" \ + --arg default_branch "$default_branch" \ + --argjson private "$private" \ + --argjson fork "$fork" \ + --argjson archived "$archived" \ + --arg mode "$mode" \ + --argjson paths "$(json_string_array_from_semicolon "$paths")" \ + --argjson exclude_paths "$(json_string_array_from_semicolon "$exclude_paths")" ' { enabled: $enabled, full_name: $full_name, @@ -763,10 +765,10 @@ tail -n +2 "$manifest" | while IFS=, read -r enabled full_name clone_url ssh_url exclude_paths: $exclude_paths } ' >> "$objects" -done + done -jq -s \ ---arg created_at "$(now_utc_iso)" ' + jq -s \ + --arg created_at "$(now_utc_iso)" ' { version: 1, created_at: $created_at, @@ -788,72 +790,78 @@ jq -s \ } ' "$objects" -rm -f "$objects" + rm -f "$objects" } manifest_to_json() { -local manifest="$1" + local manifest="$1" -[[ -f "$manifest" ]] || die "Manifest not found: $manifest" -[[ -s "$manifest" ]] || die "Manifest is empty: $manifest" + [[ -f "$manifest" ]] || die "Manifest not found: $manifest" + [[ -s "$manifest" ]] || die "Manifest is empty: $manifest" -if jq -e 'type == "object" and has("repositories")' "$manifest" >/dev/null 2>&1; then - jq '.' "$manifest" -else - csv_manifest_to_json "$manifest" -fi + if jq -e 'type == "object" and has("repositories")' "$manifest" >/dev/null 2>&1; then + jq '.' "$manifest" + else + csv_manifest_to_json "$manifest" + fi } validate_manifest_file() { -local manifest="$1" -local tmp - -tmp="$(mktemp)" -manifest_to_json "$manifest" > "$tmp" - -jq -e ' + local manifest="$1" + local label="${2:-$1}" + local tmp + local filter=' type == "object" and (.repositories | type == "array") - and all(.repositories[]; (.enabled | type == "boolean") and (.full_name | type == "string") and (.mode | type == "string")) - ' "$tmp" >/dev/null || { -rm -f "$tmp" -die "Manifest validation failed: $manifest" -} + and all(.repositories[]; + (.enabled | type == "boolean") + and (.full_name | type == "string" and length > 0) + and (.mode | IN("mirror", "clone", "archive", "sparse"))) + ' + + tmp="$(mktemp)" + manifest_to_json "$manifest" > "$tmp" + + if ! jq -e "$filter" "$tmp" >/dev/null; then + rm -f "$tmp" + die "Manifest validation failed: $label (each repository needs boolean enabled, non-empty full_name, and mode mirror|clone|archive|sparse)" + fi -info "Manifest is valid: $manifest" -info "Enabled repositories: $(jq '[.repositories[] | select(.enabled == true)] | length' "$tmp")" -info "Total repositories: $(jq '.repositories | length' "$tmp")" -rm -f "$tmp" + info "Manifest is valid: $label" + info "Enabled repositories: $(jq '[.repositories[] | select(.enabled == true)] | length' "$tmp")" + info "Total repositories: $(jq '.repositories | length' "$tmp")" + rm -f "$tmp" } ############################################################################### # State helpers ############################################################################### init_state_file() { -local manifest_json="$1" -local checksum="$2" - -if [[ "$RESUME" == true && -f "$STATE_FILE" ]]; then -STATE_MANIFEST_CHECKSUM="$(jq -r '.manifest_checksum // empty' "$STATE_FILE")" -if [[ -n "$STATE_MANIFEST_CHECKSUM" && "$STATE_MANIFEST_CHECKSUM" != "$checksum" ]]; then - warn "State manifest checksum differs from current manifest. Completed entries may not match." -fi -RUN_DIR="$(jq -r '.run_dir // empty' "$STATE_FILE")" -if [[ -z "$RUN_DIR" || ! -d "$RUN_DIR" ]]; then - RUN_DIR="${DEST_DIR}/runs/$(now_utc_compact)" -fi -return 0 -fi - -RUN_DIR="${RUN_DIR:-${DEST_DIR}/runs/$(now_utc_compact)}" -mkdir -p "$RUN_DIR" - -jq -n \ ---arg started_at "$(now_utc_iso)" \ ---arg manifest "$MANIFEST" \ ---arg manifest_checksum "$checksum" \ ---arg run_dir "$RUN_DIR" \ ---argjson repo_count "$(jq '.repositories | length' "$manifest_json")" ' + local manifest_json="$1" + local checksum="$2" + + if [[ "$RESUME" == true && -f "$STATE_FILE" ]]; then + STATE_MANIFEST_CHECKSUM="$(jq -r '.manifest_checksum // empty' "$STATE_FILE")" + if [[ -n "$STATE_MANIFEST_CHECKSUM" && "$STATE_MANIFEST_CHECKSUM" != "$checksum" ]]; then + warn "State manifest checksum differs from current manifest. Completed entries may not match." + fi + RUN_DIR="$(jq -r '.run_dir // empty' "$STATE_FILE")" + if [[ -z "$RUN_DIR" || ! -d "$RUN_DIR" ]]; then + warn "Run directory from state is missing; repositories marked done will still be skipped." + RUN_DIR="${DEST_DIR}/runs/$(now_utc_compact)" + fi + return 0 + fi + + RUN_DIR="${RUN_DIR:-${DEST_DIR}/runs/$(now_utc_compact)}" + mkdir -p "$RUN_DIR" + + jq -n \ + --arg started_at "$(now_utc_iso)" \ + --arg manifest "$MANIFEST" \ + --arg manifest_checksum "$checksum" \ + --arg run_dir "$RUN_DIR" \ + --argjson repo_count "$(jq '.repositories | length' "$manifest_json")" ' { version: 1, started_at: $started_at, @@ -868,36 +876,36 @@ jq -n \ } state_repo_status() { -local key="$1" -[[ -f "$STATE_FILE" ]] || { -printf 'pending' -return 0 -} -jq -r --arg key "$key" '.repositories[$key].status // "pending"' "$STATE_FILE" + local key="$1" + [[ -f "$STATE_FILE" ]] || { + printf 'pending' + return 0 + } + jq -r --arg key "$key" '.repositories[$key].status // "pending"' "$STATE_FILE" } update_state_repo() { -local key="$1" -local status="$2" -local full_name="$3" -local mode="$4" -local output_path="$5" -local error_message="${6:-}" -local lock_file="${STATE_FILE}.lock" - -( -local tmp - -flock 8 -tmp="$(mktemp)" -jq \ - --arg updated_at "$(now_utc_iso)" \ - --arg key "$key" \ - --arg status "$status" \ - --arg full_name "$full_name" \ - --arg mode "$mode" \ - --arg output_path "$output_path" \ ---arg error_message "$error_message" ' + local key="$1" + local status="$2" + local full_name="$3" + local mode="$4" + local output_path="$5" + local error_message="${6:-}" + local lock_file="${STATE_FILE}.lock" + + ( + local tmp + + flock 8 + tmp="$(mktemp)" + jq \ + --arg updated_at "$(now_utc_iso)" \ + --arg key "$key" \ + --arg status "$status" \ + --arg full_name "$full_name" \ + --arg mode "$mode" \ + --arg output_path "$output_path" \ + --arg error_message "$error_message" ' .updated_at = $updated_at | .repositories[$key] = { status: $status, @@ -908,13 +916,13 @@ jq \ updated_at: $updated_at } ' "$STATE_FILE" > "$tmp" -mv "$tmp" "$STATE_FILE" -) 8>"$lock_file" + mv "$tmp" "$STATE_FILE" + ) 8>"$lock_file" } write_summary() { -local summary_file="$RUN_DIR/summary.json" -jq ' + local summary_file="$RUN_DIR/summary.json" + jq ' . as $state | { started_at: $state.started_at, @@ -928,111 +936,111 @@ jq ' repositories: $state.repositories } ' "$STATE_FILE" > "$summary_file" -info "Summary written to $summary_file" + info "Summary written to $summary_file" } ############################################################################### # Discovery ############################################################################### parse_discover_args() { -while [[ $# -gt 0 ]]; do -case "$1" in - --user) GITHUB_USER="${2:-}"; shift 2 ;; - --org) GITHUB_ORG="${2:-}"; shift 2 ;; - --authenticated-user) AUTHENTICATED_USER=true; shift ;; - --token) GITHUB_TOKEN="${2:-}"; shift 2 ;; - --output) OUTPUT_FILE="${2:-}"; shift 2 ;; - --format) OUTPUT_FORMAT="${2:-}"; shift 2 ;; - --input) INPUT_MANIFEST="${2:-}"; shift 2 ;; - --config) CONFIG_FILE="${2:-}"; shift 2 ;; - --include-forks) DISCOVER_INCLUDE_FORKS=true; shift ;; - --exclude-forks) DISCOVER_INCLUDE_FORKS=false; shift ;; - --include-archived) DISCOVER_INCLUDE_ARCHIVED=true; shift ;; - --exclude-archived) DISCOVER_INCLUDE_ARCHIVED=false; shift ;; - --visibility) VISIBILITY="${2:-}"; shift 2 ;; - --name-regex) NAME_REGEX="${2:-}"; shift 2 ;; - --max-repos) MAX_REPOS="${2:-}"; shift 2 ;; - --rate-limit) RATE_LIMIT_MODE="${2:-}"; shift 2 ;; - --max-retries) MAX_RETRIES="${2:-}"; shift 2 ;; - --retry-delay) RETRY_DELAY="${2:-}"; shift 2 ;; - --log-file) LOG_FILE="${2:-}"; shift 2 ;; - --quiet) QUIET=true; shift ;; - --verbose) VERBOSE=true; shift ;; - --no-color) NO_COLOR=true; shift ;; - -h|--help) print_discover_usage; exit 0 ;; - *) die "Unknown discover argument: $1" ;; -esac -done - -[[ -n "$OUTPUT_FILE" ]] || die "discover requires --output FILE." -case "$OUTPUT_FORMAT" in -json | csv) ;; -*) die "--format must be json or csv." ;; -esac -case "$VISIBILITY" in -all | public | private) ;; -*) die "--visibility must be all, public, or private." ;; -esac -case "$RATE_LIMIT_MODE" in -wait | fail) ;; -*) die "--rate-limit must be wait or fail." ;; -esac -is_positive_integer "$MAX_RETRIES" || die "--max-retries expects a positive integer." -is_non_negative_integer "$RETRY_DELAY" || die "--retry-delay expects a non-negative integer." -[[ -z "$MAX_REPOS" ]] || is_positive_integer "$MAX_REPOS" || die "--max-repos expects a positive integer." - -local sources=0 -[[ -n "$GITHUB_USER" ]] && ((sources += 1)) -[[ -n "$GITHUB_ORG" ]] && ((sources += 1)) -[[ "$AUTHENTICATED_USER" == true ]] && ((sources += 1)) -(( sources == 1 )) || die "Specify exactly one of --user, --org, or --authenticated-user." - -if [[ "$AUTHENTICATED_USER" == true && -z "$GITHUB_TOKEN" ]]; then -die "--authenticated-user requires --token or GITHUB_TOKEN." -fi + while [[ $# -gt 0 ]]; do + case "$1" in + --user) need_arg "$@"; GITHUB_USER="$2"; shift 2 ;; + --org) need_arg "$@"; GITHUB_ORG="$2"; shift 2 ;; + --authenticated-user) AUTHENTICATED_USER=true; shift ;; + --token) need_arg "$@"; GITHUB_TOKEN="$2"; shift 2 ;; + --output) need_arg "$@"; OUTPUT_FILE="$2"; shift 2 ;; + --format) need_arg "$@"; OUTPUT_FORMAT="$2"; shift 2 ;; + --input) need_arg "$@"; INPUT_MANIFEST="$2"; shift 2 ;; + --config) need_arg "$@"; CONFIG_FILE="$2"; shift 2 ;; + --include-forks) DISCOVER_INCLUDE_FORKS=true; shift ;; + --exclude-forks) DISCOVER_INCLUDE_FORKS=false; shift ;; + --include-archived) DISCOVER_INCLUDE_ARCHIVED=true; shift ;; + --exclude-archived) DISCOVER_INCLUDE_ARCHIVED=false; shift ;; + --visibility) need_arg "$@"; VISIBILITY="$2"; shift 2 ;; + --name-regex) need_arg "$@"; NAME_REGEX="$2"; shift 2 ;; + --max-repos) need_arg "$@"; MAX_REPOS="$2"; shift 2 ;; + --rate-limit) need_arg "$@"; RATE_LIMIT_MODE="$2"; shift 2 ;; + --max-retries) need_arg "$@"; MAX_RETRIES="$2"; shift 2 ;; + --retry-delay) need_arg "$@"; RETRY_DELAY="$2"; shift 2 ;; + --log-file) need_arg "$@"; LOG_FILE="$2"; shift 2 ;; + --quiet) QUIET=true; shift ;; + --verbose) VERBOSE=true; shift ;; + --no-color) NO_COLOR=true; shift ;; + -h|--help) print_discover_usage; exit 0 ;; + *) die "Unknown discover argument: $1" ;; + esac + done + + [[ -n "$OUTPUT_FILE" ]] || die "discover requires --output FILE." + case "$OUTPUT_FORMAT" in + json | csv) ;; + *) die "--format must be json or csv." ;; + esac + case "$VISIBILITY" in + all | public | private) ;; + *) die "--visibility must be all, public, or private." ;; + esac + case "$RATE_LIMIT_MODE" in + wait | fail) ;; + *) die "--rate-limit must be wait or fail." ;; + esac + is_positive_integer "$MAX_RETRIES" || die "--max-retries expects a positive integer." + is_non_negative_integer "$RETRY_DELAY" || die "--retry-delay expects a non-negative integer." + [[ -z "$MAX_REPOS" ]] || is_positive_integer "$MAX_REPOS" || die "--max-repos expects a positive integer." + + local sources=0 + [[ -n "$GITHUB_USER" ]] && ((sources += 1)) + [[ -n "$GITHUB_ORG" ]] && ((sources += 1)) + [[ "$AUTHENTICATED_USER" == true ]] && ((sources += 1)) + (( sources == 1 )) || die "Specify exactly one of --user, --org, or --authenticated-user." + + if [[ "$AUTHENTICATED_USER" == true && -z "$GITHUB_TOKEN" ]]; then + die "--authenticated-user requires --token or GITHUB_TOKEN." + fi } set_discover_endpoint_and_source() { -DISCOVER_OWNER_FILTER="" - -if [[ "$AUTHENTICATED_USER" == true ]]; then -DISCOVER_ENDPOINT="https://api.github.com/user/repos?per_page=100&visibility=${VISIBILITY}" -DISCOVER_SOURCE_TYPE="authenticated-user" -DISCOVER_SOURCE_VALUE="token-owner" -elif [[ -n "$GITHUB_USER" ]]; then -if [[ -n "$GITHUB_TOKEN" ]]; then - DISCOVER_ENDPOINT="https://api.github.com/user/repos?per_page=100&visibility=${VISIBILITY}&affiliation=owner,collaborator,organization_member" - DISCOVER_OWNER_FILTER="$GITHUB_USER" -else - DISCOVER_ENDPOINT="https://api.github.com/users/${GITHUB_USER}/repos?per_page=100&type=all" -fi -DISCOVER_SOURCE_TYPE="user" -DISCOVER_SOURCE_VALUE="$GITHUB_USER" -else -DISCOVER_ENDPOINT="https://api.github.com/orgs/${GITHUB_ORG}/repos?per_page=100&type=all" -DISCOVER_SOURCE_TYPE="org" -DISCOVER_SOURCE_VALUE="$GITHUB_ORG" -fi + DISCOVER_OWNER_FILTER="" + + if [[ "$AUTHENTICATED_USER" == true ]]; then + DISCOVER_ENDPOINT="https://api.github.com/user/repos?per_page=100&visibility=${VISIBILITY}" + DISCOVER_SOURCE_TYPE="authenticated-user" + DISCOVER_SOURCE_VALUE="token-owner" + elif [[ -n "$GITHUB_USER" ]]; then + if [[ -n "$GITHUB_TOKEN" ]]; then + DISCOVER_ENDPOINT="https://api.github.com/user/repos?per_page=100&visibility=${VISIBILITY}&affiliation=owner,collaborator,organization_member" + DISCOVER_OWNER_FILTER="$GITHUB_USER" + else + DISCOVER_ENDPOINT="https://api.github.com/users/${GITHUB_USER}/repos?per_page=100&type=all" + fi + DISCOVER_SOURCE_TYPE="user" + DISCOVER_SOURCE_VALUE="$GITHUB_USER" + else + DISCOVER_ENDPOINT="https://api.github.com/orgs/${GITHUB_ORG}/repos?per_page=100&type=all" + DISCOVER_SOURCE_TYPE="org" + DISCOVER_SOURCE_VALUE="$GITHUB_ORG" + fi } discover_repositories() { -local pages_file filtered_file json_output - -set_discover_endpoint_and_source -pages_file="$(mktemp)" -filtered_file="$(mktemp)" -json_output="$(mktemp)" - -info "Discovering repositories from GitHub source: ${DISCOVER_SOURCE_TYPE}=${DISCOVER_SOURCE_VALUE}" -github_api_paged "$DISCOVER_ENDPOINT" > "$pages_file" - -jq -s \ ---argjson include_forks "$DISCOVER_INCLUDE_FORKS" \ ---argjson include_archived "$DISCOVER_INCLUDE_ARCHIVED" \ ---arg visibility "$VISIBILITY" \ ---arg owner_filter "$DISCOVER_OWNER_FILTER" \ ---arg name_regex "$NAME_REGEX" \ ---arg max_repos "${MAX_REPOS:-0}" ' + local pages_file filtered_file json_output + + set_discover_endpoint_and_source + pages_file="$(mktemp)" + filtered_file="$(mktemp)" + json_output="$(mktemp)" + + info "Discovering repositories from GitHub source: ${DISCOVER_SOURCE_TYPE}=${DISCOVER_SOURCE_VALUE}" + github_api_paged "$DISCOVER_ENDPOINT" > "$pages_file" + + jq -s \ + --argjson include_forks "$DISCOVER_INCLUDE_FORKS" \ + --argjson include_archived "$DISCOVER_INCLUDE_ARCHIVED" \ + --arg visibility "$VISIBILITY" \ + --arg owner_filter "$DISCOVER_OWNER_FILTER" \ + --arg name_regex "$NAME_REGEX" \ + --arg max_repos "${MAX_REPOS:-0}" ' add | map(select($owner_filter == "" or ((.owner.login // "") | ascii_downcase) == ($owner_filter | ascii_downcase))) | map(select($include_forks or (.fork | not))) @@ -1064,445 +1072,440 @@ jq -s \ }) ' "$pages_file" > "$filtered_file" -write_json_manifest "$filtered_file" "$json_output" "$DISCOVER_SOURCE_TYPE" "$DISCOVER_SOURCE_VALUE" "$INPUT_MANIFEST" + write_json_manifest "$filtered_file" "$json_output" "$DISCOVER_SOURCE_TYPE" "$DISCOVER_SOURCE_VALUE" "$INPUT_MANIFEST" -mkdir -p "$(dirname "$OUTPUT_FILE")" -if [[ "$OUTPUT_FORMAT" == "json" ]]; then -mv "$json_output" "$OUTPUT_FILE" -else -json_manifest_to_csv "$json_output" "$OUTPUT_FILE" -rm -f "$json_output" -fi + mkdir -p "$(dirname "$OUTPUT_FILE")" + if [[ "$OUTPUT_FORMAT" == "json" ]]; then + cat "$json_output" > "$OUTPUT_FILE" + else + json_manifest_to_csv "$json_output" "$OUTPUT_FILE" + fi + rm -f "$json_output" -info "Manifest written to $OUTPUT_FILE" -info "Repositories in manifest: $(jq '. | length' "$filtered_file")" + info "Manifest written to $OUTPUT_FILE" + info "Repositories in manifest: $(jq '. | length' "$filtered_file")" -rm -f "$pages_file" "$filtered_file" + rm -f "$pages_file" "$filtered_file" } ############################################################################### # Backup ############################################################################### parse_backup_args() { -while [[ $# -gt 0 ]]; do -case "$1" in - --manifest) MANIFEST="${2:-}"; shift 2 ;; - --dest) DEST_DIR="${2:-}"; shift 2 ;; - --config) CONFIG_FILE="${2:-}"; shift 2 ;; - --mode) BACKUP_MODE="${2:-}"; shift 2 ;; - --protocol) PROTOCOL="${2:-}"; shift 2 ;; - --token) GITHUB_TOKEN="${2:-}"; shift 2 ;; - --resume) RESUME=true; shift ;; - --checksum) CHECKSUM=true; shift ;; - --no-checksum) CHECKSUM=false; shift ;; - --verify-after-backup) VERIFY_AFTER_BACKUP=true; shift ;; - --sleep) SLEEP_BETWEEN_REPOS="${2:-}"; shift 2 ;; - --parallel) PARALLEL_JOBS="${2:-}"; shift 2 ;; - --non-interactive) NON_INTERACTIVE=true; shift ;; - --log-file) LOG_FILE="${2:-}"; shift 2 ;; - --lock-file) LOCK_FILE="${2:-}"; shift 2 ;; - --state-file) STATE_FILE="${2:-}"; shift 2 ;; - --rate-limit) RATE_LIMIT_MODE="${2:-}"; shift 2 ;; - --max-retries) MAX_RETRIES="${2:-}"; shift 2 ;; - --retry-delay) RETRY_DELAY="${2:-}"; shift 2 ;; - --retry-backoff) RETRY_BACKOFF="${2:-}"; shift 2 ;; - --quiet) QUIET=true; shift ;; - --verbose) VERBOSE=true; shift ;; - --no-color) NO_COLOR=true; shift ;; - -h|--help) print_backup_usage; exit 0 ;; - *) die "Unknown backup argument: $1" ;; -esac -done - -[[ -n "$MANIFEST" ]] || die "backup requires --manifest FILE." -[[ -n "$DEST_DIR" ]] || die "backup requires --dest DIR." -[[ -z "$BACKUP_MODE" || "$BACKUP_MODE" =~ ^(mirror|clone|archive|sparse)$ ]] || die "--mode must be mirror, clone, archive, or sparse." -[[ "$PROTOCOL" =~ ^(https|ssh)$ ]] || die "--protocol must be https or ssh." -case "$RATE_LIMIT_MODE" in -wait | fail) ;; -*) die "--rate-limit must be wait or fail." ;; -esac -is_positive_integer "$MAX_RETRIES" || die "--max-retries expects a positive integer." -is_non_negative_integer "$RETRY_DELAY" || die "--retry-delay expects a non-negative integer." -is_positive_integer "$RETRY_BACKOFF" || die "--retry-backoff expects a positive integer." -is_non_negative_integer "$SLEEP_BETWEEN_REPOS" || die "--sleep expects a non-negative integer." -PARALLEL_JOBS="${PARALLEL_JOBS:-$(cpu_count)}" -is_positive_integer "$PARALLEL_JOBS" || die "--parallel expects a positive integer." - -if (( PARALLEL_JOBS > 1 && SLEEP_BETWEEN_REPOS > 0 )); then -warn "--sleep delays job launches when backups run in parallel." -fi -if [[ "$NON_INTERACTIVE" == true ]]; then -debug "Running in non-interactive mode." -fi - -mkdir -p "$DEST_DIR" -STATE_FILE="${STATE_FILE:-${DEST_DIR}/.github-backup-state.json}" -LOCK_FILE="${LOCK_FILE:-${DEST_DIR}/.github-backup.lock}" + while [[ $# -gt 0 ]]; do + case "$1" in + --manifest) need_arg "$@"; MANIFEST="$2"; shift 2 ;; + --dest) need_arg "$@"; DEST_DIR="$2"; shift 2 ;; + --config) need_arg "$@"; CONFIG_FILE="$2"; shift 2 ;; + --mode) need_arg "$@"; BACKUP_MODE="$2"; shift 2 ;; + --protocol) need_arg "$@"; PROTOCOL="$2"; shift 2 ;; + --token) need_arg "$@"; GITHUB_TOKEN="$2"; shift 2 ;; + --resume) RESUME=true; shift ;; + --checksum) CHECKSUM=true; shift ;; + --no-checksum) CHECKSUM=false; shift ;; + --verify-after-backup) VERIFY_AFTER_BACKUP=true; shift ;; + --sleep) need_arg "$@"; SLEEP_BETWEEN_REPOS="$2"; shift 2 ;; + --parallel) need_arg "$@"; PARALLEL_JOBS="$2"; shift 2 ;; + --non-interactive) NON_INTERACTIVE=true; shift ;; + --log-file) need_arg "$@"; LOG_FILE="$2"; shift 2 ;; + --lock-file) need_arg "$@"; LOCK_FILE="$2"; shift 2 ;; + --state-file) need_arg "$@"; STATE_FILE="$2"; shift 2 ;; + --rate-limit) need_arg "$@"; RATE_LIMIT_MODE="$2"; shift 2 ;; + --max-retries) need_arg "$@"; MAX_RETRIES="$2"; shift 2 ;; + --retry-delay) need_arg "$@"; RETRY_DELAY="$2"; shift 2 ;; + --retry-backoff) need_arg "$@"; RETRY_BACKOFF="$2"; shift 2 ;; + --quiet) QUIET=true; shift ;; + --verbose) VERBOSE=true; shift ;; + --no-color) NO_COLOR=true; shift ;; + -h|--help) print_backup_usage; exit 0 ;; + *) die "Unknown backup argument: $1" ;; + esac + done + + [[ -n "$MANIFEST" ]] || die "backup requires --manifest FILE." + [[ -n "$DEST_DIR" ]] || die "backup requires --dest DIR." + [[ -z "$BACKUP_MODE" || "$BACKUP_MODE" =~ ^(mirror|clone|archive|sparse)$ ]] || die "--mode must be mirror, clone, archive, or sparse." + [[ "$PROTOCOL" =~ ^(https|ssh)$ ]] || die "--protocol must be https or ssh." + case "$RATE_LIMIT_MODE" in + wait | fail) ;; + *) die "--rate-limit must be wait or fail." ;; + esac + is_positive_integer "$MAX_RETRIES" || die "--max-retries expects a positive integer." + is_non_negative_integer "$RETRY_DELAY" || die "--retry-delay expects a non-negative integer." + is_positive_integer "$RETRY_BACKOFF" || die "--retry-backoff expects a positive integer." + is_non_negative_integer "$SLEEP_BETWEEN_REPOS" || die "--sleep expects a non-negative integer." + PARALLEL_JOBS="${PARALLEL_JOBS:-$(cpu_count)}" + is_positive_integer "$PARALLEL_JOBS" || die "--parallel expects a positive integer." + + if (( PARALLEL_JOBS > 1 && SLEEP_BETWEEN_REPOS > 0 )); then + warn "--sleep delays job launches when backups run in parallel." + fi + if [[ "$NON_INTERACTIVE" == true ]]; then + debug "Running in non-interactive mode." + fi + + mkdir -p "$DEST_DIR" + STATE_FILE="${STATE_FILE:-${DEST_DIR}/.github-backup-state.json}" + LOCK_FILE="${LOCK_FILE:-${DEST_DIR}/.github-backup.lock}" } acquire_lock() { -[[ -n "$LOCK_FILE" ]] || return 0 -mkdir -p "$(dirname "$LOCK_FILE")" -exec 9>"$LOCK_FILE" -flock -n 9 || die "Another backup appears to be running. Lock file: $LOCK_FILE" + [[ -n "$LOCK_FILE" ]] || return 0 + mkdir -p "$(dirname "$LOCK_FILE")" + exec 9>"$LOCK_FILE" + flock -n 9 || die "Another backup appears to be running. Lock file: $LOCK_FILE" } repo_clone_url() { -local repo_json="$1" -local clone_url ssh_url full_name - -clone_url="$(jq -r '.clone_url // empty' <<< "$repo_json")" -ssh_url="$(jq -r '.ssh_url // empty' <<< "$repo_json")" -full_name="$(jq -r '.full_name' <<< "$repo_json")" - -if [[ "$PROTOCOL" == "ssh" ]]; then -if [[ -n "$ssh_url" ]]; then - printf '%s' "$ssh_url" -else - printf 'git@github.com:%s.git' "$full_name" -fi -else -if [[ -n "$clone_url" ]]; then - printf '%s' "$clone_url" -else - printf 'https://github.com/%s.git' "$full_name" -fi -fi + local repo_json="$1" + local clone_url ssh_url full_name + + clone_url="$(jq -r '.clone_url // empty' <<< "$repo_json")" + ssh_url="$(jq -r '.ssh_url // empty' <<< "$repo_json")" + full_name="$(jq -r '.full_name' <<< "$repo_json")" + + if [[ "$PROTOCOL" == "ssh" ]]; then + if [[ -n "$ssh_url" ]]; then + printf '%s' "$ssh_url" + else + printf 'git@github.com:%s.git' "$full_name" + fi + else + if [[ -n "$clone_url" ]]; then + printf '%s' "$clone_url" + else + printf 'https://github.com/%s.git' "$full_name" + fi + fi } git_with_optional_auth() { -if [[ -n "$GITHUB_TOKEN" && "$PROTOCOL" == "https" ]]; then -GIT_TERMINAL_PROMPT=0 \ - GIT_CONFIG_COUNT=1 \ - GIT_CONFIG_KEY_0='http.https://github.com/.extraheader' \ - GIT_CONFIG_VALUE_0="AUTHORIZATION: bearer $GITHUB_TOKEN" \ - git "$@" -else -GIT_TERMINAL_PROMPT=0 git "$@" -fi + if [[ -n "$GITHUB_TOKEN" && "$PROTOCOL" == "https" ]]; then + GIT_TERMINAL_PROMPT=0 \ + GIT_CONFIG_COUNT=1 \ + GIT_CONFIG_KEY_0='http.https://github.com/.extraheader' \ + GIT_CONFIG_VALUE_0="AUTHORIZATION: bearer $GITHUB_TOKEN" \ + git "$@" + else + GIT_TERMINAL_PROMPT=0 git "$@" + fi } archive_url_for_repo() { -local full_name="$1" -local ref="$2" -printf 'https://api.github.com/repos/%s/tarball/%s' "$full_name" "$ref" + local full_name="$1" + local ref="$2" + printf 'https://api.github.com/repos/%s/tarball/%s' "$full_name" "$ref" } read_json_array_into() { -local repo_json="$1" -local field="$2" -local -n array_ref="$3" -# shellcheck disable=SC2034 # array_ref is a nameref output parameter. -mapfile -t array_ref < <(jq -r --arg field "$field" '.[$field][]? // empty' <<< "$repo_json") + local repo_json="$1" + local field="$2" + local -n array_ref="$3" + # shellcheck disable=SC2034 # array_ref is a nameref output parameter. + mapfile -t array_ref < <(jq -r --arg field "$field" '.[$field][]? // empty' <<< "$repo_json") } backup_mirror_repo() { -local repo_json="$1" -local full_name="$2" -local output_path="$3" -local url - -url="$(repo_clone_url "$repo_json")" -if [[ -d "$output_path" ]]; then -info "Updating mirror: $full_name" -git_with_optional_auth -C "$output_path" remote update --prune || return 1 -else -info "Creating mirror: $full_name" -git_with_optional_auth clone --mirror "$url" "$output_path" || return 1 -fi + local repo_json="$1" + local full_name="$2" + local output_path="$3" + local url + + url="$(repo_clone_url "$repo_json")" + if [[ -d "$output_path" ]]; then + info "Updating mirror: $full_name" + git_with_optional_auth -C "$output_path" remote update --prune || return 1 + else + info "Creating mirror: $full_name" + git_with_optional_auth clone --mirror "$url" "$output_path" || return 1 + fi } backup_clone_repo() { -local repo_json="$1" -local full_name="$2" -local output_path="$3" -local url - -url="$(repo_clone_url "$repo_json")" -if [[ -d "$output_path/.git" ]]; then -info "Updating clone: $full_name" -git_with_optional_auth -C "$output_path" fetch --all --prune --tags || return 1 -git_with_optional_auth -C "$output_path" pull --ff-only || warn "Pull failed for $full_name after fetch. Working tree may have local changes." -else -info "Creating clone: $full_name" -git_with_optional_auth clone "$url" "$output_path" || return 1 -fi + local repo_json="$1" + local full_name="$2" + local output_path="$3" + local url + + url="$(repo_clone_url "$repo_json")" + if [[ -d "$output_path/.git" ]]; then + info "Updating clone: $full_name" + git_with_optional_auth -C "$output_path" fetch --all --prune --tags || return 1 + git_with_optional_auth -C "$output_path" pull --ff-only || warn "Pull failed for $full_name after fetch. Working tree may have local changes." + else + info "Creating clone: $full_name" + git_with_optional_auth clone "$url" "$output_path" || return 1 + fi } backup_sparse_repo() { -local repo_json="$1" -local full_name="$2" -local output_path="$3" -local url default_branch -local paths=() - -read_json_array_into "$repo_json" "paths" paths -if (( ${#paths[@]} == 0 )); then -warn "Sparse mode requested for $full_name without paths. Falling back to clone mode." -backup_clone_repo "$repo_json" "$full_name" "$output_path" -return -fi - -url="$(repo_clone_url "$repo_json")" -default_branch="$(jq -r '.default_branch // "main"' <<< "$repo_json")" - -if [[ -d "$output_path/.git" ]]; then -info "Updating sparse clone: $full_name" -git_with_optional_auth -C "$output_path" sparse-checkout set "${paths[@]}" || return 1 -git_with_optional_auth -C "$output_path" fetch --all --prune --tags || return 1 -git_with_optional_auth -C "$output_path" checkout "$default_branch" || true -git_with_optional_auth -C "$output_path" pull --ff-only || warn "Pull failed for sparse clone $full_name." -else -info "Creating sparse clone: $full_name" -git_with_optional_auth clone --filter=blob:none --sparse "$url" "$output_path" || return 1 -git_with_optional_auth -C "$output_path" sparse-checkout set "${paths[@]}" || return 1 -fi -} - -backup_archive_repo() { -local repo_json="$1" -local full_name="$2" -local output_path="$3" -local default_branch url tmp_archive tmp_dir extract_dir root_dir -local paths=() -local exclude_paths=() -local tar_excludes=() + local repo_json="$1" + local full_name="$2" + local output_path="$3" + local url default_branch + local paths=() + + read_json_array_into "$repo_json" "paths" paths + if (( ${#paths[@]} == 0 )); then + warn "Sparse mode requested for $full_name without paths. Falling back to clone mode." + backup_clone_repo "$repo_json" "$full_name" "$output_path" + return + fi -default_branch="$(jq -r '.default_branch // "main"' <<< "$repo_json")" -url="$(archive_url_for_repo "$full_name" "$default_branch")" -tmp_archive="$(mktemp)" + url="$(repo_clone_url "$repo_json")" + default_branch="$(jq -r '.default_branch // "main"' <<< "$repo_json")" -info "Downloading archive: $full_name@$default_branch" -download_with_retries "$url" "$tmp_archive" || { -rm -f "$tmp_archive" -return 1 + if [[ -d "$output_path/.git" ]]; then + info "Updating sparse clone: $full_name" + git_with_optional_auth -C "$output_path" sparse-checkout set "${paths[@]}" || return 1 + git_with_optional_auth -C "$output_path" fetch --all --prune --tags || return 1 + git_with_optional_auth -C "$output_path" checkout "$default_branch" || true + git_with_optional_auth -C "$output_path" pull --ff-only || warn "Pull failed for sparse clone $full_name." + else + info "Creating sparse clone: $full_name" + git_with_optional_auth clone --filter=blob:none --sparse "$url" "$output_path" || return 1 + git_with_optional_auth -C "$output_path" sparse-checkout set "${paths[@]}" || return 1 + fi } -read_json_array_into "$repo_json" "paths" paths -read_json_array_into "$repo_json" "exclude_paths" exclude_paths +backup_archive_repo() { + local repo_json="$1" + local full_name="$2" + local output_path="$3" + local default_branch url tmp_archive tmp_dir extract_dir root_dir excluded + local paths=() + local exclude_paths=() + local tar_excludes=() + + default_branch="$(jq -r '.default_branch // "main"' <<< "$repo_json")" + url="$(archive_url_for_repo "$full_name" "$default_branch")" + tmp_archive="$(mktemp)" + + info "Downloading archive: $full_name@$default_branch" + download_with_retries "$url" "$tmp_archive" || { + rm -f "$tmp_archive" + return 1 + } + + read_json_array_into "$repo_json" "paths" paths + read_json_array_into "$repo_json" "exclude_paths" exclude_paths -mkdir -p "$(dirname "$output_path")" -if (( ${#paths[@]} == 0 && ${#exclude_paths[@]} == 0 )); then -mv "$tmp_archive" "$output_path" || return 1 -return 0 -fi + mkdir -p "$(dirname "$output_path")" + if (( ${#paths[@]} == 0 && ${#exclude_paths[@]} == 0 )); then + mv "$tmp_archive" "$output_path" || return 1 + return 0 + fi -tmp_dir="$(mktemp -d)" -extract_dir="$tmp_dir/extract" -mkdir -p "$extract_dir" -tar -xzf "$tmp_archive" -C "$extract_dir" || { -rm -rf "$tmp_dir" "$tmp_archive" -return 1 -} -root_dir="$(find "$extract_dir" -mindepth 1 -maxdepth 1 -type d | head -n 1)" -[[ -n "$root_dir" ]] || { -rm -rf "$tmp_dir" "$tmp_archive" -return 1 -} + tmp_dir="$(mktemp -d)" + extract_dir="$tmp_dir/extract" + mkdir -p "$extract_dir" + tar -xzf "$tmp_archive" -C "$extract_dir" || { + rm -rf "$tmp_dir" "$tmp_archive" + return 1 + } + root_dir="$(find "$extract_dir" -mindepth 1 -maxdepth 1 -type d | head -n 1)" + [[ -n "$root_dir" ]] || { + rm -rf "$tmp_dir" "$tmp_archive" + return 1 + } -for excluded in "${exclude_paths[@]}"; do -tar_excludes+=(--exclude="$excluded") -done + for excluded in "${exclude_paths[@]}"; do + tar_excludes+=(--exclude="$excluded") + done -if (( ${#paths[@]} == 0 )); then -paths=(".") -fi + if (( ${#paths[@]} == 0 )); then + paths=(".") + fi -info "Creating filtered archive for $full_name" -tar -czf "${output_path}.tmp" -C "$root_dir" "${tar_excludes[@]}" "${paths[@]}" || { -rm -rf "$tmp_dir" "$tmp_archive" -return 1 -} -mv "${output_path}.tmp" "$output_path" || { -rm -rf "$tmp_dir" "$tmp_archive" -return 1 -} -rm -rf "$tmp_dir" "$tmp_archive" + info "Creating filtered archive for $full_name" + tar -czf "${output_path}.tmp" -C "$root_dir" "${tar_excludes[@]}" "${paths[@]}" || { + rm -rf "$tmp_dir" "$tmp_archive" "${output_path}.tmp" + return 1 + } + mv "${output_path}.tmp" "$output_path" || { + rm -rf "$tmp_dir" "$tmp_archive" + return 1 + } + rm -rf "$tmp_dir" "$tmp_archive" } verify_backup_output() { -local mode="$1" -local output_path="$2" - -[[ "$VERIFY_AFTER_BACKUP" == true ]] || return 0 - -case "$mode" in -mirror) - git -C "$output_path" rev-parse --is-bare-repository >/dev/null - ;; -clone|sparse) - git -C "$output_path" rev-parse --is-inside-work-tree >/dev/null - ;; -archive) - tar -tzf "$output_path" >/dev/null - ;; -esac + local mode="$1" + local output_path="$2" + + [[ "$VERIFY_AFTER_BACKUP" == true ]] || return 0 + + case "$mode" in + mirror) + [[ "$(git -C "$output_path" rev-parse --is-bare-repository)" == true ]] + ;; + clone|sparse) + [[ "$(git -C "$output_path" rev-parse --is-inside-work-tree)" == true ]] + ;; + archive) + tar -tzf "$output_path" >/dev/null + ;; + esac } write_archive_checksum() { -local output_path="$1" -local checksum_file="$RUN_DIR/checksums.sha256" -local lock_file="${checksum_file}.lock" + local output_path="$1" + local checksum_file="$RUN_DIR/checksums.sha256" + local lock_file="${checksum_file}.lock" -[[ "$CHECKSUM" == true ]] || return 0 -( -flock 8 -sha256sum "$output_path" >> "$checksum_file" -) 8>"$lock_file" + [[ "$CHECKSUM" == true ]] || return 0 + ( + flock 8 + sha256sum "$output_path" >> "$checksum_file" + ) 8>"$lock_file" } log_mirror_progress() { -local full_name="$1" -local count -local lock_file="${MIRROR_PROGRESS_FILE}.lock" + local full_name="$1" + local count + local lock_file="${MIRROR_PROGRESS_FILE}.lock" -(( MIRROR_TOTAL > 0 )) || return 0 -[[ -n "$MIRROR_PROGRESS_FILE" ]] || return 0 + (( MIRROR_TOTAL > 0 )) || return 0 + [[ -n "$MIRROR_PROGRESS_FILE" ]] || return 0 -( -flock 8 -count="$(cat "$MIRROR_PROGRESS_FILE" 2>/dev/null || printf '0')" -count=$((count + 1)) -printf '%s\n' "$count" > "$MIRROR_PROGRESS_FILE" -info "finished ${count}/${MIRROR_TOTAL}: $full_name" -) 8>"$lock_file" + ( + flock 8 + count="$(cat "$MIRROR_PROGRESS_FILE" 2>/dev/null || printf '0')" + count=$((count + 1)) + printf '%s\n' "$count" > "$MIRROR_PROGRESS_FILE" + info "finished ${count}/${MIRROR_TOTAL}: $full_name" + ) 8>"$lock_file" } backup_repo_once() { -local repo_json="$1" -local full_name mode safe output_path - -full_name="$(jq -r '.full_name' <<< "$repo_json")" -safe="$(safe_repo_name "$full_name")" -mode="${BACKUP_MODE:-$(jq -r '.mode // "mirror"' <<< "$repo_json")}" - -case "$mode" in -mirror) output_path="$RUN_DIR/repos/${safe}.git" ;; -clone|sparse) output_path="$RUN_DIR/repos/${safe}" ;; -archive) output_path="$RUN_DIR/archives/${safe}.tar.gz" ;; -*) die "Invalid backup mode for $full_name: $mode" ;; -esac + local repo_json="$1" + local full_name mode safe output_path + + full_name="$(jq -r '.full_name' <<< "$repo_json")" + safe="$(safe_repo_name "$full_name")" + mode="${BACKUP_MODE:-$(jq -r '.mode // "mirror"' <<< "$repo_json")}" + + case "$mode" in + mirror) output_path="$RUN_DIR/repos/${safe}.git" ;; + clone|sparse) output_path="$RUN_DIR/repos/${safe}" ;; + archive) output_path="$RUN_DIR/archives/${safe}.tar.gz" ;; + *) die "Invalid backup mode for $full_name: $mode" ;; + esac -mkdir -p "$RUN_DIR/repos" "$RUN_DIR/archives" + mkdir -p "$RUN_DIR/repos" "$RUN_DIR/archives" -case "$mode" in -mirror) backup_mirror_repo "$repo_json" "$full_name" "$output_path" ;; -clone) backup_clone_repo "$repo_json" "$full_name" "$output_path" ;; -sparse) backup_sparse_repo "$repo_json" "$full_name" "$output_path" ;; -archive) backup_archive_repo "$repo_json" "$full_name" "$output_path" ;; -esac + case "$mode" in + mirror) backup_mirror_repo "$repo_json" "$full_name" "$output_path" >&2 || return 1 ;; + clone) backup_clone_repo "$repo_json" "$full_name" "$output_path" >&2 || return 1 ;; + sparse) backup_sparse_repo "$repo_json" "$full_name" "$output_path" >&2 || return 1 ;; + archive) backup_archive_repo "$repo_json" "$full_name" "$output_path" >&2 || return 1 ;; + esac -verify_backup_output "$mode" "$output_path" || return 1 -if [[ "$mode" == "archive" ]]; then -write_archive_checksum "$output_path" || return 1 -fi + verify_backup_output "$mode" "$output_path" >&2 || return 1 + if [[ "$mode" == "archive" ]]; then + write_archive_checksum "$output_path" || return 1 + fi -printf '%s' "$output_path" + printf '%s' "$output_path" } backup_repo_with_retries() { -local repo_json="$1" -local full_name mode key status output_path -local attempt=1 -local delay="$RETRY_DELAY" - -full_name="$(jq -r '.full_name' <<< "$repo_json")" -mode="${BACKUP_MODE:-$(jq -r '.mode // "mirror"' <<< "$repo_json")}" -key="$(safe_repo_name "$full_name")" -status="$(state_repo_status "$key")" - -if [[ "$RESUME" == true && "$status" == "done" ]]; then -info "Skipping completed repository from state: $full_name" -if [[ "$mode" == "mirror" ]]; then - log_mirror_progress "$full_name" -fi -return 0 -fi - -while true; do -update_state_repo "$key" "running" "$full_name" "$mode" "" "" -if output_path="$(backup_repo_once "$repo_json")"; then - update_state_repo "$key" "done" "$full_name" "$mode" "$output_path" "" - info "Completed $full_name -> $output_path" - if [[ "$mode" == "mirror" ]]; then - log_mirror_progress "$full_name" + local repo_json="$1" + local full_name mode key status output_path + local attempt=1 + local delay="$RETRY_DELAY" + + full_name="$(jq -r '.full_name' <<< "$repo_json")" + mode="${BACKUP_MODE:-$(jq -r '.mode // "mirror"' <<< "$repo_json")}" + key="$(safe_repo_name "$full_name")" + status="$(state_repo_status "$key")" + + if [[ "$RESUME" == true && "$status" == "done" ]]; then + info "Skipping completed repository from state: $full_name" + if [[ "$mode" == "mirror" ]]; then + log_mirror_progress "$full_name" + fi + return 0 fi - return 0 -fi -if (( attempt > MAX_RETRIES )); then - update_state_repo "$key" "failed" "$full_name" "$mode" "" "backup failed after retries" - error "Backup failed for $full_name after $MAX_RETRIES retries." - return 1 -fi + while true; do + update_state_repo "$key" "running" "$full_name" "$mode" "" "" + if output_path="$(backup_repo_once "$repo_json")"; then + update_state_repo "$key" "done" "$full_name" "$mode" "$output_path" "" + info "Completed $full_name -> $output_path" + if [[ "$mode" == "mirror" ]]; then + log_mirror_progress "$full_name" + fi + return 0 + fi + + if (( attempt > MAX_RETRIES )); then + update_state_repo "$key" "failed" "$full_name" "$mode" "" "backup failed after retries" + error "Backup failed for $full_name after $MAX_RETRIES retries." + return 1 + fi -warn "Backup failed for $full_name. Retry ${attempt}/${MAX_RETRIES} in ${delay}s." -update_state_repo "$key" "failed" "$full_name" "$mode" "" "retry pending" -countdown_sleep "$delay" -delay=$(( delay * RETRY_BACKOFF )) -((attempt++)) -done + warn "Backup failed for $full_name. Retry ${attempt}/${MAX_RETRIES} in ${delay}s." + update_state_repo "$key" "failed" "$full_name" "$mode" "" "retry pending" + countdown_sleep "$delay" + delay=$(( delay * RETRY_BACKOFF )) + ((attempt++)) + done } BACKUP_FAILURES=0 run_backup_jobs() { -local repos_file="$1" -local repo_json running=0 + local repos_file="$1" + local repo_json running=0 -while IFS= read -r repo_json; do -[[ -z "$repo_json" ]] && continue + while IFS= read -r repo_json; do + [[ -z "$repo_json" ]] && continue -( - if backup_repo_with_retries "$repo_json"; then - exit 0 - fi - exit 1 -) & -running=$((running + 1)) + backup_repo_with_retries "$repo_json" & + running=$((running + 1)) -if (( SLEEP_BETWEEN_REPOS > 0 )); then - countdown_sleep "$SLEEP_BETWEEN_REPOS" -fi + if (( SLEEP_BETWEEN_REPOS > 0 )); then + countdown_sleep "$SLEEP_BETWEEN_REPOS" + fi -if (( running >= PARALLEL_JOBS )); then - if ! wait -n; then - BACKUP_FAILURES=$((BACKUP_FAILURES + 1)) - fi - running=$((running - 1)) -fi -done < "$repos_file" + if (( running >= PARALLEL_JOBS )); then + if ! wait -n; then + BACKUP_FAILURES=$((BACKUP_FAILURES + 1)) + fi + running=$((running - 1)) + fi + done < "$repos_file" -while (( running > 0 )); do -if ! wait -n; then - BACKUP_FAILURES=$((BACKUP_FAILURES + 1)) -fi -running=$((running - 1)) -done + while (( running > 0 )); do + if ! wait -n; then + BACKUP_FAILURES=$((BACKUP_FAILURES + 1)) + fi + running=$((running - 1)) + done } run_backup() { -local manifest_json checksum repo_count enabled_count -local repos_file - -manifest_json="$(mktemp)" -manifest_to_json "$MANIFEST" > "$manifest_json" -validate_manifest_file "$manifest_json" - -checksum="$(manifest_checksum "$manifest_json")" -mkdir -p "$DEST_DIR" -init_state_file "$manifest_json" "$checksum" -mkdir -p "$RUN_DIR/repos" "$RUN_DIR/archives" - -info "Backup run directory: $RUN_DIR" -info "State file: $STATE_FILE" - -repos_file="$(mktemp)" -jq -c '.repositories[] | select(.enabled == true)' "$manifest_json" > "$repos_file" -repo_count="$(jq '.repositories | length' "$manifest_json")" -enabled_count="$(wc -l < "$repos_file" | tr -d ' ')" -MIRROR_TOTAL="$(jq --arg override "$BACKUP_MODE" ' + local manifest_json checksum repo_count enabled_count + local repos_file + + manifest_json="$(mktemp)" + manifest_to_json "$MANIFEST" > "$manifest_json" + validate_manifest_file "$manifest_json" "$MANIFEST" + + checksum="$(manifest_checksum "$manifest_json")" + mkdir -p "$DEST_DIR" + init_state_file "$manifest_json" "$checksum" + mkdir -p "$RUN_DIR/repos" "$RUN_DIR/archives" + + info "Backup run directory: $RUN_DIR" + info "State file: $STATE_FILE" + + repos_file="$(mktemp)" + jq -c '.repositories[] | select(.enabled == true)' "$manifest_json" > "$repos_file" + repo_count="$(jq '.repositories | length' "$manifest_json")" + enabled_count="$(wc -l < "$repos_file" | tr -d ' ')" + MIRROR_TOTAL="$(jq --arg override "$BACKUP_MODE" ' [ .repositories[] | select(.enabled == true) @@ -1510,131 +1513,138 @@ MIRROR_TOTAL="$(jq --arg override "$BACKUP_MODE" ' ] | length ' "$manifest_json")" -MIRROR_PROGRESS_FILE="$RUN_DIR/.mirror-progress" -printf '0\n' > "$MIRROR_PROGRESS_FILE" -info "Manifest repositories: $repo_count total, $enabled_count enabled." -info "Parallel backup jobs: $PARALLEL_JOBS" + MIRROR_PROGRESS_FILE="$RUN_DIR/.mirror-progress" + printf '0\n' > "$MIRROR_PROGRESS_FILE" + info "Manifest repositories: $repo_count total, $enabled_count enabled." + info "Parallel backup jobs: $PARALLEL_JOBS" -BACKUP_FAILURES=0 -run_backup_jobs "$repos_file" + BACKUP_FAILURES=0 + run_backup_jobs "$repos_file" -write_summary -rm -f "$manifest_json" "$repos_file" + write_summary + rm -f "$manifest_json" "$repos_file" -if (( BACKUP_FAILURES > 0 )); then -die "$BACKUP_FAILURES repository backup(s) failed. Re-run with --resume after fixing the issue." -fi + if (( BACKUP_FAILURES > 0 )); then + die "$BACKUP_FAILURES repository backup(s) failed. Re-run with --resume after fixing the issue." + fi -info "Backup completed successfully." + info "Backup completed successfully." } ############################################################################### # Convert and validate commands ############################################################################### parse_validate_args() { -while [[ $# -gt 0 ]]; do -case "$1" in - --manifest) MANIFEST="${2:-}"; shift 2 ;; - --log-file) LOG_FILE="${2:-}"; shift 2 ;; - --quiet) QUIET=true; shift ;; - --verbose) VERBOSE=true; shift ;; - --no-color) NO_COLOR=true; shift ;; - -h|--help) - printf 'Usage: %s validate-manifest --manifest FILE\n' "$SCRIPT_NAME" - exit 0 - ;; - *) die "Unknown validate-manifest argument: $1" ;; -esac -done -[[ -n "$MANIFEST" ]] || die "validate-manifest requires --manifest FILE." + while [[ $# -gt 0 ]]; do + case "$1" in + --manifest) need_arg "$@"; MANIFEST="$2"; shift 2 ;; + --log-file) need_arg "$@"; LOG_FILE="$2"; shift 2 ;; + --quiet) QUIET=true; shift ;; + --verbose) VERBOSE=true; shift ;; + --no-color) NO_COLOR=true; shift ;; + -h|--help) + printf 'Usage: %s validate-manifest --manifest FILE\n' "$SCRIPT_NAME" + exit 0 + ;; + *) die "Unknown validate-manifest argument: $1" ;; + esac + done + [[ -n "$MANIFEST" ]] || die "validate-manifest requires --manifest FILE." } parse_convert_args() { -while [[ $# -gt 0 ]]; do -case "$1" in - --manifest) MANIFEST="${2:-}"; shift 2 ;; - --output) OUTPUT_FILE="${2:-}"; shift 2 ;; - --format) OUTPUT_FORMAT="${2:-}"; shift 2 ;; - --log-file) LOG_FILE="${2:-}"; shift 2 ;; - --quiet) QUIET=true; shift ;; - --verbose) VERBOSE=true; shift ;; - --no-color) NO_COLOR=true; shift ;; - -h|--help) - printf 'Usage: %s convert-manifest --manifest FILE --output FILE --format json|csv\n' "$SCRIPT_NAME" - exit 0 - ;; - *) die "Unknown convert-manifest argument: $1" ;; -esac -done -[[ -n "$MANIFEST" ]] || die "convert-manifest requires --manifest FILE." -[[ -n "$OUTPUT_FILE" ]] || die "convert-manifest requires --output FILE." -case "$OUTPUT_FORMAT" in -json | csv) ;; -*) die "--format must be json or csv." ;; -esac + while [[ $# -gt 0 ]]; do + case "$1" in + --manifest) need_arg "$@"; MANIFEST="$2"; shift 2 ;; + --output) need_arg "$@"; OUTPUT_FILE="$2"; shift 2 ;; + --format) need_arg "$@"; OUTPUT_FORMAT="$2"; shift 2 ;; + --log-file) need_arg "$@"; LOG_FILE="$2"; shift 2 ;; + --quiet) QUIET=true; shift ;; + --verbose) VERBOSE=true; shift ;; + --no-color) NO_COLOR=true; shift ;; + -h|--help) + printf 'Usage: %s convert-manifest --manifest FILE --output FILE --format json|csv\n' "$SCRIPT_NAME" + exit 0 + ;; + *) die "Unknown convert-manifest argument: $1" ;; + esac + done + [[ -n "$MANIFEST" ]] || die "convert-manifest requires --manifest FILE." + [[ -n "$OUTPUT_FILE" ]] || die "convert-manifest requires --output FILE." + case "$OUTPUT_FORMAT" in + json | csv) ;; + *) die "--format must be json or csv." ;; + esac } run_convert_manifest() { -local tmp -tmp="$(mktemp)" -manifest_to_json "$MANIFEST" > "$tmp" -mkdir -p "$(dirname "$OUTPUT_FILE")" -if [[ "$OUTPUT_FORMAT" == "json" ]]; then -jq '.' "$tmp" > "$OUTPUT_FILE" -else -json_manifest_to_csv "$tmp" "$OUTPUT_FILE" -fi -rm -f "$tmp" -info "Converted manifest written to $OUTPUT_FILE" + local tmp + tmp="$(mktemp)" + manifest_to_json "$MANIFEST" > "$tmp" + mkdir -p "$(dirname "$OUTPUT_FILE")" + if [[ "$OUTPUT_FORMAT" == "json" ]]; then + jq '.' "$tmp" > "$OUTPUT_FILE" + else + json_manifest_to_csv "$tmp" "$OUTPUT_FILE" + fi + rm -f "$tmp" + info "Converted manifest written to $OUTPUT_FILE" } ############################################################################### # Main ############################################################################### main() { -COMMAND="${1:-}" -[[ -n "$COMMAND" ]] || { -print_usage -exit 1 -} - -case "$COMMAND" in -discover|backup|validate-manifest|convert-manifest|-h|--help|help) shift || true ;; ---*) COMMAND="discover" ;; -*) shift || true ;; -esac - -case "$COMMAND" in -discover) - maybe_load_config "$COMMAND" "$@" - parse_discover_args "$@" - check_common_dependencies - discover_repositories - ;; -backup) - maybe_load_config "$COMMAND" "$@" - parse_backup_args "$@" - check_backup_dependencies - acquire_lock - run_backup - ;; -validate-manifest) - parse_validate_args "$@" - require_command jq - validate_manifest_file "$MANIFEST" - ;; -convert-manifest) - parse_convert_args "$@" - require_command jq - run_convert_manifest - ;; --h|--help|help) - print_usage - ;; -*) - die "Unknown command: $COMMAND" - ;; -esac + # Keep every mktemp file in one private directory that is removed on exit, + # so temp files do not leak when a step dies part-way through. + WORK_TMPDIR="$(mktemp -d)" + trap 'rm -rf "$WORK_TMPDIR"' EXIT + trap 'exit 130' INT TERM + export TMPDIR="$WORK_TMPDIR" + + COMMAND="${1:-}" + [[ -n "$COMMAND" ]] || { + print_usage >&2 + exit 1 + } + + case "$COMMAND" in + discover|backup|validate-manifest|convert-manifest|-h|--help|help) shift || true ;; + --*) COMMAND="discover" ;; + *) shift || true ;; + esac + + case "$COMMAND" in + discover) + maybe_load_config "$COMMAND" "$@" + parse_discover_args "$@" + check_common_dependencies + discover_repositories + ;; + backup) + maybe_load_config "$COMMAND" "$@" + parse_backup_args "$@" + check_backup_dependencies + acquire_lock + run_backup + ;; + validate-manifest) + parse_validate_args "$@" + require_command jq + validate_manifest_file "$MANIFEST" + ;; + convert-manifest) + parse_convert_args "$@" + require_command jq + run_convert_manifest + ;; + -h|--help|help) + print_usage + ;; + *) + die "Unknown command: $COMMAND" + ;; + esac } main "$@" diff --git a/src/empty_trash.sh b/src/empty_trash.sh index 86c8d05..200bba5 100755 --- a/src/empty_trash.sh +++ b/src/empty_trash.sh @@ -8,7 +8,7 @@ # -l, --log-file FILE Enable logging to a specified log file. # -v, --verbose Enable verbose mode. # -f, --force Force deletion without confirmation. -# -s, --simulate Simulate deletion (dry-run). +# -s, --simulate Simulate deletion (dry-run, no confirmation needed). # -u, --user USER Empty trash for specified user(s). Requires root privileges. # -a, --all-users Empty trash for all users. Requires root privileges. # --no-preserve-root Allow deleting root directory (dangerous). @@ -40,7 +40,7 @@ print_usage() { echo " -l, --log-file FILE Enable logging to a specified log file." echo " -v, --verbose Enable verbose mode." echo " -f, --force Force deletion without confirmation." - echo " -s, --simulate Simulate deletion (dry-run)." + echo " -s, --simulate Simulate deletion (dry-run, no confirmation needed)." echo " -u, --user USER Empty trash for specified user(s). Requires root privileges." echo " -a, --all-users Empty trash for all users. Requires root privileges." echo " --no-preserve-root Allow deleting root directory (dangerous)." @@ -62,38 +62,51 @@ log_action() { fi } +# Print an error message and exit +die() { + echo "Error: $*" >&2 + exit 1 +} + # Function to confirm deletion confirm_deletion() { - if [[ "$FORCE" == true ]]; then + if [[ "$FORCE" == true || "$SIMULATE" == true ]]; then return 0 fi - read -p "Are you sure you want to empty the trash? [y/N] " -n 1 -r + printf 'Trash directories to empty:\n' >&2 + printf ' %s\n' "${TRASH_PATHS[@]}" >&2 + read -p "Are you sure you want to empty the trash? [y/N] " -n 1 -r || return 1 echo - if [[ "$REPLY" =~ ^[Yy]$ ]]; then - return 0 - else - return 1 - fi + [[ "$REPLY" =~ ^[Yy]$ ]] } # Function to get trash directories for a user get_trash_paths_for_user() { local user="$1" - local home_dir - home_dir=$(eval echo "~$user") - local trash_paths=() - - # Detect OS and set trash path accordingly - if [[ "$(uname)" == "Darwin" ]]; then - trash_paths+=("$home_dir/.Trash") - elif [[ "$(uname)" == "Linux" ]]; then - trash_paths+=("$home_dir/.local/share/Trash/files") - else - echo "Unsupported system. Please specify the trash path." - exit 1 + local home_dir="" + + # Look up the home directory without eval (user names are untrusted input) + if command -v getent >/dev/null 2>&1; then + home_dir=$(getent passwd "$user" | cut -d: -f6) + elif [[ "$(uname)" == "Darwin" ]]; then + home_dir=$(dscl . -read "/Users/$user" NFSHomeDirectory 2>/dev/null | awk '{print $2}') + fi + if [[ -z "$home_dir" ]]; then + echo "Warning: cannot determine home directory of user '$user'; skipping." >&2 + return 0 fi - echo "${trash_paths[@]}" + # Print one path per line (the caller reads them with mapfile) + case "$(uname)" in + Darwin) + echo "$home_dir/.Trash" + ;; + Linux) + # FreeDesktop trash: deleted files plus their .trashinfo metadata + echo "$home_dir/.local/share/Trash/files" + echo "$home_dir/.local/share/Trash/info" + ;; + esac } # Function to empty trash directories @@ -111,18 +124,23 @@ empty_trash() { continue fi - if [[ "$path" == "/" ]] && [[ "$NO_PRESERVE_ROOT" != true ]]; then + # Resolve symlinks and things like '//' or '/.' before the root check + local resolved + resolved=$(cd "$path" && pwd -P) || continue + if [[ "$resolved" == "/" ]] && [[ "$NO_PRESERVE_ROOT" != true ]]; then log_action "Refusing to delete '/' without --no-preserve-root option." continue fi if [[ "$SIMULATE" == true ]]; then log_action "Simulating emptying trash at '$path'." - find "$path" -mindepth 1 -print + find "$path" -mindepth 1 -print || true else log_action "Emptying trash at '$path'." local deleted_files - deleted_files=$(find "$path" -mindepth 1 -print -delete | wc -l) + # find -delete exits non-zero if some items could not be removed; + # report that, but keep going with the other trash directories. + deleted_files=$( { find "$path" -mindepth 1 -print -delete || echo "Warning: some items in '$path' could not be deleted." >&2; } | wc -l) total_deleted=$((total_deleted + deleted_files)) log_action "Deleted $deleted_files items from '$path'." fi @@ -139,22 +157,20 @@ empty_trash() { while [[ $# -gt 0 ]]; do case "$1" in -p|--path) - if [[ -n "$2" ]]; then + if [[ -n "${2-}" ]]; then TRASH_PATHS+=("$2") shift 2 else - echo "Error: '--path' requires a non-empty argument." - exit 1 + die "'--path' requires a non-empty argument." fi ;; -l|--log-file) - if [[ -n "$2" ]]; then + if [[ -n "${2-}" ]]; then LOG_FILE="$2" LOG_ENABLED=true shift 2 else - echo "Error: '--log-file' requires a non-empty argument." - exit 1 + die "'--log-file' requires a non-empty argument." fi ;; -v|--verbose) @@ -170,12 +186,11 @@ while [[ $# -gt 0 ]]; do shift ;; -u|--user) - if [[ -n "$2" ]]; then + if [[ -n "${2-}" ]]; then USERS+=("$2") shift 2 else - echo "Error: '--user' requires a non-empty argument." - exit 1 + die "'--user' requires a non-empty argument." fi ;; -a|--all-users) @@ -195,22 +210,26 @@ while [[ $# -gt 0 ]]; do break ;; -*) - echo "Unknown option: $1" - print_usage + echo "Unknown option: $1" >&2 + print_usage >&2 exit 1 ;; *) - # No more options, break break ;; esac done +if [[ $# -gt 0 ]]; then + echo "Unexpected argument: $1 (use -p to specify a trash path)" >&2 + print_usage >&2 + exit 1 +fi + # Verify root privileges if necessary if [[ "$ALL_USERS" == true ]] || [[ "${#USERS[@]}" -gt 0 ]]; then if [[ "$EUID" -ne 0 ]]; then - echo "This option requires root privileges. Please run as root." - exit 1 + die "This option requires root privileges. Please run as root." fi fi @@ -221,7 +240,7 @@ if [[ "${#TRASH_PATHS[@]}" -eq 0 ]]; then mapfile -t USERS < <(awk -F: '{ if ($3 >= 1000 && $3 != 65534) print $1}' /etc/passwd) elif [[ "${#USERS[@]}" -eq 0 ]]; then # Default to current user - USERS+=("$USER") + USERS+=("$(id -un)") fi for user in "${USERS[@]}"; do @@ -231,8 +250,7 @@ if [[ "${#TRASH_PATHS[@]}" -eq 0 ]]; then fi if [[ "${#TRASH_PATHS[@]}" -eq 0 ]]; then - echo "No trash paths specified and none found for users." - exit 1 + die "No trash paths specified and none found for users." fi # Confirm deletion diff --git a/src/extend_video_height.sh b/src/extend_video_height.sh index ab47337..fbce858 100755 --- a/src/extend_video_height.sh +++ b/src/extend_video_height.sh @@ -1,35 +1,54 @@ #!/usr/bin/env bash # Script Name: extend_video_height.sh -# Description: Extends the height of a video by adding black padding using ffmpeg. +# Description: Extends the height of a video by adding black padding (top and bottom) +# using ffmpeg. The new height is rounded up to an even number. # Usage: ./extend_video_height.sh input_video output_video scale_factor +# scale_factor - number >= 1 (e.g. 1.1 makes the video 10% taller) # Example: ./extend_video_height.sh input.mp4 output.mp4 1.1 # Check for correct number of arguments if [[ $# -ne 3 ]]; then - echo "Usage: $0 input_video output_video scale_factor" - echo "Example: $0 input.mp4 output.mp4 1.1" + echo "Usage: $0 input_video output_video scale_factor" >&2 + echo "Example: $0 input.mp4 output.mp4 1.1" >&2 exit 1 fi +for cmd in ffmpeg ffprobe bc; do + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "Error: '$cmd' is required but not installed." >&2 + exit 1 + fi +done + # Assign arguments to variables input_video="$1" output_video="$2" scale_factor="$3" -# Calculate the new height and width -eval "$(ffprobe -v error -of flat=s=_ -select_streams v:0 -show_entries stream=height,width "$input_video")" +if [[ ! -f "$input_video" ]]; then + echo "Error: Input file '$input_video' not found." >&2 + exit 1 +fi + +if ! [[ "$scale_factor" =~ ^[0-9]*\.?[0-9]+$ ]] || (( $(echo "$scale_factor < 1" | bc -l) )); then + echo "Error: scale_factor must be a number >= 1." >&2 + exit 1 +fi + +# Read the original height +original_height=$(ffprobe -v error -select_streams v:0 -show_entries stream=height -of csv=p=0 "$input_video") -if [[ -z "${streams_stream_0_height:-}" || -z "${streams_stream_0_width:-}" ]]; then +if ! [[ "$original_height" =~ ^[0-9]+$ ]]; then echo "Error: Could not extract video dimensions." >&2 exit 1 fi -original_height="$streams_stream_0_height" -new_height=$(printf '%.0f' "$(echo "$original_height * $scale_factor" | bc -l)") +# Calculate the new height (rounded up to an even number, as most codecs require) +new_height=$(echo "h = $original_height * $scale_factor; scale = 0; h = (h + 1.999999) / 2; h * 2" | bc -l) -# Calculate the padding needed -padding=$(printf '%.0f' "$(echo "($new_height - $original_height) / 2" | bc -l)") +# Center the original video vertically +padding=$(( (new_height - original_height) / 2 )) # Run ffmpeg to add padding ffmpeg -i "$input_video" -vf "pad=width=iw:height=$new_height:x=0:y=$padding:color=black" "$output_video" diff --git a/src/extract.sh b/src/extract.sh index 3e7ecb2..550f40a 100755 --- a/src/extract.sh +++ b/src/extract.sh @@ -1,12 +1,14 @@ #!/usr/bin/env bash # Script Name: extract.sh -# Description: Extracts files based on their extension. +# Description: Extracts files based on their extension. Single-file compressed +# formats (.gz, .bz/.bz2, .Z) are decompressed into the output +# directory, keeping the original file. # Usage: extract.sh [archive file] [optional: output directory] main() { if [ $# -lt 1 ] || [ $# -gt 2 ]; then - echo "Usage: extract.sh [archive file] [optional: output directory]" + echo "Usage: extract.sh [archive file] [optional: output directory]" >&2 exit 1 fi @@ -14,12 +16,12 @@ main() { output_dir="${2:-.}" if ! [ -f "$file" ]; then - echo "File $file does not exist." + echo "File $file does not exist." >&2 exit 1 fi if ! [ -d "$output_dir" ]; then - mkdir -p "$output_dir" || { echo "Failed to create output directory $output_dir"; exit 1; } + mkdir -p "$output_dir" || { echo "Failed to create output directory $output_dir" >&2; exit 1; } echo "Created output directory $output_dir" fi @@ -29,20 +31,26 @@ main() { extract_file() { local file=$1 local output_dir=$2 + local base + base=$(basename "$file") case "$file" in - *.tar.xz) command_exists "tar" && tar -xvf "$file" -C "$output_dir" ;; - *.tar.gz) command_exists "tar" && tar -xzf "$file" -C "$output_dir" ;; - *.tar.bz2) command_exists "tar" && tar -xjf "$file" -C "$output_dir" ;; - *.tar) command_exists "tar" && tar -xf "$file" -C "$output_dir" ;; - *.tgz) command_exists "tar" && tar -xzf "$file" -C "$output_dir" ;; - *.bz|*.bz2) command_exists "bzip2" && bzip2 -d -k "$file" ;; - *.gz) command_exists "gunzip" && gunzip "$file" -c > "$output_dir" ;; + *.tar.xz|*.tar.gz|*.tar.bz2|*.tar|*.tgz) + command_exists "tar" && tar -xf "$file" -C "$output_dir" ;; + *.tar.Z) command_exists "zcat" && command_exists "tar" && zcat "$file" | tar -xf - -C "$output_dir" ;; + *.bz|*.bz2) command_exists "bzip2" && bzip2 -dc "$file" > "$output_dir/${base%.*}" ;; + *.gz) command_exists "gunzip" && gunzip -c "$file" > "$output_dir/${base%.gz}" ;; + *.Z) command_exists "zcat" && zcat "$file" > "$output_dir/${base%.Z}" ;; *.zip|*.jar) command_exists "unzip" && unzip "$file" -d "$output_dir" ;; - *.Z) command_exists "zcat" && zcat "$file" | tar -xvf - -C "$output_dir" ;; - *.rar) command_exists "rar" && rar x "$file" "$output_dir" ;; + *.rar) + if command -v unrar >/dev/null 2>&1; then + unrar x "$file" "$output_dir/" + else + command_exists "rar" && rar x "$file" "$output_dir/" + fi + ;; *.7z) command_exists "7z" && 7z x "$file" -o"$output_dir" ;; - *) echo "Unsupported archive format." ;; + *) echo "Unsupported archive format: $file" >&2; exit 1 ;; esac } diff --git a/src/factorial.sh b/src/factorial.sh index 3f8d68d..ad58431 100755 --- a/src/factorial.sh +++ b/src/factorial.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Script Name: factorial.sh -# Description: Calculates the factorial of a given integer. +# Description: Calculates the factorial of a given non-negative integer (0-20; larger values overflow 64-bit arithmetic). # Usage: factorial.sh integer # integer - Integer to calculate the factorial of. # Example: ./factorial.sh 5 @@ -20,17 +20,27 @@ calculate_factorial() { main() { if (( $# != 1 )); then - echo "Must provide exactly one integer!" + echo "Must provide exactly one integer!" >&2 exit 1 fi if ! [[ $1 =~ ^[0-9]+$ ]]; then - echo "$1 is not a positive integer!" + echo "$1 is not a non-negative integer!" >&2 exit 1 fi - local number=$1 - local result='-1' + # Strip leading zeros (avoids octal parsing) and reject anything above 20, + # since 21! no longer fits in a 64-bit integer. + local number + if [[ $1 =~ ^0*([0-9]{1,2})$ ]]; then + number=$((10#${BASH_REMATCH[1]})) + fi + if [[ -z $number ]] || ((number > 20)); then + echo "$1 is too large: factorials above 20! overflow 64-bit integers." >&2 + exit 1 + fi + + local result result=$(calculate_factorial "$number") echo "The factorial of $number is: $result" diff --git a/src/fetch_github_repos_names.sh b/src/fetch_github_repos_names.sh index ba3af70..faaf9b9 100755 --- a/src/fetch_github_repos_names.sh +++ b/src/fetch_github_repos_names.sh @@ -10,77 +10,76 @@ set -euo pipefail # Display usage message and exit usage() { - echo "Usage: $0 [github_username] [github_token]" - echo " github_username - Optional. GitHub username to fetch repositories for." - echo " github_token - Optional. Personal access token to access private repositories." + echo "Usage: $0 [github_username] [github_token]" >&2 + echo " github_username - Optional. GitHub username to fetch repositories for." >&2 + echo " github_token - Optional. Personal access token to access private repositories." >&2 exit 1 } -echo "GitHub Repository Fetcher" - -# Check if jq is installed -check_jq_installed() { - if ! command -v jq &>/dev/null; then - echo "Error: jq is not installed. Please install jq to run this script." - exit 1 - fi +# Check that the required tools are installed +check_dependencies() { + local cmd + for cmd in curl jq; do + if ! command -v "$cmd" &>/dev/null; then + echo "Error: $cmd is not installed. Please install $cmd to run this script." >&2 + exit 1 + fi + done } +# Extra curl arguments (the authorization header when a token is given; set in main) +curl_auth_args=() + # Get authenticated user's login using provided token get_authenticated_user() { - local token="$1" - local auth_header="Authorization: token $token" - local user_login - user_login=$(curl -s -H "$auth_header" https://api.github.com/user | jq -r '.login') - echo "$user_login" + curl -s "${curl_auth_args[@]}" https://api.github.com/user | jq -r '.login // empty' 2>/dev/null || true } -# Global variable for authorization header (set in main) -auth_header="" - -# Fetch a page with a single curl call to get both headers and body -# The function echoes the body and sets the global variable 'next_url' if more pages exist. +# Fetch one page of results. +# Sets the globals 'page_body' (the JSON body) and 'next_url' (next page URL, or empty). +# Globals are used because a command substitution would run in a subshell and lose next_url. +page_body="" +next_url="" +header_file="" fetch_page() { local url="$1" - # Fetch headers and body together - local response - response=$(curl -s -D - "$url" -H "$auth_header") - # Separate headers (everything until the first blank line) local header - header=$(printf "%s" "$response" | sed -n '1,/^$/p') - # Separate body (everything after the first blank line) - local body - body=$(printf "%s" "$response" | sed -n '/^$/,$p' | sed '1d') + + page_body=$(curl -s -D "$header_file" "${curl_auth_args[@]}" "$url") || { + echo "Error: request to $url failed." >&2 + exit 1 + } + # HTTP header lines end with CRLF; strip the CR before matching + header=$(tr -d '\r' < "$header_file") + # Extract next page URL from the Link header, if available if [[ "$header" =~ \<([^>]+)\>\;\ *rel=\"next\" ]]; then next_url="${BASH_REMATCH[1]}" else next_url="" fi - echo "$body" } # Main function main() { - check_jq_installed - if [ "$#" -gt 2 ]; then usage fi + echo "GitHub Repository Fetcher" + check_dependencies + local USERNAME="${1:-}" local GITHUB_TOKEN="${2:-}" local AUTHENTICATED_USER="" if [ -n "$GITHUB_TOKEN" ]; then - auth_header="Authorization: token $GITHUB_TOKEN" - AUTHENTICATED_USER=$(get_authenticated_user "$GITHUB_TOKEN") - if [ -z "$AUTHENTICATED_USER" ] || [ "$AUTHENTICATED_USER" = "null" ]; then - echo "Error: Invalid or expired GitHub token." + curl_auth_args=(-H "Authorization: token $GITHUB_TOKEN") + AUTHENTICATED_USER=$(get_authenticated_user) + if [ -z "$AUTHENTICATED_USER" ]; then + echo "Error: Invalid or expired GitHub token." >&2 exit 1 fi - else - auth_header="" fi local URL="" @@ -92,17 +91,24 @@ main() { URL="https://api.github.com/users/$USERNAME/repos?per_page=100" echo "Fetching public repositories for user '$USERNAME'..." else - echo "Error: Username required if no token is provided." + echo "Error: Username required if no token is provided." >&2 usage fi + header_file=$(mktemp) + trap 'rm -f "$header_file"' EXIT + echo "--------------------------------" # Fetch repositories with pagination while [ -n "$URL" ]; do - local page_body - page_body=$(fetch_page "$URL") - echo "$page_body" | jq -r '.[] | .name' + fetch_page "$URL" + # The API returns an object with a "message" on errors (unknown user, rate limit...) + if ! jq -e 'type == "array"' <<< "$page_body" >/dev/null 2>&1; then + echo "Error: GitHub API: $(jq -r '.message // "unexpected response"' <<< "$page_body" 2>/dev/null || echo "unexpected response")" >&2 + exit 1 + fi + jq -r '.[] | .name' <<< "$page_body" URL="$next_url" done diff --git a/src/firefox_profile_usb.sh b/src/firefox_profile_usb.sh index f4402ac..1d43ad1 100755 --- a/src/firefox_profile_usb.sh +++ b/src/firefox_profile_usb.sh @@ -27,6 +27,7 @@ declare -a PROFILE_PATHS_RAW=() declare -a PROFILE_IS_REL=() declare -a PROFILE_DEFAULTS=() declare -a PROFILE_RESOLVED=() +declare -a RSYNC_PROGRESS=() on_error() { local exit_code=$? @@ -234,7 +235,7 @@ discover_profiles() { BEGIN { in_profile=0; name=""; path=""; rel="1"; def="0" } function flush() { if (in_profile && path != "") - printf "%s\t%s\t%s\t%s\n", name, def, rel, path + printf "%s\t%s\t%s\t%s\n", (name == "" ? "unnamed" : name), def, rel, path } /^\[Profile[0-9]+\]$/ { flush(); in_profile=1; name=""; path=""; rel="1"; def="0"; next } /^\[/ { flush(); in_profile=0; next } @@ -460,7 +461,7 @@ backup_mode() { log "Backing up profile '$name' from $resolved" mkdir -p "$dest_profile_dir" - rsync -aH --delete --info=progress2 \ + rsync -aH --delete "${RSYNC_PROGRESS[@]}" \ --exclude='lock' \ --exclude='.parentlock' \ --exclude='*.sqlite-wal' \ @@ -600,19 +601,14 @@ restore_mode() { validate_profile_integrity "$src_profile_dir" "$name" + # Never overwrites an existing profile: picks a free directory name instead dest_basename="$(unique_profile_basename "$backup_dir")" dest_profile_dir="$FF_DIR/Profiles/$dest_basename" - if [[ -e "$dest_profile_dir" ]]; then - safety_dir="$dest_profile_dir.pre-restore-$ts" - warn "Target profile already exists, moving aside: $dest_profile_dir -> $safety_dir" - mv "$dest_profile_dir" "$safety_dir" - fi - mkdir -p "$dest_profile_dir" log "Restoring profile '$name' to $dest_profile_dir" - rsync -aH --delete --info=progress2 "$src_profile_dir/" "$dest_profile_dir/" + rsync -aH --delete "${RSYNC_PROGRESS[@]}" "$src_profile_dir/" "$dest_profile_dir/" clean_restored_profile "$dest_profile_dir" validate_profile_integrity "$dest_profile_dir" "$name" @@ -682,13 +678,17 @@ parse_args() { esac done - [[ -n "$MODE" ]] || { usage; exit 1; } + [[ -n "$MODE" ]] || { usage >&2; exit 1; } } main() { parse_args "$@" require_tools + if [[ "$QUIET" -eq 0 ]]; then + RSYNC_PROGRESS=(--info=progress2) + fi + case "$MODE" in backup) backup_mode ;; restore) restore_mode ;; diff --git a/src/for_loop.sh b/src/for_loop.sh index c64569c..26488b9 100755 --- a/src/for_loop.sh +++ b/src/for_loop.sh @@ -2,21 +2,22 @@ # Script Name: for_loop.sh # Description: Prints numbers from 1 to a given positive number. +# If no valid number is given, prompts for one. # Usage: ./for_loop.sh [number] # Example: ./for_loop.sh 5 read_positive_number() { local n + # Prompts and errors go to stderr: stdout is captured by the caller while true; do - echo "Enter a positive number: " - read -r n + read -r -p "Enter a positive number: " n || exit 1 if [[ $n =~ ^[1-9][0-9]*$ ]]; then echo "$n" return else - echo "Error: Invalid input. Please enter a positive number." + echo "Error: Invalid input. Please enter a positive number." >&2 fi done } @@ -31,10 +32,10 @@ print_numbers() { } main() { - local n="$1" + local n="${1:-}" if [[ ! $n =~ ^[1-9][0-9]*$ ]]; then - n=$(read_positive_number) + n=$(read_positive_number) || exit 1 fi print_numbers "$n" diff --git a/src/generate_books.sh b/src/generate_books.sh index 52e0335..02dfad4 100755 --- a/src/generate_books.sh +++ b/src/generate_books.sh @@ -1,68 +1,65 @@ #!/usr/bin/env bash # Script Name: generate_books.sh -# Description: Converts Markdown files to PDF, treating each file as a chapter and headers as subchapters. +# Description: Converts the Markdown files in the current directory (in name order) +# to a single PDF, treating each file as a chapter and headers as subchapters. +# A page break is inserted before each chapter's first header, unless the +# file already contains \newpage. The source files are not modified. # Usage: ./generate_books.sh +# Dependencies: pandoc (plus a LaTeX engine such as pdflatex for PDF output). # Constants OUTPUT_FILE="output.pdf" PAPER_SIZE="a5" -BACKUP_DIR_PREFIX="./backup_" MARGIN="top=2cm, right=1.5cm, bottom=2cm, left=1.5cm, footskip=8mm" -CONCATENATED_MD="concatenated.md" - -# Function to create a backup of files -backup_files() { - local backup_dir - backup_dir="${BACKUP_DIR_PREFIX}$(date +%Y%m%d_%H%M%S)" - mkdir -p "$backup_dir" || { echo "Failed to create backup directory."; exit 1; } - for file in "$@"; do - cp "$file" "$backup_dir" || { echo "Failed to copy file $file to backup."; continue; } - done - echo "$backup_dir" -} +CONCATENATED_MD="" # Function to add page breaks and concatenate Markdown files concatenate_md_files() { - local files=("$@") - : > "$CONCATENATED_MD" # Safely truncate file - for file in "${files[@]}"; do - if ! grep -q '\\newpage' "$file"; then - awk '/^#/ && !f {print "\\newpage\n"; f=1} 1' "$file" > "$file.tmp" && mv "$file.tmp" "$file" - fi - cat "$file" >> "$CONCATENATED_MD" || { echo "Failed to concatenate file $file."; continue; } - done + local file + for file in "$@"; do + if grep -q '\\newpage' "$file"; then + cat "$file" + else + awk '/^#/ && !f {print "\\newpage\n"; f=1} 1' "$file" + fi || { echo "Failed to concatenate file $file." >&2; return 1; } + # Separate chapters, even if a file lacks a trailing newline. + printf '\n\n' + done > "$CONCATENATED_MD" } # Function to convert Markdown to PDF convert_to_pdf() { pandoc "$CONCATENATED_MD" --from markdown --to pdf \ - --output "$OUTPUT_FILE" -V papersize="$PAPER_SIZE" -V geometry="$MARGIN" || { echo "Failed to convert to PDF."; exit 1; } + --output "$OUTPUT_FILE" -V papersize="$PAPER_SIZE" -V geometry="$MARGIN" || { echo "Failed to convert to PDF." >&2; exit 1; } } # Cleanup function cleanup() { - echo "Cleaning up temporary files..." - [ -f "$CONCATENATED_MD" ] && rm "$CONCATENATED_MD" + if [ -n "$CONCATENATED_MD" ]; then + rm -f -- "$CONCATENATED_MD" + fi } # Main logic -trap cleanup EXIT +if ! command -v pandoc >/dev/null 2>&1; then + echo "This script requires 'pandoc'. Please install it and rerun the script." >&2 + exit 1 +fi mapfile -t md_files < <(find . -maxdepth 1 -type f -name "*.md" | sort) if [ ${#md_files[@]} -eq 0 ]; then - echo "No Markdown files found in the current directory." + echo "No Markdown files found in the current directory." >&2 exit 1 fi -backup_dir=$(backup_files "${md_files[@]}") -concatenate_md_files "${md_files[@]}" +# Temporary file lives in the current directory so relative image paths still resolve. +trap cleanup EXIT +CONCATENATED_MD=$(mktemp ./.generate_books.XXXXXX) || { echo "Failed to create a temporary file." >&2; exit 1; } + +concatenate_md_files "${md_files[@]}" || exit 1 convert_to_pdf -if [ -f "$OUTPUT_FILE" ]; then - echo "PDF generated successfully. Original files have been backed up in $backup_dir." -else - echo "PDF generation failed." -fi +echo "PDF generated successfully: $OUTPUT_FILE" diff --git a/src/gif_to_mp4_converter.sh b/src/gif_to_mp4_converter.sh index 1a908bb..952ad77 100755 --- a/src/gif_to_mp4_converter.sh +++ b/src/gif_to_mp4_converter.sh @@ -1,60 +1,40 @@ #!/usr/bin/env bash # Script Name: gif_to_mp4_converter.sh -# Description: Converts a GIF file into a formatted MP4 file using ffmpeg. -# Usage: ./gif_to_mp4_converter.sh [input_gif_file] +# Description: Converts a GIF file into a 1080x1920 MP4 file (MPEG-4 video) using ffmpeg. +# The output is written to the current directory as +# formatted_output_.mp4. +# Usage: ./gif_to_mp4_converter.sh input_gif_file # Example: ./gif_to_mp4_converter.sh animation.gif -# Function to check for command errors -check_error() { - if [ $? -ne 0 ]; then - echo "Error: $1" - cleanup - exit 1 - fi -} - -# Function for cleanup -cleanup() { - echo "Cleaning up temporary files..." - rm -f "$TEMP_FILE1" "$TEMP_FILE2" -} - # Check if the correct number of arguments is given if [ "$#" -ne 1 ]; then - echo "Usage: $0 [input_gif_file]" + echo "Usage: $0 input_gif_file" >&2 exit 1 fi # Check if ffmpeg is installed if ! command -v ffmpeg &> /dev/null; then - echo "Error: ffmpeg is not installed." + echo "Error: ffmpeg is not installed." >&2 exit 1 fi # Variables INPUT_FILE="$1" -DATE_STRING=$(date +"%Y%m%d%H%M%S") -TEMP_FILE1="temp_output1_${DATE_STRING}.mp4" -TEMP_FILE2="temp_output2_${DATE_STRING}.mp4" -OUTPUT_FILE="formatted_output_${DATE_STRING}.mp4" +OUTPUT_FILE="formatted_output_$(date +"%Y%m%d%H%M%S").mp4" -trap cleanup EXIT +if [ ! -f "$INPUT_FILE" ]; then + echo "Error: Input file '$INPUT_FILE' not found." >&2 + exit 1 +fi -# Convert GIF to MP4 +# Convert, scale and encode in a single pass (no lossy intermediate files) echo "Converting GIF to MP4..." -ffmpeg -i "$INPUT_FILE" "$TEMP_FILE1" -check_error "during the GIF to MP4 conversion." - -# Scale the video -echo "Scaling the video..." -ffmpeg -i "$TEMP_FILE1" -vf scale=1080:1920 "$TEMP_FILE2" -check_error "during the scaling process." - -# Final formatting -echo "Applying final formatting..." -ffmpeg -i "$TEMP_FILE2" -c:v mpeg4 -q:v 5 "$OUTPUT_FILE" -check_error "during the final formatting process." +if ! ffmpeg -n -i "$INPUT_FILE" -vf scale=1080:1920 -c:v mpeg4 -q:v 5 "$OUTPUT_FILE"; then + echo "Error: ffmpeg failed to convert '$INPUT_FILE'." >&2 + rm -f "$OUTPUT_FILE" + exit 1 +fi echo "Conversion completed successfully. Output file: $OUTPUT_FILE" diff --git a/src/hamming_distance.sh b/src/hamming_distance.sh index f629991..01b721c 100755 --- a/src/hamming_distance.sh +++ b/src/hamming_distance.sh @@ -1,15 +1,15 @@ #!/usr/bin/env bash # Script Name: hamming_distance.sh -# Description: Calculate the Hamming Distance of two strings. +# Description: Calculate the Hamming Distance of two strings of equal length. # Usage: hamming_distance.sh string_a string_b # string_a - First string to compare # string_b - Second string to compare +# Exit codes: 0 success, 1 invalid arguments, 2 strings of different lengths # Example: hamming_distance.sh "xxbab" "bbabb" -# Output: 4 +# Output: The Hamming Distance between "xxbab" and "bbabb" is: 4 # Exit codes -EXIT_SUCCESS=0 EXIT_INVALID_ARGS=1 EXIT_DIFFERENT_LENGTHS=2 @@ -17,17 +17,12 @@ calculate_hamming_distance() { local string_a="$1" local string_b="$2" local length=${#string_a} - - if (( length != ${#string_b} )); then - echo "Error: Strings have different lengths." - exit $EXIT_DIFFERENT_LENGTHS - fi - local distance=0 + local i for ((i = 0; i < length; i++)); do if [[ "${string_a:i:1}" != "${string_b:i:1}" ]]; then - (( distance++ )) + distance=$((distance + 1)) fi done @@ -36,20 +31,20 @@ calculate_hamming_distance() { main() { if (( $# != 2 )); then - echo "Error: Invalid number of arguments." - echo "Usage: hamming_distance.sh string_a string_b" + echo "Error: Invalid number of arguments." >&2 + echo "Usage: hamming_distance.sh string_a string_b" >&2 exit $EXIT_INVALID_ARGS fi local string_a="$1" local string_b="$2" - local distance - distance=$(calculate_hamming_distance "$string_a" "$string_b") - if [[ $distance != *"Error"* ]]; then - echo "The Hamming Distance between \"$string_a\" and \"$string_b\" is: $distance" - exit $EXIT_SUCCESS + if (( ${#string_a} != ${#string_b} )); then + echo "Error: Strings have different lengths." >&2 + exit $EXIT_DIFFERENT_LENGTHS fi + + echo "The Hamming Distance between \"$string_a\" and \"$string_b\" is: $(calculate_hamming_distance "$string_a" "$string_b")" } main "$@" diff --git a/src/html_table_to_markdown.sh b/src/html_table_to_markdown.sh index 5257b08..29fe5e7 100755 --- a/src/html_table_to_markdown.sh +++ b/src/html_table_to_markdown.sh @@ -1,66 +1,63 @@ #!/usr/bin/env bash # Script Name: html_table_to_markdown.sh -# Description: Converts an HTML table to a Markdown table. -# Usage: ./html_table_to_markdown.sh [html_file] +# Description: Converts the HTML table rows in a file to a Markdown table. +# Rows containing cells become the header row. Requires GNU grep (-P). +# Usage: ./html_table_to_markdown.sh html_file print_usage() { - echo "Usage: $0 [html_file]" + echo "Usage: $0 html_file" echo "Converts an HTML table in the specified file to a Markdown table." } convert_row_to_markdown() { local row=$1 - local row_type=$2 # 'header' or 'data' + local cells cell line="" - # Detect if the row is a header or data - if [[ $row =~ "]*>//g' -e 's/^\s*//g' -e 's/\s*$//g') - local IFS=$'\n' - local cells - mapfile -t cells < <(grep -o '.*\|.*' <<< "$row") - local markdown_cells=() + # Extract the cells (non-greedy so adjacent cells are not merged) + mapfile -t cells < <(grep -oiP ']*>.*?' <<< "$row") + [[ ${#cells[@]} -gt 0 ]] || return 0 for cell in "${cells[@]}"; do - # Strip HTML tags and escape pipes - cell=$(echo "$cell" | sed -e 's/<[^>]*>//g' -e 's/|/\\|/g') - markdown_cells+=("$cell") + # Strip HTML tags, trim whitespace and escape pipes + cell=$(sed -e 's/<[^>]*>//g' -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e 's/|/\\|/g' <<< "$cell") + line+="| $cell " done + echo "${line}|" - if [ "$row_type" == "header" ]; then - echo "| ${markdown_cells[*]} |" - printf '|%s' "$(yes ' --- |' | head -n ${#markdown_cells[@]})" - echo '|' - else - echo "| ${markdown_cells[*]} |" + # Header rows are followed by the Markdown separator line + if grep -qi '&2 + print_usage >&2 exit 1 fi html_file="$1" if [[ ! -f $html_file ]]; then - echo "Error: File $html_file not found." + echo "Error: File $html_file not found." >&2 exit 1 fi -# Extract the table rows from the HTML file -rows=$(grep -oP '.+?' "$html_file") +# Join the file into one line (so rows spanning several lines work), then +# extract the table rows +mapfile -t rows < <(tr '\n\r' ' ' < "$html_file" | grep -oiP ']*>.*?') + +if [[ ${#rows[@]} -eq 0 ]]; then + echo "Error: No table rows found in $html_file." >&2 + exit 1 +fi # Convert each table row to Markdown -while IFS= read -r row; do +for row in "${rows[@]}"; do convert_row_to_markdown "$row" -done <<< "$rows" +done diff --git a/src/install_nvidia_and_prepare_steam.sh b/src/install_nvidia_and_prepare_steam.sh index 02d1be6..a4cf15d 100755 --- a/src/install_nvidia_and_prepare_steam.sh +++ b/src/install_nvidia_and_prepare_steam.sh @@ -8,8 +8,8 @@ set -euo pipefail if [[ ${EUID} -ne 0 ]]; then - echo "Run this script as root:" - echo " sudo bash $0" + echo "Run this script as root:" >&2 + echo " sudo bash $0" >&2 exit 1 fi @@ -19,7 +19,7 @@ log() { need_cmd() { if ! command -v "$1" >/dev/null 2>&1; then - echo "Missing required command: $1" + echo "Missing required command: $1" >&2 exit 1 fi } @@ -29,11 +29,11 @@ need_cmd ubuntu-drivers need_cmd lspci need_cmd grep need_cmd awk -need_cmd sed +need_cmd lsmod log "Detecting NVIDIA GPU" if ! lspci -nn | grep -qi 'NVIDIA'; then - echo "No NVIDIA GPU detected. Exiting." + echo "No NVIDIA GPU detected. Exiting." >&2 exit 1 fi @@ -44,9 +44,9 @@ recommended_driver="$( )" if [[ -z "${recommended_driver}" ]]; then - echo "Could not determine a recommended NVIDIA driver from ubuntu-drivers." - echo "Output follows:" - ubuntu-drivers devices || true + echo "Could not determine a recommended NVIDIA driver from ubuntu-drivers." >&2 + echo "Output follows:" >&2 + ubuntu-drivers devices >&2 || true exit 1 fi @@ -59,9 +59,14 @@ log "Installing ${recommended_driver}" DEBIAN_FRONTEND=noninteractive apt-get install -y "${recommended_driver}" log "Checking for a Steam desktop entry" +# Look in the invoking (non-root) user's home first, then system-wide. +user_home="" +if [[ -n "${SUDO_USER:-}" ]]; then + user_home="$(getent passwd "${SUDO_USER}" | cut -d: -f6)" +fi steam_desktop="" -if [[ -f /home/adam/.local/share/applications/steam.desktop ]]; then - steam_desktop="/home/adam/.local/share/applications/steam.desktop" +if [[ -n "${user_home}" && -f "${user_home}/.local/share/applications/steam.desktop" ]]; then + steam_desktop="${user_home}/.local/share/applications/steam.desktop" elif [[ -f /usr/share/applications/steam.desktop ]]; then steam_desktop="/usr/share/applications/steam.desktop" fi diff --git a/src/install_signal.sh b/src/install_signal.sh old mode 100644 new mode 100755 index 8cdb9db..8b28419 --- a/src/install_signal.sh +++ b/src/install_signal.sh @@ -1,4 +1,14 @@ #!/usr/bin/env bash + +# Script Name: install_signal.sh +# Description: Installs, upgrades or purges Signal Desktop on Debian/Ubuntu using +# Signal's official apt repository. Requires sudo, curl, gpg and apt-get. +# Usage: ./install_signal.sh [install|upgrade|purge|help] +# Examples: +# ./install_signal.sh +# ./install_signal.sh upgrade +# ./install_signal.sh purge + set -Eeuo pipefail readonly KEY_URL="https://updates.signal.org/desktop/apt/keys.asc" diff --git a/src/ip_info.sh b/src/ip_info.sh index 1699375..b51d8c9 100755 --- a/src/ip_info.sh +++ b/src/ip_info.sh @@ -10,11 +10,13 @@ # -p, --public Display the public IP address. # -l, --location Display location information for the public IP address. # -r, --private Display private IP addresses. -# -i, --interface IFACE Display IP address of a specific network interface. +# -i, --interface IFACE Display IP address of a specific network interface (instead of all private IPs). # -a, --all Display all available information. # -j, --json Output in JSON format. # -s, --save FILE Save output to a file. # +# With no selection options, public IP, private IPs and location are all shown. +# # Examples: # ip_info.sh --public # ip_info.sh --private @@ -27,7 +29,6 @@ # - curl # - jq (optional, for JSON parsing) - set -euo pipefail # Default configurations @@ -68,7 +69,7 @@ Options: -p, --public Display the public IP address. -l, --location Display location information for the public IP address. -r, --private Display private IP addresses. - -i, --interface IFACE Display IP address of a specific network interface. + -i, --interface IFACE Display IP address of a specific network interface (instead of all private IPs). -a, --all Display all available information. -j, --json Output in JSON format. -s, --save FILE Save output to a file. @@ -118,6 +119,7 @@ while [[ $# -gt 0 ]]; do -i|--interface) if [[ -n "${2-}" ]]; then interface="$2" + include_private_ip=true shift 2 else echo "Error: --interface requires a non-empty argument." >&2 @@ -152,20 +154,36 @@ while [[ $# -gt 0 ]]; do done # If no options are specified, default to displaying all information -if [[ "$include_public_ip" == false && "$include_private_ip" == false && "$include_location" == false && -z "$interface" ]]; then +if [[ "$include_public_ip" == false && "$include_private_ip" == false && "$include_location" == false ]]; then include_public_ip=true include_private_ip=true include_location=true fi -display_public_ip() { +# Fetches the public IP once and stores it in $public_ip +fetch_public_ip() { if [[ -z "$public_ip" ]]; then - public_ip=$(curl -s https://api.ipify.org) + public_ip=$(curl -fsS --max-time 10 https://api.ipify.org) || public_ip="" if [[ -z "$public_ip" ]]; then echo "Error: Unable to retrieve public IP address." >&2 exit 1 fi fi +} + +# Prints the given words as a JSON array of strings +json_array() { + local item sep="" + printf '[' + for item in "$@"; do + printf '%s"%s"' "$sep" "$item" + sep=", " + done + printf ']' +} + +display_public_ip() { + fetch_public_ip if [[ "$output_json" == true ]]; then echo "{\"public_ip\": \"$public_ip\"}" else @@ -177,9 +195,9 @@ display_private_ip() { if [[ -n "$interface" ]]; then # Get IP address of specified interface if [[ "$(uname)" == "Darwin" ]]; then - private_ip=$(ifconfig "$interface" 2>/dev/null | awk '/inet /{print $2}') + private_ip=$(ifconfig "$interface" 2>/dev/null | awk '/inet /{print $2}' | paste -sd' ' -) || private_ip="" else - private_ip=$(ip addr show "$interface" 2>/dev/null | awk '/inet /{print $2}' | cut -d/ -f1) + private_ip=$(ip addr show "$interface" 2>/dev/null | awk '/inet /{print $2}' | cut -d/ -f1 | paste -sd' ' -) || private_ip="" fi if [[ -z "$private_ip" ]]; then echo "Error: Unable to retrieve IP address for interface '$interface'." >&2 @@ -193,13 +211,13 @@ display_private_ip() { else # Get all private IP addresses if [[ "$(uname)" == "Darwin" ]]; then - private_ips=$(ifconfig | awk '/inet /{print $2}' | grep -v '127.0.0.1') + private_ips=$(ifconfig | awk '/inet /{print $2}' | grep -v '127.0.0.1') || private_ips="" else - private_ips=$(hostname -I) + private_ips=$(hostname -I) || private_ips="" fi if [[ "$output_json" == true ]]; then - ips_array=$(echo "$private_ips" | tr ' ' '\n' | jq -R . | jq -s .) - echo "{\"private_ips\": $ips_array}" + # shellcheck disable=SC2086 # split the whitespace-separated list into words + echo "{\"private_ips\": $(json_array $private_ips)}" else echo "Private IPs:" echo "$private_ips" @@ -208,10 +226,12 @@ display_private_ip() { } display_location() { - if [[ -z "$public_ip" ]]; then - display_public_ip >/dev/null + fetch_public_ip + location_info=$(curl -fsS --max-time 10 http://ip-api.com/json/"$public_ip") || location_info="" + if [[ -z "$location_info" ]]; then + echo "Error: Unable to retrieve location information." >&2 + exit 1 fi - location_info=$(curl -s http://ip-api.com/json/"$public_ip") if [[ "$output_json" == true ]]; then echo "$location_info" else @@ -260,6 +280,10 @@ main() { if [[ -n "$save_file" ]]; then exec > >(tee -a "$save_file") 2>&1 fi + # Fetch once up front: the display functions below may run in subshells + if [[ "$include_public_ip" == true || "$include_location" == true ]]; then + fetch_public_ip + fi if [[ "$output_json" == true ]]; then # Collect outputs in JSON format output_json_data="{" @@ -286,9 +310,7 @@ main() { output_json_data+=", " fi display_location_json=$(display_location) - output_json_data+="\"location\": ${display_location_json#\{}" - output_json_data="${output_json_data%\}}" - first=false + output_json_data+="\"location\": ${display_location_json}" fi output_json_data+="}" echo "$output_json_data" | jq . 2>/dev/null || echo "$output_json_data" diff --git a/src/is_palindrome.sh b/src/is_palindrome.sh index f20cbaa..ca425fb 100755 --- a/src/is_palindrome.sh +++ b/src/is_palindrome.sh @@ -24,8 +24,8 @@ is_palindrome() { main() { if (( $# != 1 )); then - echo "Usage: is_palindrome.sh string" - echo " string - The string to check" + echo "Usage: is_palindrome.sh string" >&2 + echo " string - The string to check" >&2 exit 1 fi diff --git a/src/is_prime.sh b/src/is_prime.sh index 23acfd9..5bd8f80 100755 --- a/src/is_prime.sh +++ b/src/is_prime.sh @@ -3,30 +3,28 @@ # Script Name: is_prime.sh # Description: Checks if a number is prime. # Usage: is_prime.sh number -# number - the number to check if it is a prime number. +# number - the non-negative integer (at most 18 digits) to check. # Example: ./is_prime.sh 5 is_prime() { local num=$1 + local i - if [[ $num -lt 2 ]]; then + if (( num < 2 )); then return 1 fi - if [[ $num -eq 2 ]]; then + if (( num == 2 )); then return 0 fi - if [[ $((num % 2)) -eq 0 ]]; then + if (( num % 2 == 0 )); then return 1 fi - local sqrt='-1' - sqrt=$(echo "sqrt($num)" | bc) - sqrt=${sqrt%.*} # Remove decimal part - - for ((i = 3; i <= sqrt; i += 2)); do - if [[ $((num % i)) -eq 0 ]]; then + # Only odd divisors up to sqrt(num) need to be tried + for ((i = 3; i * i <= num; i += 2)); do + if (( num % i == 0 )); then return 1 fi done @@ -36,25 +34,24 @@ is_prime() { main() { if [ $# -ne 1 ]; then - echo "Usage: is_prime.sh number" + echo "Usage: is_prime.sh number" >&2 exit 1 fi - re='^[0-9]+$' + # Limit to 18 digits so the value fits in Bash's 64-bit integers + re='^[0-9]{1,18}$' if ! [[ $1 =~ $re ]]; then - echo "$1 is not a positive integer!" + echo "$1 is not a non-negative integer (max 18 digits)!" >&2 exit 1 fi - if [[ $1 -eq 1 ]]; then - echo "$1 is not a prime number!" - exit 0 - fi + # Force base 10 so leading zeros (e.g. 08) aren't parsed as octal + local num=$((10#$1)) - if is_prime "$1"; then - echo "$1 is a prime number!" + if is_prime "$num"; then + echo "$num is a prime number!" else - echo "$1 is not a prime number!" + echo "$num is not a prime number!" fi } diff --git a/src/last_line_empty.sh b/src/last_line_empty.sh index 116ab9c..478494c 100755 --- a/src/last_line_empty.sh +++ b/src/last_line_empty.sh @@ -3,7 +3,8 @@ # Script Name: last_line_empty.sh # Description: Ensures each file ends with exactly one empty trailing line. # Adds a line if missing, removes extras if more than one. -# Skips binary files. Supports in-place and check-only modes. +# Skips binary files and .git directories. Supports in-place +# (permission-preserving) and check-only modes. # Usage: ./last_line_empty.sh [--check] # Options: # --check Only detect if changes are needed, do not modify files. @@ -12,13 +13,13 @@ # ./last_line_empty.sh --check myfolder checkonly=0 # 1 => only check, 0 => fix in place -status=0 # For --check mode: 1 if any file needs fixing +status=0 # 1 if any file needs fixing (--check mode) or an error occurred ############################################################################### # process_file: Applies the trailing-empty-line logic to one file. # - Skips if not a regular file or if it's binary. # - If not skipping, performs (or simulates) the transformation in memory. -# - Logs everything to stdout. +# - Logs progress to stdout, errors to stderr. ############################################################################### process_file() { local file="$1" @@ -26,7 +27,7 @@ process_file() { # Skip non-regular files: if [[ ! -f "$file" ]]; then - echo " [ERROR] Not a regular file: $file" + echo " [ERROR] Not a regular file: $file" >&2 return 1 fi @@ -36,16 +37,16 @@ process_file() { return 0 fi - # Read the file into an array, one line per element (mapfile strips the ending newline from each line). - # This loads everything into memory, which is typically fine for small/medium files. - # If you have very large files, you'd need a streaming approach, but you explicitly requested these steps. + # Read the file into an array, one line per element (mapfile strips the + # newline from each line). Fine for the small/medium text files this targets. + local lines=() mapfile -t lines < "$file" local num_lines="${#lines[@]}" # Count how many trailing lines are truly empty: # We go backward until we find a non-empty line, incrementing empty_count for each empty line. - local empty_count=0 + local empty_count=0 i for (( i = num_lines - 1; i >= 0; i-- )); do if [[ -z "${lines[$i]}" ]]; then (( empty_count++ )) @@ -79,32 +80,14 @@ process_file() { return 0 fi - # Otherwise, we actually fix the file in place. - # Create a temp file to store the modified content: - local tmp - tmp="$(mktemp -t lastline.XXXXXX)" - - # 1. If empty_count == 0, we just print out all lines, then add one empty line. - # 2. If empty_count > 1, we remove the extras so exactly 1 remains. - if (( empty_count == 0 )); then - # Print all lines as is, then add one empty line: - for line in "${lines[@]}"; do - echo "$line" - done - echo "" - else - # empty_count > 1 - # We want to remove empty_count-1 lines from the end, leaving exactly one empty line. - local keep_until=$(( num_lines - empty_count )) # index of last non-empty line - for (( i=0; i "$tmp" - - # Replace the original file with the new content: - mv "$tmp" "$file" + # Otherwise, fix the file in place: keep everything up to the last non-empty + # line, then add exactly one empty line. Writing through the existing file + # (instead of replacing it) preserves its permissions and ownership. + local keep_until=$(( num_lines - empty_count )) # number of lines to keep + if ! printf '%s\n' "${lines[@]:0:keep_until}" "" > "$file"; then + echo " [ERROR] Failed to write $file" >&2 + return 1 + fi echo " [FIXED] File updated (was $empty_count trailing empties)." } @@ -115,43 +98,44 @@ process_directory() { local directory="$1" if [[ ! -d "$directory" ]]; then - echo "Error: $directory is not a directory." + echo "Error: $directory is not a directory." >&2 return 1 fi echo "Recursively processing directory: $directory" - # Use find to traverse + # Use find to traverse, skipping .git directories + local f while IFS= read -r -d '' f; do - process_file "$f" - done < <(find "$directory" -type f -print0 2>/dev/null) + process_file "$f" || status=1 + done < <(find "$directory" -name .git -prune -o -type f -print0) } ############################################################################### # main ############################################################################### main() { - if [[ $# -eq 0 ]]; then - echo "Usage: $0 [--check] " - exit 1 - fi - - if [[ "$1" == "--check" ]]; then + if [[ "${1:-}" == "--check" ]]; then checkonly=1 shift fi + if [[ $# -ne 1 ]]; then + echo "Usage: $0 [--check] " >&2 + exit 1 + fi + local path="$1" if [[ -d "$path" ]]; then process_directory "$path" elif [[ -f "$path" ]]; then - process_file "$path" + process_file "$path" || status=1 else - echo "Error: '$path' is not a valid file or directory." + echo "Error: '$path' is not a valid file or directory." >&2 exit 1 fi - # If in --check mode and any file needed changes => exit 1 + # Exit 1 if any file needed changes (--check mode) or could not be processed exit "$status" } diff --git a/src/line_counter.sh b/src/line_counter.sh index ee75d96..15608a4 100755 --- a/src/line_counter.sh +++ b/src/line_counter.sh @@ -11,7 +11,7 @@ validate_file() { # $1: file path if [ ! -f "$1" ]; then - echo "Error: $1 does not exist." + echo "Error: $1 does not exist." >&2 exit 1 fi } @@ -22,7 +22,9 @@ count_lines() { local counter=0 - while read -r _; do + # "|| [ -n "$line" ]" also counts a final line that lacks a trailing newline. + local line + while IFS= read -r line || [ -n "$line" ]; do ((counter++)) done < "$1" @@ -33,8 +35,8 @@ main() { # Main function to execute the script if [ $# -eq 0 ]; then - echo "Error: No file name provided." - echo "Usage: line_counter.sh file_name" + echo "Error: No file name provided." >&2 + echo "Usage: line_counter.sh file_name" >&2 exit 1 fi diff --git a/src/lower.sh b/src/lower.sh index 30ade7d..ec6dac6 100755 --- a/src/lower.sh +++ b/src/lower.sh @@ -8,8 +8,8 @@ # Output: hello world convert_to_lowercase() { - # Converts a string to lowercase using tr - echo "$1" | tr '[:upper:]' '[:lower:]' + # Converts a string to lowercase using tr (printf, unlike echo, prints "-n" or "-e" literally) + printf '%s\n' "$1" | tr '[:upper:]' '[:lower:]' } main() { diff --git a/src/make_dir.sh b/src/make_dir.sh index 199b4fd..dadafc3 100755 --- a/src/make_dir.sh +++ b/src/make_dir.sh @@ -1,15 +1,17 @@ #!/usr/bin/env bash # Script Name: make_dir.sh -# Description: Creates a directory and a file in it if the directory does not exist. +# Description: Creates a directory (if it does not already exist) and a file in it, +# filled with content typed on standard input. # Usage: make_dir.sh [directory_name] [file_name] +# directory_name defaults to '.', file_name to a timestamp (YYYY-MM-DD_HH-MM-SS.txt). # Example: ./make_dir.sh my_directory my_file.txt create_directory() { local dir_name="$1" if [[ ! -d "$dir_name" ]]; then - mkdir -p "$dir_name" || { echo "Failed to create directory: $dir_name"; exit 1; } + mkdir -p -- "$dir_name" || { echo "Failed to create directory: $dir_name" >&2; exit 1; } echo "Directory $dir_name created successfully." else echo "Directory $dir_name already exists." @@ -18,38 +20,31 @@ create_directory() { create_file() { local file_name="$1" - local file_content="$2" - echo "$file_content" > "$file_name" || { echo "Failed to create file: $file_name"; exit 1; } + # Copy standard input verbatim (keeps a last line without trailing newline) + cat > "$file_name" || { echo "Failed to create file: $file_name" >&2; exit 1; } echo "File $file_name created successfully." } main() { local dir_name="${1:-.}" local file_name="${2:-$(date +"%Y-%m-%d_%H-%M-%S").txt}" - local file_content="" + local answer - # Validate directory name create_directory "$dir_name" - # Validate file name local full_file_path="$dir_name/$file_name" - if [[ -f "$full_file_path" ]]; then - read -rp "File $file_name already exists. Do you want to overwrite it? [Y/n]: " answer + if [[ -e "$full_file_path" ]]; then + read -rp "File $file_name already exists. Do you want to overwrite it? [y/N]: " answer if [[ $answer != "Y" && $answer != "y" ]]; then echo "Aborted. File $file_name was not created." exit 0 fi fi - # Get file content from user + # Get file content from user and create or overwrite the file with it echo "Enter the file content (press Ctrl+D to finish):" - while IFS= read -r line; do - file_content+="$line"$'\n' - done - - # Create or overwrite file with content - create_file "$full_file_path" "$file_content" + create_file "$full_file_path" } main "$@" diff --git a/src/make_short.sh b/src/make_short.sh index a92e60d..b231700 100755 --- a/src/make_short.sh +++ b/src/make_short.sh @@ -37,14 +37,14 @@ Fit/Output: shortsmart (default): remove black bars, then crop to exact 9:16 safely, then scale 1080x1920 pad: keep AR, center with black bars as needed (1080x1920 canvas) stretch: force 1080x1920 (distorts) - cropfill: crop to fill 9:16 using centered math, then scale (no safety clamp) + cropfill: same crop-to-9:16 path as shortsmart (kept for compatibility) Safety margin: --safe-left N Left black margin in final 1080x1920 output (px). Default: 30. Use 0 to disable. Encoding: --fps N Output fps (default: 25) - --speed auto|X.Y Speed-up factor (default: auto = max(1.0, dur/59)) + --speed auto|X.Y Speed factor > 0 (default: auto = max(1.0, dur/max-seconds)) --max-seconds S Hard cap duration (default: 59) --crf N x264 CRF (default: 18) --preset NAME x264 preset (default: veryfast) @@ -54,9 +54,23 @@ Flags: --debug Print built ffmpeg command -h, --help Show this help EOF + exit "${1:-1}" +} + +die() { + echo "Error: $*" >&2 exit 1 } +# Value of an option that requires one ($1 = option name, $2 = value) +need_arg() { + [[ -n "${2:-}" ]] || die "Option $1 requires a value." +} + +is_number() { + [[ "$1" =~ ^[0-9]+([.][0-9]+)?$ ]] +} + INPUT="" OUTPUT="" CROP_MODE="auto" # default to auto now @@ -75,36 +89,53 @@ SAFE_LEFT="30" # --- Parse args --- while [[ $# -gt 0 ]]; do case "$1" in - -i|--input) INPUT="${2:-}"; shift 2 ;; - -o|--output) OUTPUT="${2:-}"; shift 2 ;; + -h|--help) usage 0 ;; + --debug) DEBUG="true"; shift; continue ;; + -i|--input|-o|--output|--crop|--fit|--fps|--speed|--max-seconds|--crf|--preset|--probe-seconds|--exact-crop|--safe-left) + need_arg "$1" "${2:-}" ;; + *) echo "Unknown arg: $1" >&2; usage >&2 ;; + esac + case "$1" in + -i|--input) INPUT="$2" ;; + -o|--output) OUTPUT="$2" ;; --crop) - if [[ "${2:-}" == "auto" ]]; then CROP_MODE="auto"; shift 2 - elif [[ "${2:-}" == "none" ]]; then CROP_MODE="none"; shift 2 - elif [[ "${2:-}" =~ ^manual: ]]; then CROP_MODE="manual"; CROP_SPEC="${2#manual:}"; shift 2 - else echo "Invalid --crop value"; exit 1; fi ;; - --fit) FIT="${2:-}"; shift 2 ;; - --fps) FPS="${2:-}"; shift 2 ;; - --speed) SPEED="${2:-}"; shift 2 ;; - --max-seconds) MAXS="${2:-}"; shift 2 ;; - --crf) CRF="${2:-}"; shift 2 ;; - --preset) PRESET="${2:-}"; shift 2 ;; - --probe-seconds) PROBE_S="${2:-}"; shift 2 ;; - --exact-crop) EXACT_CROP="${2:-false}"; shift 2 ;; - --safe-left) SAFE_LEFT="${2:-30}"; shift 2 ;; - --debug) DEBUG="true"; shift 1 ;; - -h|--help) usage ;; - *) echo "Unknown arg: $1"; usage ;; + case "$2" in + auto|none) CROP_MODE="$2" ;; + manual:*) CROP_MODE="manual"; CROP_SPEC="${2#manual:}" ;; + *) die "Invalid --crop value: $2" ;; + esac + ;; + --fit) FIT="$2" ;; + --fps) FPS="$2" ;; + --speed) SPEED="$2" ;; + --max-seconds) MAXS="$2" ;; + --crf) CRF="$2" ;; + --preset) PRESET="$2" ;; + --probe-seconds) PROBE_S="$2" ;; + --exact-crop) EXACT_CROP="$2" ;; + --safe-left) SAFE_LEFT="$2" ;; esac + shift 2 done -[[ -z "$INPUT" || -z "$OUTPUT" ]] && usage -[[ ! -f "$INPUT" ]] && { echo "Input not found: $INPUT"; exit 1; } +[[ -z "$INPUT" || -z "$OUTPUT" ]] && usage >&2 +[[ -f "$INPUT" ]] || die "Input not found: $INPUT" -command -v ffmpeg >/dev/null || { echo "ffmpeg not found"; exit 1; } -command -v ffprobe >/dev/null || { echo "ffprobe not found"; exit 1; } -command -v awk >/dev/null || { echo "awk not found"; exit 1; } - -tmpdir="$(mktemp -d)"; trap 'rm -rf "$tmpdir"' EXIT +case "$FIT" in + shortsmart|pad|stretch|cropfill) ;; + *) die "Invalid --fit: $FIT" ;; +esac +[[ "$SPEED" == "auto" ]] || { is_number "$SPEED" && awk -v s="$SPEED" 'BEGIN{exit !(s>0)}'; } || die "--speed must be 'auto' or a number > 0" +{ is_number "$MAXS" && awk -v m="$MAXS" 'BEGIN{exit !(m>0.2)}'; } || die "--max-seconds must be a number > 0.2" +is_number "$FPS" || die "--fps must be a number" +is_number "$PROBE_S" || die "--probe-seconds must be a number" +[[ "$CRF" =~ ^[0-9]+$ ]] || die "--crf must be an integer" +[[ "$SAFE_LEFT" =~ ^[0-9]+$ ]] || die "--safe-left must be a non-negative integer" +[[ "$EXACT_CROP" == "true" || "$EXACT_CROP" == "false" ]] || die "--exact-crop must be true or false" + +for cmd in ffmpeg ffprobe awk; do + command -v "$cmd" >/dev/null || die "$cmd not found" +done # --- Helpers (numeric) --- get_dims() { @@ -121,8 +152,9 @@ clamp() { # x min max # --- cropdetect (stable, conservative) --- # Use modest threshold, small rounding so we don't lose content. # We take the LAST suggested crop within PROBE_S (usually stable for bars). +# cropdetect reports at "info" log level, so ffmpeg must not run with -v error. autodetect_crop_whxy() { - ffmpeg -v error -i "$INPUT" -t "$PROBE_S" \ + ffmpeg -hide_banner -nostats -i "$INPUT" -t "$PROBE_S" \ -vf "cropdetect=24:2:1" -f null - 2>&1 \ | sed -n 's/.*crop=\([0-9]\+:[0-9]\+:[0-9]\+:[0-9]\+\).*/\1/p' \ | tail -n 1 @@ -131,9 +163,8 @@ autodetect_crop_whxy() { # --- SAFE 9:16 crop synthesizer --- # Inputs: source iw,ih and optional detected crop w,h,x,y # Output: FINAL evenized w:h:x:y that: -# - never narrower than floor_even(h*9/16) +# - is 9:16 (width shrunk if too wide, height shrunk if too tall), centered # - stays inside the frame -# - centered if we need to shrink width to 9:16 synthesize_safe_916_crop() { local iw="$1" ih="$2" det="$3" local w h x y @@ -155,6 +186,15 @@ synthesize_safe_916_crop() { dx=$(LC_ALL=C awk -v W="$w" -v WM="$wmin" 'BEGIN{print (W-WM)/2.0}') x=$(LC_ALL=C awk -v X="$x" -v DX="$dx" 'BEGIN{print X+DX}') w="$wmin" + elif (( w < wmin )); then + # Too tall for 9:16 (e.g. portrait sources): shrink height and center vertically + local hmax dy + hmax="$(floor_even "$(LC_ALL=C awk -v W="$w" 'BEGIN{print W*16/9.0}')")" + if (( hmax >= 2 && hmax < h )); then + dy=$(LC_ALL=C awk -v H="$h" -v HM="$hmax" 'BEGIN{print (H-HM)/2.0}') + y=$(LC_ALL=C awk -v Y="$y" -v DY="$dy" 'BEGIN{print Y+DY}') + h="$hmax" + fi fi # Evenize and clamp to frame @@ -175,6 +215,10 @@ synthesize_safe_916_crop() { # --- Manual crop (L:T:R:B -> w:h:x:y) with evenization --- manual_to_whxy() { local iw="$1" ih="$2" L="$3" T="$4" R="$5" B="$6" + local v + for v in "$L" "$T" "$R" "$B"; do + [[ "$v" =~ ^[0-9]+$ ]] || return 1 + done local w=$(( iw - L - R )) local h=$(( ih - T - B )) local x="$L" y="$T" @@ -188,10 +232,11 @@ DUR="$(ffprobe -v error -show_entries format=duration -of default=nw=1:nk=1 "$IN if [[ "$SPEED" == "auto" ]]; then SPEED="$(LC_ALL=C awk -v d="$DUR" -v m="$MAXS" 'BEGIN{f=d/m; if(f<1.0) f=1.0; printf("%.8f",f)}')" fi -echo "Computed speed factor: $SPEED"; LC_ALL=C awk -v s="$SPEED" 'BEGIN{if(s>16) print "Warning: very high speed factor (" s "x)"}' +echo "Computed speed factor: $SPEED"; LC_ALL=C awk -v s="$SPEED" 'BEGIN{if(s>16) print "Warning: very high speed factor (" s "x)" > "/dev/stderr"}' # --- Build filter graph --- IFS=',' read -r IW IH <<<"$(get_dims "$INPUT")" +[[ "$IW" =~ ^[0-9]+$ && "$IH" =~ ^[0-9]+$ ]] || die "Could not read video dimensions of $INPUT" vf_chain=() CROP_SUFFIX=""; [[ "$EXACT_CROP" == "true" ]] && CROP_SUFFIX=":exact=1" @@ -201,8 +246,7 @@ SAFE_LEFT="$(clamp "${SAFE_LEFT}" 0 1078)" SAFE_LEFT="$(floor_even "${SAFE_LEFT}")" # keep even for yuv420 alignment TARGET_WIDTH=$(( 1080 - SAFE_LEFT )) if (( TARGET_WIDTH < 2 )); then - echo "--safe-left too large; resulting width < 2" - exit 1 + die "--safe-left too large; resulting width < 2" fi case "$CROP_MODE" in @@ -221,7 +265,7 @@ case "$CROP_MODE" in manual) IFS=':' read -r L T R B <<< "$CROP_SPEC" : "${L:?Missing L}"; : "${T:?Missing T}"; : "${R:?Missing R}"; : "${B:?Missing B}" - MAN="$(manual_to_whxy "$IW" "$IH" "$L" "$T" "$R" "$B")" || { echo "Manual crop produced invalid window"; exit 1; } + MAN="$(manual_to_whxy "$IW" "$IH" "$L" "$T" "$R" "$B")" || die "Manual crop must be manual:L:T:R:B (pixels) leaving a valid window" if [[ "$FIT" == "shortsmart" || "$FIT" == "cropfill" ]]; then # For portrait fill, ensure 9:16 width from the MAN height SAFE="$(synthesize_safe_916_crop "$IW" "$IH" "$MAN")" @@ -233,13 +277,6 @@ case "$CROP_MODE" in none) # Explicitly disable cropping. ;; - *) # default - if [[ "$FIT" == "shortsmart" || "$FIT" == "cropfill" ]]; then - # No bars removal requested; derive 9:16 from full frame - SAFE="$(synthesize_safe_916_crop "$IW" "$IH" "${IW}:${IH}:0:0")" - vf_chain+=("crop=${SAFE}${CROP_SUFFIX}") - fi - ;; esac # Fit to 9:16 frame @@ -248,13 +285,12 @@ case "$FIT" in vf_chain+=("scale=1080:1920:force_divisible_by=2") ;; pad) - vf_chain+=("scale=1080:-2:force_original_aspect_ratio=decrease:force_divisible_by=2") + vf_chain+=("scale=1080:1920:force_original_aspect_ratio=decrease:force_divisible_by=2") vf_chain+=("pad=1080:1920:floor((ow-iw)/2):floor((oh-ih)/2)") ;; stretch) vf_chain+=("scale=1080:1920:force_divisible_by=2") ;; - *) echo "Invalid --fit: $FIT"; exit 1 ;; esac # Apply left safety margin without covering or cropping content: @@ -277,34 +313,37 @@ VIDEO_LABEL="[v]" AUDIO_LABEL="[a]" FILTER_COMPLEX="[0:v]$(IFS=,; echo "${vf_chain[*]}")${VIDEO_LABEL}" +# Build an atempo chain for speed factor $1 (each atempo stage must be within 0.5..2.0) +build_atempo_chain() { + local f="$1" chain=() + while LC_ALL=C awk -v x="$f" 'BEGIN{exit !(x>2.0000001)}'; do + chain+=("atempo=2.0") + f="$(LC_ALL=C awk -v x="$f" 'BEGIN{printf("%.8f", x/2.0)}')" + done + while LC_ALL=C awk -v x="$f" 'BEGIN{exit !(x<0.4999999)}'; do + chain+=("atempo=0.5") + f="$(LC_ALL=C awk -v x="$f" 'BEGIN{printf("%.8f", x*2.0)}')" + done + chain+=("atempo=$(LC_ALL=C awk -v x="$f" 'BEGIN{printf("%.8f",x)}')") + (IFS=,; echo "${chain[*]}") +} + # Audio -MAP_AUDIO="" +AUDIO_ARGS=() if ffprobe -v error -select_streams a -show_entries stream=index -of csv=p=0 "$INPUT" | grep -q .; then - # Build atempo chain - build_atempo_chain() { - local f="$1" chain=() - f="$(LC_ALL=C awk -v x="$f" 'BEGIN{if (x<1.000001) x=1.0; printf("%.8f",x)}')" - while LC_ALL=C awk -v x="$f" 'BEGIN{exit !(x>2.0000001)}'; do - chain+=("atempo=2.0") - f="$(LC_ALL=C awk -v x="$f" 'BEGIN{printf("%.8f", x/2.0)}')" - done - chain+=("atempo=$(LC_ALL=C awk -v x="$f" 'BEGIN{printf("%.8f",x)}')") - (IFS=,; echo "${chain[*]}") - } ATEMPO_CHAIN="$(build_atempo_chain "$SPEED")" FILTER_COMPLEX="${FILTER_COMPLEX};[0:a]${ATEMPO_CHAIN}${AUDIO_LABEL}" - MAP_AUDIO="-map ${AUDIO_LABEL} -c:a aac -b:a 128k" + AUDIO_ARGS=(-map "${AUDIO_LABEL}" -c:a aac -b:a 128k) else - MAP_AUDIO="-an" + AUDIO_ARGS=(-an) fi # Cap duration slightly under the limit CAP="$(LC_ALL=C awk -v m="$MAXS" 'BEGIN{printf("%.3f",m-0.2)}')" -# shellcheck disable=SC2206 FFCMD=( ffmpeg -y -noautorotate -i "$INPUT" -filter_complex "$FILTER_COMPLEX" - -map "${VIDEO_LABEL}" $MAP_AUDIO + -map "${VIDEO_LABEL}" "${AUDIO_ARGS[@]}" -c:v libx264 -preset "$PRESET" -crf "$CRF" -pix_fmt yuv420p -metadata:s:v:0 rotate=0 -map_metadata -1 -movflags +faststart -t "$CAP" diff --git a/src/matrix_display.sh b/src/matrix_display.sh index 575cf7c..0b2fbb9 100755 --- a/src/matrix_display.sh +++ b/src/matrix_display.sh @@ -2,6 +2,7 @@ # Script Name: matrix_display.sh # Description: Displays a Matrix-style animation of falling characters in the terminal. +# Press Ctrl+C to stop. # Usage: ./matrix_display.sh # Example: ./matrix_display.sh @@ -18,11 +19,23 @@ COLS=$(tput cols) ROWS=$(tput lines) COLUMN_SPEEDS=() -# Initialize speeds for each column +# Initialize speeds for each column (1 = prints every frame, 5 = rarely) for (( c=1; c<=COLS; c++ )); do COLUMN_SPEEDS[c]=$(( ( RANDOM % 5 ) + 1 )) done +# Restore the terminal (colors, cursor, screen) when the script exits +restore_terminal() { + echo -ne "$END_COLOR" + tput cnorm + clear +} +trap restore_terminal EXIT +trap 'exit 0' INT TERM + +clear +tput civis + print_char() { local col=$1 local char=${CHARS:$(( RANDOM % ${#CHARS} )):1} @@ -37,8 +50,8 @@ print_char() { # Infinite loop to keep the matrix going while :; do for (( c=1; c<=COLS; c++ )); do - if (( RANDOM % COLUMN_SPEEDS[c] == 1 )); then - print_char $c + if (( RANDOM % COLUMN_SPEEDS[c] == 0 )); then + print_char "$c" fi done sleep 0.1 diff --git a/src/max_array.sh b/src/max_array.sh index 69d61f3..b5acf95 100755 --- a/src/max_array.sh +++ b/src/max_array.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Script Name: max_array.sh -# Description: Find the maximum value in an array. +# Description: Find the maximum value in a list of integers. # Usage: max_array.sh val1 val2 ... # val1 val2 ... - a list of values separated by spaces # Example: max_array.sh 4 5 6 1 2 3 @@ -13,6 +13,7 @@ find_maximum() { local max=${1} shift + local i for i in "$@"; do if [[ ${i} -gt ${max} ]]; then max=${i} @@ -26,14 +27,16 @@ validate_input() { # $@: a list of values if [ $# -eq 0 ]; then - echo "Error: No values provided" + echo "Error: No values provided" >&2 + echo "Usage: max_array.sh val1 val2 ..." >&2 exit 1 fi - re='^[0-9]+$' + local re='^-?[0-9]+$' + local i for i in "$@"; do if ! [[ $i =~ $re ]]; then - echo "Error: $i is not an integer" + echo "Error: $i is not an integer" >&2 exit 1 fi done diff --git a/src/middle_line.sh b/src/middle_line.sh index 6187f93..e60c652 100755 --- a/src/middle_line.sh +++ b/src/middle_line.sh @@ -1,44 +1,40 @@ #!/usr/bin/env bash # Script Name: middle_line.sh -# Description: Prints the middle line of a file. +# Description: Prints the middle line of a file (for an even number of lines, +# the first of the two middle lines). # Usage: middle_line.sh file_name # file_name - the name of the file to print the middle line of. # Example: ./middle_line.sh path/to/file.txt main() { if [ $# -eq 0 ]; then - echo "You must provide a file path!" + echo "You must provide a file path!" >&2 exit 1 fi file_path=$1 if [ ! -f "$file_path" ]; then - echo "$file_path is not a valid file path!" + echo "$file_path is not a valid file path!" >&2 exit 1 fi if [ ! -r "$file_path" ]; then - echo "Cannot read $file_path!" + echo "Cannot read $file_path!" >&2 exit 1 fi if [ ! -s "$file_path" ]; then - echo "$file_path is empty!" + echo "$file_path is empty!" >&2 exit 1 fi - total_lines=$(wc -l <"$file_path") - middle_line=$((total_lines / 2)) + # awk also counts a last line that has no trailing newline (wc -l does not). + total_lines=$(awk 'END { print NR }' "$file_path") + middle_line=$(((total_lines + 1) / 2)) - while IFS= read -r line; do - ((current_line++)) - if [ "$current_line" -eq "$middle_line" ]; then - echo "$line" - break - fi - done <"$file_path" + sed -n "${middle_line}{p;q}" "$file_path" } main "$@" diff --git a/src/min_array.sh b/src/min_array.sh index cb38e94..8d0a8ef 100755 --- a/src/min_array.sh +++ b/src/min_array.sh @@ -3,7 +3,7 @@ # Script Name: min_array.sh # Description: Find the minimum value in an array. # Usage: min_array.sh val1 val2 ... -# val1 val2 ... - a list of values separated by spaces +# val1 val2 ... - a list of integers (may be negative) separated by spaces # Example: min_array.sh 4 5 6 1 2 3 # Output: 1 @@ -14,7 +14,7 @@ find_minimum() { local min=${1} shift for i in "$@"; do - if [[ ${i} -lt ${min} ]]; then + if (( i < min )); then min=${i} fi done @@ -26,14 +26,16 @@ validate_input() { # $@: a list of values if [ $# -eq 0 ]; then - echo "Error: No values provided" + echo "Error: No values provided" >&2 + echo "Usage: min_array.sh val1 val2 ..." >&2 exit 1 fi - re='^[0-9]+$' + # No leading zeros: Bash would read e.g. 010 as octal + re='^-?(0|[1-9][0-9]*)$' for i in "$@"; do if ! [[ $i =~ $re ]]; then - echo "Error: $i is not an integer" + echo "Error: $i is not an integer" >&2 exit 1 fi done diff --git a/src/month_to_number.sh b/src/month_to_number.sh index 370e15b..64b5d78 100755 --- a/src/month_to_number.sh +++ b/src/month_to_number.sh @@ -1,11 +1,15 @@ #!/usr/bin/env bash -# Script Name: convert_month.sh +# Script Name: month_to_number.sh # Description: Converts between month names and numbers. -# Usage: convert_month.sh -# - either a month name or a number -# Example: ./convert_month.sh 7 -# ./convert_month.sh jul +# Usage: month_to_number.sh +# - a month name, full or three-letter (case-insensitive), or a number from 1 to 12 +# Example: ./month_to_number.sh 7 +# ./month_to_number.sh jul +# ./month_to_number.sh January + +MONTHS=(jan feb mar apr may jun jul aug sep oct nov dec) +FULL_MONTHS=(january february march april may june july august september october november december) number_to_month() { # Converts a number to the corresponding month name @@ -13,25 +17,27 @@ number_to_month() { # Check if exactly one argument is passed if [ $# -ne 1 ]; then - echo "Error: exactly one argument is required" + echo "Error: exactly one argument is required" >&2 return 1 fi # Check if the argument is an integer if ! [[ $1 =~ ^[0-9]+$ ]]; then - echo "Error: argument must be an integer" + echo "Error: argument must be an integer" >&2 return 1 fi + # Force base 10 so that e.g. "08" is not parsed as an invalid octal number + local number=$((10#$1)) + # Check if the argument is in the range of months - if [ "$1" -lt 1 ] || [ "$1" -gt 12 ]; then - echo "Error: argument must be in the range of months" + if [ "$number" -lt 1 ] || [ "$number" -gt 12 ]; then + echo "Error: argument must be in the range of months" >&2 return 1 fi # Convert the number to the corresponding month - local months=(jan feb mar apr may jun jul aug sep oct nov dec) - echo "${months[$1-1]}" + echo "${MONTHS[number-1]}" } month_to_number() { @@ -40,34 +46,29 @@ month_to_number() { # Check if exactly one argument is passed if [ $# -ne 1 ]; then - echo "Error: exactly one argument is required" + echo "Error: exactly one argument is required" >&2 return 1 fi - # Check if the argument is a valid month name - if ! [[ $1 =~ ^[a-z]+$ ]]; then - echo "Error: argument must be a month name" - return 1 - fi + local month="${1,,}" # Convert the month name to the corresponding number - local months=(jan feb mar apr may jun jul aug sep oct nov dec) - for i in "${!months[@]}"; do - if [ "$1" = "${months[$i]}" ]; then + for i in "${!MONTHS[@]}"; do + if [ "$month" = "${MONTHS[$i]}" ] || [ "$month" = "${FULL_MONTHS[$i]}" ]; then echo $((i+1)) return 0 fi done # If the month name is not found - echo "Error: argument must be a month name" + echo "Error: argument must be a month name" >&2 return 1 } main() { if [ $# -ne 1 ]; then # Exactly one argument is required: either month or number - echo "Usage: $0 " + echo "Usage: $0 " >&2 return 1 fi diff --git a/src/network_bandwidth.sh b/src/network_bandwidth.sh index 9977c5f..b739612 100755 --- a/src/network_bandwidth.sh +++ b/src/network_bandwidth.sh @@ -11,7 +11,9 @@ # -I, --interface IFACE Specify network interface to monitor (default: auto-detect). # -i, --interval SECONDS Sampling interval in seconds (default: 2). # -t, --threshold KB/S Alert threshold in KB/s for high bandwidth (default: 1000). -# -n, --top N Number of top processes to display (default: 10). +# -n, --top N Number of processes with open sockets to display (default: 10). +# Processes are ranked by queued bytes (ss Recv-Q/Send-Q), +# which is an indication, not a per-process bandwidth measurement. # -c, --count COUNT Number of samples to collect (default: unlimited). # -l, --log-file FILE Log output to specified file. # -o, --output FILE Save output to specified file. @@ -50,7 +52,7 @@ Options: -I, --interface IFACE Specify network interface to monitor (default: auto-detect). -i, --interval SECONDS Sampling interval in seconds (default: 2). -t, --threshold KB/S Alert threshold in KB/s for high bandwidth (default: 1000). - -n, --top N Number of top processes to display (default: 10). + -n, --top N Number of processes with open sockets to display (default: 10). -c, --count COUNT Number of samples to collect (default: unlimited). -l, --log-file FILE Log output to specified file. -o, --output FILE Save output to specified file. @@ -65,14 +67,14 @@ Examples: EOF } -# Function for logging +# Function for logging (verbose messages go to stderr so they never mix with JSON output) log_action() { local message="$1" if [[ "$LOG_ENABLED" == true ]]; then echo "$(date +"%Y-%m-%d %T"): $message" >> "$LOG_FILE" fi if [[ "$VERBOSE" == true ]]; then - echo "$message" + echo "$message" >&2 fi } @@ -135,22 +137,28 @@ read_bytes() { format_rate() { local bytes_per_sec="$1" if (( bytes_per_sec >= 1073741824 )); then - awk -v b="$bytes_per_sec" 'BEGIN {printf "%.2f GB/s", b/1073741824}' + LC_ALL=C awk -v b="$bytes_per_sec" 'BEGIN {printf "%.2f GB/s", b/1073741824}' elif (( bytes_per_sec >= 1048576 )); then - awk -v b="$bytes_per_sec" 'BEGIN {printf "%.2f MB/s", b/1048576}' + LC_ALL=C awk -v b="$bytes_per_sec" 'BEGIN {printf "%.2f MB/s", b/1048576}' elif (( bytes_per_sec >= 1024 )); then - awk -v b="$bytes_per_sec" 'BEGIN {printf "%.2f KB/s", b/1024}' + LC_ALL=C awk -v b="$bytes_per_sec" 'BEGIN {printf "%.2f KB/s", b/1024}' else echo "${bytes_per_sec} B/s" fi } -# Get top network-consuming processes using ss and /proc +# Summarize sockets per process using ss (queued bytes and connection count). +# $1: number of processes to show, $2: "text" or "json" get_top_processes() { local number="$1" + local format="$2" if ! command -v ss &>/dev/null; then - echo " (ss not available — process tracing requires ss)" + if [[ "$format" == json ]]; then + echo "[]" + else + echo " (ss not available — process tracing requires ss)" + fi return fi @@ -159,23 +167,25 @@ get_top_processes() { ss_output=$(ss -tunap 2>/dev/null | tail -n +2) || true if [[ -z "$ss_output" ]]; then - echo " (No active network connections found or insufficient permissions)" + if [[ "$format" == json ]]; then + echo "[]" + else + echo " (No active network connections found or insufficient permissions)" + fi return fi - # Extract and aggregate by process name, showing recv-q and send-q - echo "$ss_output" | awk ' + # Aggregate by process name (portable awk: no gawk-only match() array) + echo "$ss_output" | awk -v limit="$number" -v format="$format" ' { - # The last field contains the process info: users:(("name",pid=N,fd=N)) - proc = $NF - recv_q = $2 - send_q = $3 - - # Extract process name from users:(("name",...)) - if (match(proc, /\(\("([^"]+)"/, arr)) { - name = arr[1] - } else { - name = "-" + # Columns: Netid State Recv-Q Send-Q Local Peer [Process] + # The process column looks like users:(("name",pid=N,fd=N)) and is + # missing for sockets of other users when not running as root + recv_q = $3 + send_q = $4 + name = "-" + if (match($0, /users:\(\("[^"]+"/)) { + name = substr($0, RSTART + 9, RLENGTH - 10) } recv[name] += recv_q @@ -183,99 +193,41 @@ get_top_processes() { count[name]++ } END { - # Sort by total (recv+send) descending + # Sort by total (recv+send) descending, then by connection count n = 0 for (name in count) { - total[n] = recv[name] + send[name] - names[n] = name - n++ + names[n++] = name } # Simple selection sort for (i = 0; i < n - 1; i++) { max_idx = i for (j = i + 1; j < n; j++) { - if (total[j] > total[max_idx]) { + a = names[j]; b = names[max_idx] + if (recv[a] + send[a] > recv[b] + send[b] || \ + (recv[a] + send[a] == recv[b] + send[b] && count[a] > count[b])) { max_idx = j } } - if (max_idx != i) { - tmp = total[i]; total[i] = total[max_idx]; total[max_idx] = tmp - tmp = names[i]; names[i] = names[max_idx]; names[max_idx] = tmp - } + tmp = names[i]; names[i] = names[max_idx]; names[max_idx] = tmp } - printf " %-20s %10s %10s %8s\n", "PROCESS", "RECV-Q", "SEND-Q", "CONNS" - limit = (n < '"$number"') ? n : '"$number"' - for (i = 0; i < limit; i++) { - name = names[i] - printf " %-20s %10d %10d %8d\n", name, recv[name], send[name], count[name] - } - }' -} - -# Get top processes in JSON format -get_top_processes_json() { - local number="$1" - - if ! command -v ss &>/dev/null; then - echo "[]" - return - fi - - local ss_output - ss_output=$(ss -tunap 2>/dev/null | tail -n +2) || true - - if [[ -z "$ss_output" ]]; then - echo "[]" - return - fi - - echo "$ss_output" | awk ' - { - proc = $NF - recv_q = $2 - send_q = $3 - - if (match(proc, /\(\("([^"]+)"/, arr)) { - name = arr[1] - } else { - name = "-" - } - - recv[name] += recv_q - send[name] += send_q - count[name]++ - } - END { - n = 0 - for (name in count) { - total[n] = recv[name] + send[name] - names[n] = name - n++ - } - - for (i = 0; i < n - 1; i++) { - max_idx = i - for (j = i + 1; j < n; j++) { - if (total[j] > total[max_idx]) { - max_idx = j - } + if (n < limit) limit = n + if (format == "json") { + printf "[" + for (i = 0; i < limit; i++) { + name = names[i] + if (i > 0) printf ", " + printf "{\"process\": \"%s\", \"recv_q\": %d, \"send_q\": %d, \"connections\": %d}", name, recv[name], send[name], count[name] } - if (max_idx != i) { - tmp = total[i]; total[i] = total[max_idx]; total[max_idx] = tmp - tmp = names[i]; names[i] = names[max_idx]; names[max_idx] = tmp + printf "]\n" + } else { + printf " %-20s %10s %10s %8s\n", "PROCESS", "RECV-Q", "SEND-Q", "CONNS" + for (i = 0; i < limit; i++) { + name = names[i] + printf " %-20s %10d %10d %8d\n", name, recv[name], send[name], count[name] } } - - printf "[" - limit = (n < '"$number"') ? n : '"$number"' - for (i = 0; i < limit; i++) { - name = names[i] - if (i > 0) printf ", " - printf "{\"process\": \"%s\", \"recv_q\": %d, \"send_q\": %d, \"connections\": %d}", name, recv[name], send[name], count[name] - } - printf "]" }' } @@ -300,7 +252,7 @@ output_text_sample() { echo "" echo "Active Network Processes:" - get_top_processes "$TOP_N" + get_top_processes "$TOP_N" text echo "" } @@ -314,7 +266,7 @@ output_json_sample() { local high_usage="$6" local processes_json - processes_json=$(get_top_processes_json "$TOP_N") + processes_json=$(get_top_processes "$TOP_N" json) cat << EOF { @@ -462,11 +414,8 @@ main() { fi # Read initial bytes - local initial - initial=$(read_bytes "$INTERFACE") local prev_rx prev_tx - prev_rx=$(echo "$initial" | awk '{print $1}') - prev_tx=$(echo "$initial" | awk '{print $2}') + read -r prev_rx prev_tx < <(read_bytes "$INTERFACE") sleep "$INTERVAL" @@ -475,16 +424,16 @@ main() { while true; do sample_num=$((sample_num + 1)) - local current - current=$(read_bytes "$INTERFACE") local curr_rx curr_tx - curr_rx=$(echo "$current" | awk '{print $1}') - curr_tx=$(echo "$current" | awk '{print $2}') + read -r curr_rx curr_tx < <(read_bytes "$INTERFACE") - # Calculate rates (bytes per second) + # Calculate rates (bytes per second); a counter reset (e.g. interface + # restarted) would give a negative difference, so clamp it to 0 local rx_diff tx_diff rx_rate tx_rate rx_diff=$((curr_rx - prev_rx)) tx_diff=$((curr_tx - prev_tx)) + (( rx_diff < 0 )) && rx_diff=0 + (( tx_diff < 0 )) && tx_diff=0 rx_rate=$((rx_diff / INTERVAL)) tx_rate=$((tx_diff / INTERVAL)) diff --git a/src/numbers_in_interval.sh b/src/numbers_in_interval.sh index fc31cf5..01473d1 100755 --- a/src/numbers_in_interval.sh +++ b/src/numbers_in_interval.sh @@ -1,12 +1,12 @@ #!/usr/bin/env bash # Script Name: numbers_in_interval.sh -# Description: Script to print all numbers in a given interval. +# Description: Script to print all numbers in a given interval (inclusive), one per line. # Usage: numbers_in_interval.sh [start] [end] -# [start] - start of the interval -# [end] - end of the interval +# [start] - start of the interval (non-negative integer) +# [end] - end of the interval (non-negative integer, >= start) # Example: ./numbers_in_interval.sh 1 5 -# Output: 1 2 3 4 5 +# Output: 1 2 3 4 5 (each on its own line) print_numbers_in_interval() { local start=$1 @@ -20,22 +20,26 @@ print_numbers_in_interval() { main() { if [ $# -ne 2 ]; then - echo "Usage: numbers_in_interval.sh [start] [end]" + echo "Usage: numbers_in_interval.sh [start] [end]" >&2 exit 1 fi re='^[0-9]+$' if ! [[ $1 =~ $re ]] || ! [[ $2 =~ $re ]]; then - echo "Both arguments must be positive integers" + echo "Both arguments must be non-negative integers" >&2 exit 1 fi - if [ "$1" -ge "$2" ]; then - echo "Start must be less than end" + # Force base 10 so values with leading zeros (e.g. 08) are not read as octal + local start=$((10#$1)) + local end=$((10#$2)) + + if [ "$start" -gt "$end" ]; then + echo "Start must not be greater than end" >&2 exit 1 fi - print_numbers_in_interval "$1" "$2" + print_numbers_in_interval "$start" "$end" } main "$@" diff --git a/src/orphans.sh b/src/orphans.sh index 2fd5396..3255b07 100755 --- a/src/orphans.sh +++ b/src/orphans.sh @@ -85,25 +85,6 @@ parse_arguments() { done } -# Create temporary files for process data -create_temp_files() { - if ! TMP_FILE=$(mktemp /tmp/orphans_processes.XXXXXX 2>/dev/null); then - echo -e "${RED}Error: Failed to create temporary file${NC}" >&2 - exit 1 - fi - - if ! PIDS_TMP_FILE=$(mktemp /tmp/orphans_pids.XXXXXX 2>/dev/null); then - rm -f "$TMP_FILE" - echo -e "${RED}Error: Failed to create temporary PID file${NC}" >&2 - exit 1 - fi -} - -# Cleanup function to remove temporary files -cleanup() { - rm -f "${TMP_FILE:-}" "${PIDS_TMP_FILE:-}" -} - # Error handling function error_exit() { echo -e "${RED}Error: $1${NC}" >&2 @@ -112,51 +93,45 @@ error_exit() { # Function to check if required commands are available check_dependencies() { - local missing_deps=() - - for cmd in ps awk sort grep mktemp; do - if ! command -v "$cmd" >/dev/null 2>&1; then - missing_deps+=("$cmd") - fi - done - - if [[ ${#missing_deps[@]} -gt 0 ]]; then - error_exit "Missing required commands: ${missing_deps[*]}" + if ! command -v ps >/dev/null 2>&1; then + error_exit "Missing required command: ps" fi } +# Running PIDs (keys) and the candidate processes to check ("ppid pid user comm" lines) +declare -A RUNNING_PIDS=() +PROCESS_LINES=() + # Function to get process information based on user preference get_process_info() { - local -a ps_options + local all_processes line pid - if [[ "$USER_ONLY" == true ]]; then - ps_options=(-u "$(id -un)" -o "ppid,pid,user,comm") - else - ps_options=(-eo "ppid,pid,user,comm") - fi - - # Get process information and handle potential ps command failures - if ! ps "${ps_options[@]}" --no-headers 2>/dev/null > "$TMP_FILE"; then - error_exit "Failed to retrieve process information. You may need elevated privileges." + # Always look up parents among ALL processes; --user only limits which + # children are reported (a user's process may have a root-owned parent). + if ! all_processes=$(ps -eo "ppid,pid,user,comm" --no-headers 2>/dev/null); then + error_exit "Failed to retrieve process information." fi - - # Verify that we got some data - if [[ ! -s "$TMP_FILE" ]]; then + if [[ -z "$all_processes" ]]; then error_exit "No process information retrieved" fi -} -# Function to create PID lookup table -create_pid_lookup() { - if ! awk '{print $2}' "$TMP_FILE" | sort -u > "$PIDS_TMP_FILE"; then - error_exit "Failed to create PID lookup table" - fi + local current_user + current_user=$(id -un) + while read -r _ pid _; do + RUNNING_PIDS[$pid]=1 + done <<< "$all_processes" + + while IFS= read -r line; do + read -r _ _ user _ <<< "$line" + if [[ "$USER_ONLY" == false || "$user" == "$current_user" ]]; then + PROCESS_LINES+=("$line") + fi + done <<< "$all_processes" } # Function to check for orphan processes check_orphans() { local orphan_count=0 - local line_number=0 if [[ "$VERBOSE" == true && "$COUNT_ONLY" == false ]]; then echo -e "${BLUE}Checking for orphaned processes...${NC}" @@ -165,8 +140,6 @@ check_orphans() { fi while IFS=' ' read -r ppid pid user comm; do - line_number=$((line_number + 1)) - # Skip empty lines or malformed entries if [[ -z "$ppid" || -z "$pid" ]]; then continue @@ -179,7 +152,7 @@ check_orphans() { fi # Check if the parent PID exists in our running processes - if ! grep -Fxq "$ppid" "$PIDS_TMP_FILE"; then + if [[ -z "${RUNNING_PIDS[$ppid]:-}" ]]; then orphan_count=$((orphan_count + 1)) if [[ "$COUNT_ONLY" == false ]]; then @@ -191,7 +164,7 @@ check_orphans() { fi fi - done < "$TMP_FILE" + done < <(printf '%s\n' "${PROCESS_LINES[@]}") # Display results summary if [[ "$COUNT_ONLY" == true ]]; then @@ -223,18 +196,9 @@ main() { # Check for required dependencies check_dependencies - # Create temporary files - create_temp_files - - # Set trap to cleanup on exit - trap cleanup EXIT INT TERM - # Get process information get_process_info - # Create PID lookup table - create_pid_lookup - # Check for orphaned processes check_orphans } diff --git a/src/pdf_page_extractor.sh b/src/pdf_page_extractor.sh index 9545c0c..7401b86 100755 --- a/src/pdf_page_extractor.sh +++ b/src/pdf_page_extractor.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash # Script Name: pdf_page_extractor.sh -# Description: Extracts a range of pages from a PDF file using ghostscript. +# Description: Extracts a range of pages from a PDF file using pdftk. +# If end-page is omitted, extraction runs to the last page. # Usage: ./pdf_page_extractor.sh [-h] [-v] [-o output_file] [end-page] # Options: # -h, --help Display the help message. @@ -14,12 +15,26 @@ usage() { echo " -h, --help Display this help message." echo " -v, --verbose Enable verbose mode." echo " -o, --output Specify output file name." + echo " If end-page is omitted, pages are extracted up to the last page." } +# Translate long options into their short equivalents for getopts +args=() +for arg in "$@"; do + case $arg in + --help) args+=("-h") ;; + --verbose) args+=("-v") ;; + --output) args+=("-o") ;; + --output=*) args+=("-o" "${arg#--output=}") ;; + *) args+=("$arg") ;; + esac +done +set -- "${args[@]}" + # Parse command-line options VERBOSE=0 OUTPUT_FILE="" -while getopts "hvo:" opt; do +while getopts ":hvo:" opt; do case $opt in h) usage @@ -31,8 +46,14 @@ while getopts "hvo:" opt; do o) OUTPUT_FILE=$OPTARG ;; + :) + echo "Error: Option -$OPTARG requires an argument." >&2 + usage >&2 + exit 1 + ;; "?") - echo "Invalid option: -$OPTARG" >&2 + echo "Error: Invalid option: -$OPTARG" >&2 + usage >&2 exit 1 ;; esac @@ -40,46 +61,56 @@ done shift $((OPTIND-1)) -# Check minimum number of arguments -if [[ $# -lt 2 ]]; then - echo "Error: Too few arguments" - usage +# Check number of arguments +if [[ $# -lt 2 || $# -gt 3 ]]; then + echo "Error: Wrong number of arguments" >&2 + usage >&2 exit 1 fi PDF_FILE="$1" START_PAGE="$2" -END_PAGE="${3:--1}" +END_PAGE="${3:-}" # Check for pdftk if ! command -v pdftk &> /dev/null; then - echo "Error: pdftk is not installed." + echo "Error: pdftk is not installed." >&2 exit 1 fi # Check if the PDF file exists if [[ ! -f "$PDF_FILE" ]]; then - echo "Error: File not found - $PDF_FILE" + echo "Error: File not found - $PDF_FILE" >&2 exit 1 fi # Find total number of pages -TOTAL_PAGES=$(pdftk "$PDF_FILE" dump_data | grep NumberOfPages | awk '{print $2}') - -# Validate page numbers -if ! [[ "$START_PAGE" =~ ^[0-9]+$ ]] || ! [[ "$END_PAGE" =~ ^[0-9]+$ ]] || (( START_PAGE > END_PAGE )); then - echo "Error: Invalid page range specified." +TOTAL_PAGES=$(pdftk "$PDF_FILE" dump_data 2>/dev/null | awk '/^NumberOfPages:/ {print $2}') +if ! [[ "$TOTAL_PAGES" =~ ^[0-9]+$ ]]; then + echo "Error: Could not read the page count of $PDF_FILE." >&2 exit 1 fi -# Replace -1 with the total number of pages -if [[ "$END_PAGE" -eq -1 ]]; then +# Default the end page to the last page +if [[ -z "$END_PAGE" ]]; then END_PAGE="$TOTAL_PAGES" fi +# Validate page numbers (base 10, so values like 08 are not read as octal) +if ! [[ "$START_PAGE" =~ ^[0-9]+$ ]] || ! [[ "$END_PAGE" =~ ^[0-9]+$ ]]; then + echo "Error: Invalid page range specified." >&2 + exit 1 +fi +if (( 10#$START_PAGE < 1 || 10#$START_PAGE > 10#$END_PAGE )); then + echo "Error: Invalid page range specified." >&2 + exit 1 +fi +START_PAGE=$((10#$START_PAGE)) +END_PAGE=$((10#$END_PAGE)) + # Check if end page is greater than total pages if (( END_PAGE > TOTAL_PAGES )); then - echo "Error: End page ($END_PAGE) is greater than total pages ($TOTAL_PAGES)." + echo "Error: End page ($END_PAGE) is greater than total pages ($TOTAL_PAGES)." >&2 exit 1 fi @@ -92,7 +123,10 @@ fi if [[ $VERBOSE -eq 1 ]]; then echo "Extracting pages $START_PAGE to $END_PAGE from $PDF_FILE..." fi -pdftk "$PDF_FILE" cat "${START_PAGE}"-"${END_PAGE}" output "$OUTPUT_FILE" +if ! pdftk "$PDF_FILE" cat "${START_PAGE}-${END_PAGE}" output "$OUTPUT_FILE"; then + echo "Error: pdftk failed to extract the pages." >&2 + exit 1 +fi echo "New PDF saved as $OUTPUT_FILE" diff --git a/src/prepend_text_to_file.sh b/src/prepend_text_to_file.sh index b43203f..1fee7a1 100755 --- a/src/prepend_text_to_file.sh +++ b/src/prepend_text_to_file.sh @@ -45,18 +45,26 @@ prepend_text_to_file() { # Create a secure temporary file. local tmpfile tmpfile=$(mktemp) || { echo "Error: Could not create temporary file." >&2; return 1; } - # Ensure the temporary file is removed on function exit. - trap 'rm -f "$tmpfile"' RETURN + # Ensure the temporary file is removed however the script ends + # (with set -e a failing command exits without running a RETURN trap). + # shellcheck disable=SC2064 # expand $tmpfile now, it is local + trap "rm -f '$tmpfile'" EXIT # Prepend the text: write the new text first, then the existing file content. - { echo "$text"; cat "$file"; } > "$tmpfile" - mv "$tmpfile" "$file" - # Clear the temporary file trap (temporary file has been moved). - trap - RETURN + { printf '%s\n' "$text"; cat "$file"; } > "$tmpfile" + # Copy the content back instead of using mv, so the file keeps its + # permissions, ownership and inode (symlinks and hard links stay intact). + if ! cat "$tmpfile" > "$file"; then + trap - EXIT + echo "Error: Failed to write '$file'; the full content was kept in '$tmpfile'." >&2 + return 1 + fi + rm -f "$tmpfile" + trap - EXIT # Log the action. echo "The following text was prepended to the file '$file':" - echo "$text" + printf '%s\n' "$text" } # Main function to call the prepend_text_to_file function. diff --git a/src/program_on_port.sh b/src/program_on_port.sh index 71fc271..6e577a2 100755 --- a/src/program_on_port.sh +++ b/src/program_on_port.sh @@ -1,38 +1,47 @@ #!/usr/bin/env bash # Script Name: program_on_port.sh -# Description: Script to check which programs are running on a specific port. +# Description: Script to check which programs are using a specific port (TCP or UDP). +# Without root privileges only your own processes are shown. # Usage: program_on_port.sh port -# port - a port to check +# port - a port to check (1-65535) # Example: ./program_on_port.sh 8001 check_port() { - # Check which programs are running on the specified port + # Check which programs are using the specified port # $1: port number local port="$1" - lsof -i tcp:"$port" + if ! lsof -i :"$port"; then + echo "No program found using port $port." >&2 + return 1 + fi } main() { # Main function to orchestrate the script if [ $# -ne 1 ]; then - echo "Usage: program_on_port.sh port" - echo " port - a port to check" - echo "Example: ./program_on_port.sh 8001" + { + echo "Usage: program_on_port.sh port" + echo " port - a port to check" + echo "Example: ./program_on_port.sh 8001" + } >&2 exit 1 fi re='^[0-9]+$' - if ! [[ $1 =~ $re ]]; then - echo "Error: $1 is not an integer" + if ! [[ $1 =~ $re ]] || (( 10#$1 < 1 || 10#$1 > 65535 )); then + echo "Error: $1 is not a valid port number (1-65535)" >&2 exit 1 fi - local port="$1" + if ! command -v lsof >/dev/null 2>&1; then + echo "Error: this script requires 'lsof'." >&2 + exit 1 + fi - check_port "$port" + check_port "$((10#$1))" } main "$@" diff --git a/src/progress_bar.sh b/src/progress_bar.sh index 0ea71b3..299e777 100755 --- a/src/progress_bar.sh +++ b/src/progress_bar.sh @@ -5,40 +5,39 @@ # Usage: ./progress_bar.sh # Example: ./progress_bar.sh -# Define colors -GREEN='\033[0;32m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color +# Define colors ($'...' so they hold real escape characters, usable with printf %s) +GREEN=$'\033[0;32m' +BLUE=$'\033[0;34m' +NC=$'\033[0m' # No Color - -clear # Clear the terminal for a neat start +clear 2>/dev/null # Clear the terminal for a neat start # Print fancy hacker-like messages -echo -e "${BLUE}Initializing covert protocol...${NC}" +echo "${BLUE}Initializing covert protocol...${NC}" sleep 0.3 -echo -e "${BLUE}Accessing mainframe database...${NC}" +echo "${BLUE}Accessing mainframe database...${NC}" sleep 0.3 -echo -e "${BLUE}Bypassing firewall...${NC}" +echo "${BLUE}Bypassing firewall...${NC}" sleep 0.3 -echo -e "${GREEN}Firewall bypassed successfully!${NC}" +echo "${GREEN}Firewall bypassed successfully!${NC}" sleep 0.3 +# Get the width of the terminal (fall back to 80 columns), keeping room for the percentage +total_width=$(tput cols 2>/dev/null || echo 80) +(( total_width < 20 )) && total_width=20 +max_bar=$((total_width - 10)) + # Define function to draw the progress bar draw_progress_bar() { - local progress total_width bar_length - - # Progress will be an integer (0 to 100) - progress=$1 - total_width=$(tput cols) # Get the width of the terminal + local progress=$1 # integer from 0 to 100 + local bar_length=$((max_bar * progress / 100)) + local filled empty - # Deducting 10 to leave space for percentage - bar_length=$(( (total_width - 10) * progress / 100 )) + # Build strings of the right lengths ('%*s' pads with spaces) + printf -v filled '%*s' "$bar_length" '' + printf -v empty '%*s' "$((max_bar - bar_length))" '' - # Draw the progress bar - printf "\r%s[" "$GREEN" - printf "%0.s=" $(seq 1 "$bar_length") - printf "%0.s " $(seq 1 "$((total_width - bar_length - 10))") - printf "] %3d%%%s" "$progress" "$NC" + printf "\r%s[%s%s] %3d%%%s" "$GREEN" "${filled// /=}" "$empty" "$progress" "$NC" } # This loop will mock data processing and draw the progress bar @@ -48,5 +47,5 @@ for i in $(seq 1 100); do done echo "" # Newline for cleaner output -echo -e "${GREEN}Boss Level unlocked!${NC}" +echo "${GREEN}Boss Level unlocked!${NC}" diff --git a/src/prompt_for_answer.sh b/src/prompt_for_answer.sh index 03d62b9..3cf806a 100755 --- a/src/prompt_for_answer.sh +++ b/src/prompt_for_answer.sh @@ -1,27 +1,27 @@ #!/usr/bin/env bash -# Script Name: promt_for_answer.sh -# Description: Asks a question and gets a user's response. +# Script Name: prompt_for_answer.sh +# Description: Asks a question and gets a user's response, falling back to a +# default answer when the user just presses Enter. # Usage: prompt_for_answer.sh # Example: ./prompt_for_answer.sh ask_question_and_get_response() { # $1 is the question - # $2 is the default answer + # $2 is the default answer (optional) # Check the number of arguments if [ $# -eq 0 ]; then - echo "No arguments supplied" + echo "No arguments supplied" >&2 exit 1 fi - echo -e "$1" - read -rp "[$2]"$'\n' response - - if [ $# -eq 2 ]; then - if [ -z "$response" ]; then - response="$2" - fi + echo "$1" + if [ $# -ge 2 ]; then + read -rp "[$2] " response + response="${response:-$2}" + else + read -rp "> " response fi } @@ -30,7 +30,7 @@ print_greeting() { } main() { - ask_question_and_get_response "What is your name?" + ask_question_and_get_response "What is your name?" "stranger" print_greeting } diff --git a/src/purge_and_reinstall_nodejs.sh b/src/purge_and_reinstall_nodejs.sh index ac5cd40..8c7aba3 100755 --- a/src/purge_and_reinstall_nodejs.sh +++ b/src/purge_and_reinstall_nodejs.sh @@ -166,7 +166,7 @@ remove_root_path() { local path="$1" case "$path" in - ""|"/"|"/usr"|"/usr/local"|"/opt"|"/home"|"$HOME") + ""|"/"|"/bin"|"/etc"|"/usr"|"/usr/bin"|"/usr/lib"|"/usr/local"|"/usr/local/bin"|"/usr/local/lib"|"/opt"|"/home"|"/root"|"$HOME") die "Refusing to remove unsafe path: $path" ;; esac @@ -461,14 +461,20 @@ purge_unmanaged_system_install() { local binary path local binaries=(node npm npx corepack) local dirs_to_remove=( - "$INSTALL_PREFIX" + "/opt/nodejs" "/usr/local/lib/node_modules" "/usr/local/include/node" "/usr/local/share/doc/node" ) + # A custom --install-prefix may be a shared directory, so only remove the + # node-v* installations this script creates inside it, not the prefix itself. if [[ "$INSTALL_PREFIX" != "/opt/nodejs" ]]; then - dirs_to_remove+=("/opt/nodejs") + for path in "$INSTALL_PREFIX"/node-v*; do + if [[ -e "$path" ]]; then + dirs_to_remove+=("$path") + fi + done fi echo "Purging unmanaged system Node.js/npm files..." @@ -512,10 +518,6 @@ user_home_dirs() { return fi - if [[ $EUID -ne 0 && "$DRY_RUN" == false ]]; then - die "--all-users requires root" - fi - if command -v getent >/dev/null 2>&1; then getent passwd | awk -F: '$6 ~ /^\// {print $6}' | sort -u elif [[ "$OS_TYPE" == "macos" && -d /Users ]]; then @@ -858,6 +860,9 @@ install_from_nodejs_org() { echo "[DRY-RUN] Would move extracted Node.js files to: $target_dir" else run_as_root mv "$extract_dir" "$target_dir" + # The archive was extracted as the invoking user; hand it to root so the + # binaries linked into $SYMLINK_DIR cannot be modified by that user. + run_as_root chown -R 0:0 "$target_dir" fi for binary in node npm npx corepack; do @@ -881,7 +886,8 @@ install_from_nodejs_org() { if [[ "$DRY_RUN" == false ]]; then echo "Verifying installation..." "$target_dir/bin/node" --version - "$target_dir/bin/npm" --version + # npm is a "#!/usr/bin/env node" script, so the new node must be in PATH + PATH="$target_dir/bin:$PATH" "$target_dir/bin/npm" --version case ":$PATH:" in *":$SYMLINK_DIR:"*) @@ -956,8 +962,7 @@ planned_actions() { ;; esac - if [[ "$PURGE_SYSTEM" == false && "$PURGE_USER_CACHE" == false && "$REMOVE_ALL_VERSION_MANAGERS" == false \ - && ${#VERSION_MANAGERS_TO_REMOVE[@]} -eq 0 && -z "$INSTALL_METHOD" ]]; then + if ! needs_confirmation; then echo " - Detect only" fi } @@ -996,14 +1001,9 @@ confirm_if_needed() { } validate_args() { - local install_count=0 local channel_count=0 local manager - if [[ -n "$INSTALL_METHOD" ]]; then - install_count=1 - fi - case "$INSTALL_PREFIX" in /*) ;; @@ -1040,14 +1040,24 @@ validate_args() { channel_count=$((channel_count + 1)) fi - if [[ $install_count -gt 1 ]]; then - die "Only one install method can be specified." - fi - if [[ $channel_count -gt 1 ]]; then die "Only one of --current, --latest, --lts, or --version can be specified." fi + if [[ "$INSTALL_METHOD" == "distro" && $channel_count -gt 0 ]]; then + die "--lts, --current, --latest and --version only apply to official nodejs.org installs, not --distro." + fi + + # Checked here because user_home_dirs runs in a process substitution, + # where die would only end the subshell. + local touches_user_data=false + if [[ "$PURGE_USER_CACHE" == true || "$REMOVE_ALL_VERSION_MANAGERS" == true || ${#VERSION_MANAGERS_TO_REMOVE[@]} -gt 0 ]]; then + touches_user_data=true + fi + if [[ "$ALL_USERS" == true && "$touches_user_data" == true && $EUID -ne 0 && "$DRY_RUN" == false ]]; then + die "--all-users requires root" + fi + if [[ -n "$INSTALL_VERSION" ]]; then INSTALL_VERSION=$(normalize_version "$INSTALL_VERSION") INSTALL_CHANNEL="version" @@ -1123,11 +1133,6 @@ main() { echo "Done." } -if [[ $# -eq 0 ]]; then - main - exit 0 -fi - while [[ $# -gt 0 ]]; do case "$1" in --detect) @@ -1170,10 +1175,12 @@ while [[ $# -gt 0 ]]; do shift ;; --lts) + [[ -z "$INSTALL_CHANNEL" || "$INSTALL_CHANNEL" == "lts" ]] || die "Only one of --current, --latest, --lts, or --version can be specified." INSTALL_CHANNEL="lts" shift ;; --current|--latest) + [[ -z "$INSTALL_CHANNEL" || "$INSTALL_CHANNEL" == "current" ]] || die "Only one of --current, --latest, --lts, or --version can be specified." INSTALL_CHANNEL="current" shift ;; @@ -1230,12 +1237,12 @@ while [[ $# -gt 0 ]]; do ;; -*) log_error "Unknown option: $1" - print_usage + print_usage >&2 exit 1 ;; *) log_error "Unexpected argument: $1" - print_usage + print_usage >&2 exit 1 ;; esac diff --git a/src/purge_pip.sh b/src/purge_pip.sh index 64494af..9d980d8 100755 --- a/src/purge_pip.sh +++ b/src/purge_pip.sh @@ -1,12 +1,15 @@ #!/usr/bin/env bash -# Script Name: uninstall_python_packages.sh -# Description: Safely uninstalls user-installed Python packages, keeping essential system packages intact. -# Usage: uninstall_python_packages.sh [options] +# Script Name: purge_pip.sh +# Description: Safely uninstalls the Python packages installed for the current 'pip', +# keeping essential packaging tools (pip, setuptools, wheel) intact. +# Before uninstalling, the full 'pip freeze' list is saved to +# ~/python_package_backups/backup_.txt (reinstall with 'pip install -r'). +# Usage: purge_pip.sh [options] # # Options: # -e, --exclude PACKAGE(S) Exclude additional packages from uninstallation (comma-separated). -# -i, --include PACKAGE(S) Include specific packages for uninstallation (comma-separated). +# -i, --include PACKAGE(S) Only uninstall these packages (comma-separated). # -a, --all Uninstall all packages including essential ones. # -d, --dry-run Show what would be uninstalled without making changes. # -l, --log-file FILE Log actions to a specified file. @@ -15,9 +18,9 @@ # -V, --version Display script version. # # Examples: -# uninstall_python_packages.sh --exclude numpy,requests -# uninstall_python_packages.sh --dry-run --verbose -# uninstall_python_packages.sh --include pandas,matplotlib +# purge_pip.sh --exclude numpy,requests +# purge_pip.sh --dry-run --verbose +# purge_pip.sh --include pandas,matplotlib set -euo pipefail @@ -39,7 +42,7 @@ print_usage() { echo echo "Options:" echo " -e, --exclude PACKAGE(S) Exclude additional packages from uninstallation (comma-separated)." - echo " -i, --include PACKAGE(S) Include specific packages for uninstallation (comma-separated)." + echo " -i, --include PACKAGE(S) Only uninstall these packages (comma-separated)." echo " -a, --all Uninstall all packages including essential ones." echo " -d, --dry-run Show what would be uninstalled without making changes." echo " -l, --log-file FILE Log actions to a specified file." @@ -76,6 +79,11 @@ confirm_uninstallation() { [[ "$REPLY" =~ ^[Yy]$ ]] } +# Normalize a package name for comparison (PEP 503: case-insensitive, -_. equivalent) +normalize_name() { + echo "$1" | tr '[:upper:]_.' '[:lower:]--' +} + # Function to uninstall packages uninstall_packages() { local packages=("$@") @@ -83,28 +91,37 @@ uninstall_packages() { local timestamp timestamp=$(date +%Y%m%d%H%M%S) local backup_file="$backup_dir/backup_$timestamp.txt" + local failed=0 + if [[ "$DRY_RUN" == true ]]; then + for package in "${packages[@]}"; do + echo "Would uninstall: $package" + done + return 0 + fi + + # Save name==version pins of everything installed so the state can be restored mkdir -p "$backup_dir" - echo "${packages[@]}" > "$backup_file" - log_action "Backup of uninstalled packages created at $backup_file" + pip freeze --all > "$backup_file" + log_action "Backup of installed packages created at $backup_file" for package in "${packages[@]}"; do log_action "Uninstalling package: $package" - if [[ "$DRY_RUN" == true ]]; then - echo "Would uninstall: $package" + if pip uninstall -y "$package"; then + log_action "Successfully uninstalled: $package" else - if pip uninstall -y "$package"; then - log_action "Successfully uninstalled: $package" - else - log_action "Error: Failed to uninstall $package" - fi + log_action "Error: Failed to uninstall $package" + echo "Error: Failed to uninstall $package" >&2 + failed=1 fi done + + return "$failed" } # Check for pip if ! command -v pip &>/dev/null; then - echo "Error: 'pip' is not installed." + echo "Error: 'pip' is not installed." >&2 exit 1 fi @@ -112,20 +129,20 @@ fi while [[ $# -gt 0 ]]; do case "$1" in -e|--exclude) - if [[ -n "$2" ]]; then + if [[ -n "${2-}" ]]; then IFS=',' read -ra EXCLUDE_PACKAGES <<< "$2" shift 2 else - echo "Error: '--exclude' requires a non-empty argument." + echo "Error: '--exclude' requires a non-empty argument." >&2 exit 1 fi ;; -i|--include) - if [[ -n "$2" ]]; then + if [[ -n "${2-}" ]]; then IFS=',' read -ra INCLUDE_PACKAGES <<< "$2" shift 2 else - echo "Error: '--include' requires a non-empty argument." + echo "Error: '--include' requires a non-empty argument." >&2 exit 1 fi ;; @@ -138,12 +155,12 @@ while [[ $# -gt 0 ]]; do shift ;; -l|--log-file) - if [[ -n "$2" ]]; then + if [[ -n "${2-}" ]]; then LOG_FILE="$2" LOG_ENABLED=true shift 2 else - echo "Error: '--log-file' requires a non-empty argument." + echo "Error: '--log-file' requires a non-empty argument." >&2 exit 1 fi ;; @@ -159,56 +176,42 @@ while [[ $# -gt 0 ]]; do print_version exit 0 ;; - -*) - echo "Unknown option: $1" - print_usage - exit 1 - ;; *) - # No more options - break + echo "Unknown option: $1" >&2 + print_usage >&2 + exit 1 ;; esac done -# Get list of installed packages -if [[ "$UNINSTALL_ALL" == true ]]; then - mapfile -t INSTALLED_PACKAGES < <(pip freeze | cut -d'=' -f1) +# Candidate packages: the --include list, or everything installed +# ('pip list --format=freeze' also lists pip/setuptools/wheel, unlike 'pip freeze') +if [[ "${#INCLUDE_PACKAGES[@]}" -gt 0 ]]; then + CANDIDATES=("${INCLUDE_PACKAGES[@]}") else - mapfile -t INSTALLED_PACKAGES < <(pip freeze | cut -d'=' -f1 | grep -vE "^($(IFS="|"; echo "${ESSENTIAL_PACKAGES[*]}"))$") + mapfile -t CANDIDATES < <(pip list --format=freeze | cut -d'=' -f1) fi -# Exclude specified packages -if [[ "${#EXCLUDE_PACKAGES[@]}" -gt 0 ]]; then - for exclude in "${EXCLUDE_PACKAGES[@]}"; do - # Removed problematic array manipulation line - : - done +# Packages that must be kept: user exclusions, plus essential ones unless --all +KEEP=("${EXCLUDE_PACKAGES[@]}") +if [[ "$UNINSTALL_ALL" == false ]]; then + KEEP+=("${ESSENTIAL_PACKAGES[@]}") fi -# If include packages are specified, only uninstall those -if [[ "${#INCLUDE_PACKAGES[@]}" -gt 0 ]]; then - INSTALLED_PACKAGES=("${INCLUDE_PACKAGES[@]}") -fi - -# Remove ARGS if not used - -# Refactor array filtering to use a robust method -filtered_packages=() -for pkg in "${INSTALLED_PACKAGES[@]}"; do +INSTALLED_PACKAGES=() +for pkg in "${CANDIDATES[@]}"; do + [[ -z "$pkg" ]] && continue skip=false - for exclude in "${ESSENTIAL_PACKAGES[@]}" "${EXCLUDE_PACKAGES[@]}"; do - if [[ $pkg == "$exclude" ]]; then + for keep in "${KEEP[@]}"; do + if [[ "$(normalize_name "$pkg")" == "$(normalize_name "$keep")" ]]; then skip=true break fi done if [[ $skip == false ]]; then - filtered_packages+=("$pkg") + INSTALLED_PACKAGES+=("$pkg") fi - done -INSTALLED_PACKAGES=("${filtered_packages[@]}") if [[ "${#INSTALLED_PACKAGES[@]}" -eq 0 ]]; then echo "No packages to uninstall." @@ -220,12 +223,21 @@ for pkg in "${INSTALLED_PACKAGES[@]}"; do echo "- $pkg" done -if ! confirm_uninstallation; then +if [[ "$DRY_RUN" == false ]] && ! confirm_uninstallation; then echo "Uninstallation cancelled." exit 0 fi -uninstall_packages "${INSTALLED_PACKAGES[@]}" +if ! uninstall_packages "${INSTALLED_PACKAGES[@]}"; then + log_action "Package uninstallation completed with errors." + echo "Package uninstallation completed with errors." >&2 + exit 1 +fi + +if [[ "$DRY_RUN" == true ]]; then + echo "Dry run: no packages were uninstalled." + exit 0 +fi log_action "Package uninstallation completed." echo "Package uninstallation completed." diff --git a/src/ram_memory.sh b/src/ram_memory.sh index 3bd4c06..93075d5 100755 --- a/src/ram_memory.sh +++ b/src/ram_memory.sh @@ -7,15 +7,17 @@ # Options: # -h, --help Display this help message and exit. # -v, --verbose Enable verbose output. -# -m, --minimum RAM Specify minimum RAM required (in GB). Default is 1 GB. +# -m, --minimum RAM Specify minimum RAM required (in UNIT). Default is 1. # -u, --unit UNIT Specify unit for RAM values (GB, MB, KB). Default is GB. # -t, --top N Display top N processes by RAM usage. Default is 10. # -l, --log-file FILE Enable logging to specified log file. # -c, --critical PERCENT Specify critical RAM usage level in percent (e.g., 90). # -s, --swap Include swap memory in calculations. # -o, --output FILE Save output to specified file. -# --json Output in JSON format. -# --no-color Disable colored output. +# --json Output only a JSON summary (no table). +# --no-color Accepted for compatibility (output is never colored). +# +# Exit status: 0 if the minimum is met, 1 if not (or on error). # # Examples: # ram_memory.sh --minimum 2 --unit GB --top 5 @@ -23,6 +25,7 @@ # ram_memory.sh --critical 85 set -euo pipefail +export LC_ALL=C # Use "." as the decimal separator in awk output. # Default configurations VERBOSE=false @@ -30,7 +33,6 @@ MINIMUM_RAM=1 UNIT="GB" TOP_N=10 LOG_FILE="" -LOG_ENABLED=false CRITICAL_LEVEL=0 INCLUDE_SWAP=false OUTPUT_FILE="" @@ -44,15 +46,17 @@ Usage: $0 [options] Options: -h, --help Display this help message and exit. -v, --verbose Enable verbose output. - -m, --minimum RAM Specify minimum RAM required (in GB). Default is 1 GB. + -m, --minimum RAM Specify minimum RAM required (in UNIT). Default is 1. -u, --unit UNIT Specify unit for RAM values (GB, MB, KB). Default is GB. -t, --top N Display top N processes by RAM usage. Default is 10. -l, --log-file FILE Enable logging to specified log file. -c, --critical PERCENT Specify critical RAM usage level in percent (e.g., 90). -s, --swap Include swap memory in calculations. -o, --output FILE Save output to specified file. - --json Output in JSON format. - --no-color Disable colored output. + --json Output only a JSON summary (no table). + --no-color Accepted for compatibility (output is never colored). + +Exit status: 0 if the minimum is met, 1 if not (or on error). Examples: $0 --minimum 2 --unit GB --top 5 @@ -62,91 +66,55 @@ Examples: EOF } +die() { + echo "Error: $*" >&2 + exit 1 +} + # Function for logging log_action() { local message="$1" - if [[ "$LOG_ENABLED" == true ]]; then + if [[ -n "$LOG_FILE" ]]; then echo "$(date +"%Y-%m-%d %T"): $message" >> "$LOG_FILE" fi if [[ "$VERBOSE" == true ]]; then - echo "$message" + echo "$message" >&2 fi } -# Function to convert RAM values +# Print the value (in kB) of a /proc/meminfo field, or 0 if absent. +meminfo_kb() { + awk -v key="$1:" '$1 == key { print $2; found = 1 } END { if (!found) print 0 }' /proc/meminfo +} + +# Convert a kB value to $UNIT with two decimals. convert_ram() { local ram_kb="$1" - local unit="$2" - local ram_value - - case "$unit" in - GB|gb) - ram_value=$(echo "scale=2; $ram_kb/1024/1024" | bc) - ;; - MB|mb) - ram_value=$(echo "scale=2; $ram_kb/1024" | bc) - ;; - KB|kb) - ram_value="$ram_kb" - ;; - *) - echo "Invalid unit: $unit" - exit 1 - ;; + case "$UNIT" in + GB) awk -v kb="$ram_kb" 'BEGIN { printf "%.2f\n", kb / 1024 / 1024 }' ;; + MB) awk -v kb="$ram_kb" 'BEGIN { printf "%.2f\n", kb / 1024 }' ;; + KB) echo "$ram_kb" ;; esac - echo "$ram_value" } -# Function to check minimum RAM -check_minimum_ram() { - local total_ram_kb - if ! total_ram_kb=$(grep MemTotal /proc/meminfo | awk '{print $2}'); then - echo "Unable to determine total RAM." - exit 1 - fi - - if [[ "$INCLUDE_SWAP" == true ]]; then - if swap_total_kb=$(grep SwapTotal /proc/meminfo | awk '{print $2}'); then - total_ram_kb=$((total_ram_kb + swap_total_kb)) - fi - fi - - local total_ram - total_ram=$(convert_ram "$total_ram_kb" "$UNIT") - local min_ram_kb +# Convert $MINIMUM_RAM (in $UNIT) to an integer number of kB. +minimum_in_kb() { + local factor=1 case "$UNIT" in - GB|gb) - min_ram_kb=$(echo "$MINIMUM_RAM * 1024 * 1024" | bc) - ;; - MB|mb) - min_ram_kb=$(echo "$MINIMUM_RAM * 1024" | bc) - ;; - KB|kb) - min_ram_kb="$MINIMUM_RAM" - ;; + GB) factor=1048576 ;; + MB) factor=1024 ;; esac - - if (( total_ram_kb < min_ram_kb )); then - echo "The system doesn't meet the requirements. RAM size must be at least $MINIMUM_RAM $UNIT." - log_action "Insufficient RAM. Required: $MINIMUM_RAM $UNIT, Available: $total_ram $UNIT." - exit 1 - else - echo "The system meets the minimum RAM requirements." - log_action "Sufficient RAM. Required: $MINIMUM_RAM $UNIT, Available: $total_ram $UNIT." - fi + awk -v m="$MINIMUM_RAM" -v f="$factor" 'BEGIN { printf "%d\n", m * f }' } -# Function to display top RAM usage -display_top_ram_usage() { - echo "Top $TOP_N programs by RAM usage:" - ps aux --sort=-%mem | head -n $((TOP_N + 1)) | awk '{printf "%-10s %-8s %-5s %-5s %s\n", $1, $2, $3, $4, $11}' | column -t - log_action "Top RAM consuming processes displayed." +# Ensure an option has a value. +require_value() { + [[ -n "${2:-}" ]] || die "$1 requires a value." } # Parse command-line arguments while [[ $# -gt 0 ]]; do - key="$1" - case $key in + case "$1" in -h|--help) print_usage exit 0 @@ -156,63 +124,38 @@ while [[ $# -gt 0 ]]; do shift ;; -m|--minimum) - if [[ -n "$2" ]]; then - MINIMUM_RAM="$2" - shift 2 - else - echo "Error: --minimum requires a value." - exit 1 - fi + require_value "$1" "${2:-}" + MINIMUM_RAM="$2" + shift 2 ;; -u|--unit) - if [[ -n "$2" ]]; then - UNIT="$2" - shift 2 - else - echo "Error: --unit requires a value." - exit 1 - fi + require_value "$1" "${2:-}" + UNIT="${2^^}" + shift 2 ;; -t|--top) - if [[ -n "$2" ]]; then - TOP_N="$2" - shift 2 - else - echo "Error: --top requires a value." - exit 1 - fi + require_value "$1" "${2:-}" + TOP_N="$2" + shift 2 ;; -l|--log-file) - if [[ -n "$2" ]]; then - LOG_FILE="$2" - LOG_ENABLED=true - shift 2 - else - echo "Error: --log-file requires a value." - exit 1 - fi + require_value "$1" "${2:-}" + LOG_FILE="$2" + shift 2 ;; -c|--critical) - if [[ -n "$2" ]]; then - CRITICAL_LEVEL="$2" - shift 2 - else - echo "Error: --critical requires a value." - exit 1 - fi + require_value "$1" "${2:-}" + CRITICAL_LEVEL="$2" + shift 2 ;; -s|--swap) INCLUDE_SWAP=true shift ;; -o|--output) - if [[ -n "$2" ]]; then - OUTPUT_FILE="$2" - shift 2 - else - echo "Error: --output requires a value." - exit 1 - fi + require_value "$1" "${2:-}" + OUTPUT_FILE="$2" + shift 2 ;; --json) OUTPUT_JSON=true @@ -222,58 +165,91 @@ while [[ $# -gt 0 ]]; do shift ;; *) - echo "Unknown option: $1" - print_usage + echo "Unknown option: $1" >&2 + print_usage >&2 exit 1 ;; esac done +# Validate options +case "$UNIT" in + GB|MB|KB) ;; + *) die "Invalid unit: $UNIT (expected GB, MB or KB)." ;; +esac +[[ "$MINIMUM_RAM" =~ ^[0-9]+([.][0-9]+)?$ ]] || die "--minimum must be a non-negative number." +[[ "$TOP_N" =~ ^[0-9]+$ ]] || die "--top must be a non-negative integer." +[[ "$CRITICAL_LEVEL" =~ ^[0-9]+$ ]] || die "--critical must be an integer percentage." +[[ -r /proc/meminfo ]] || die "/proc/meminfo is not readable (this script requires Linux)." + # Redirect output to file if specified if [[ -n "$OUTPUT_FILE" ]]; then exec > >(tee -a "$OUTPUT_FILE") fi -# Main execution -check_minimum_ram -display_top_ram_usage - -# Check critical RAM usage level -if [[ "$CRITICAL_LEVEL" -gt 0 ]]; then - meminfo=$(grep -E 'MemTotal|MemAvailable' /proc/meminfo) - total_ram_kb=$(echo "$meminfo" | grep MemTotal | awk '{print $2}') - mem_available_kb=$(echo "$meminfo" | grep MemAvailable | awk '{print $2}') - mem_used_kb=$((total_ram_kb - mem_available_kb)) - mem_used_percent=$((mem_used_kb * 100 / total_ram_kb)) +# Gather memory information +total_ram_kb=$(meminfo_kb MemTotal) +mem_free_kb=$(meminfo_kb MemFree) +mem_available_kb=$(meminfo_kb MemAvailable) +swap_total_kb=$(meminfo_kb SwapTotal) +swap_free_kb=$(meminfo_kb SwapFree) +(( total_ram_kb > 0 )) || die "Unable to determine total RAM." + +checked_kb=$total_ram_kb +if [[ "$INCLUDE_SWAP" == true ]]; then + checked_kb=$((checked_kb + swap_total_kb)) +fi +checked_ram=$(convert_ram "$checked_kb") +min_ram_kb=$(minimum_in_kb) + +meets_minimum=true +if (( checked_kb < min_ram_kb )); then + meets_minimum=false + log_action "Insufficient RAM. Required: $MINIMUM_RAM $UNIT, Available: $checked_ram $UNIT." +else + log_action "Sufficient RAM. Required: $MINIMUM_RAM $UNIT, Available: $checked_ram $UNIT." +fi - if (( mem_used_percent > CRITICAL_LEVEL )); then - echo "Warning: Memory usage is above critical level ($CRITICAL_LEVEL%)." - log_action "Memory usage critical: $mem_used_percent% used." - fi +mem_used_percent=$(((total_ram_kb - mem_available_kb) * 100 / total_ram_kb)) +critical=false +if (( CRITICAL_LEVEL > 0 && mem_used_percent > CRITICAL_LEVEL )); then + critical=true + log_action "Memory usage critical: $mem_used_percent% used." fi -# Output in JSON format if requested if [[ "$OUTPUT_JSON" == true ]]; then - meminfo=$(grep -E 'MemTotal|MemFree|MemAvailable|SwapTotal|SwapFree' /proc/meminfo) - total_ram_kb=$(echo "$meminfo" | grep MemTotal | awk '{print $2}') - mem_free_kb=$(echo "$meminfo" | grep MemFree | awk '{print $2}') - mem_available_kb=$(echo "$meminfo" | grep MemAvailable | awk '{print $2}') - swap_total_kb=$(echo "$meminfo" | grep SwapTotal | awk '{print $2}') - swap_free_kb=$(echo "$meminfo" | grep SwapFree | awk '{print $2}') - total_ram=$(convert_ram "$total_ram_kb" "$UNIT") - mem_free=$(convert_ram "$mem_free_kb" "$UNIT") - mem_available=$(convert_ram "$mem_available_kb" "$UNIT") - swap_total=$(convert_ram "$swap_total_kb" "$UNIT") - swap_free=$(convert_ram "$swap_free_kb" "$UNIT") - cat << EOF { - "total_ram_$UNIT": "$total_ram", - "mem_free_$UNIT": "$mem_free", - "mem_available_$UNIT": "$mem_available", - "swap_total_$UNIT": "$swap_total", - "swap_free_$UNIT": "$swap_free" + "unit": "$UNIT", + "total_ram": $(convert_ram "$total_ram_kb"), + "mem_free": $(convert_ram "$mem_free_kb"), + "mem_available": $(convert_ram "$mem_available_kb"), + "swap_total": $(convert_ram "$swap_total_kb"), + "swap_free": $(convert_ram "$swap_free_kb"), + "minimum_required": $MINIMUM_RAM, + "meets_minimum": $meets_minimum, + "mem_used_percent": $mem_used_percent, + "critical": $critical } EOF +else + if [[ "$meets_minimum" == true ]]; then + echo "The system meets the minimum RAM requirements." + else + echo "The system doesn't meet the requirements. RAM size must be at least $MINIMUM_RAM $UNIT." >&2 + fi + + if (( TOP_N > 0 )); then + echo "Top $TOP_N programs by RAM usage:" + # awk reads all of ps's output, so ps never gets SIGPIPE under pipefail. + ps aux --sort=-%mem | awk -v n="$TOP_N" 'NR <= n + 1 { printf "%-10s %-8s %-5s %-5s %s\n", $1, $2, $3, $4, $11 }' + log_action "Top RAM consuming processes displayed." + fi + + if [[ "$critical" == true ]]; then + echo "Warning: Memory usage is above critical level ($CRITICAL_LEVEL%): $mem_used_percent% used." >&2 + fi fi +[[ "$meets_minimum" == true ]] + diff --git a/src/random_int.sh b/src/random_int.sh index d89b216..ba13a20 100755 --- a/src/random_int.sh +++ b/src/random_int.sh @@ -1,9 +1,11 @@ #!/usr/bin/env bash -# Script Name: rand_int.sh -# Description: Generates a random integer within a specific range, with additional options. +# Script Name: random_int.sh +# Description: Generates a random integer within a specific range (inclusive), +# with additional options. Uses bash's $RANDOM (not cryptographically +# secure); the range may span at most 2^30 values. # -# Usage: rand_int.sh [options] lower_bound upper_bound +# Usage: random_int.sh [options] lower_bound upper_bound # # Options: # -h, --help Display this help message and exit. @@ -11,71 +13,84 @@ # -c, --count N Generate N random numbers (default: 1). # # Arguments: -# lower_bound The smallest number that can be generated. +# lower_bound The smallest number that can be generated (may be negative). # upper_bound The largest number that can be generated. # # Examples: -# ./rand_int.sh 1 10 -# ./rand_int.sh --seed 42 1 100 -# ./rand_int.sh --count 5 10 20 +# ./random_int.sh 1 10 +# ./random_int.sh --seed 42 1 100 +# ./random_int.sh --count 5 -10 20 set -euo pipefail +readonly MAX_RANGE=$((1 << 30)) + function show_help() { - grep '^#' "$0" | cut -c 4- - exit 0 + # Print the header comment block (from "Script Name" up to the first non-comment line). + sed -n '3,/^[^#]/{/^#/s/^# \{0,1\}//p}' "$0" +} + +function die() { + echo "Error: $*" >&2 + exit 1 } function generate_random_integer() { local min="$1" local max="$2" local count="$3" - - if [[ "$min" -gt "$max" ]]; then - echo "Error: lower_bound cannot be greater than upper_bound." - exit 1 - fi - local range=$((max - min + 1)) + # Largest multiple of range below MAX_RANGE; values above it are rejected to avoid modulo bias. + local limit=$((MAX_RANGE - MAX_RANGE % range)) + local i rand for ((i = 0; i < count; i++)); do - local rand=$((RANDOM % range + min)) - echo "$rand" + # Combine two 15-bit $RANDOM values into a 30-bit value. + rand=$(((RANDOM << 15) | RANDOM)) + while ((rand >= limit)); do + rand=$(((RANDOM << 15) | RANDOM)) + done + echo $((rand % range + min)) done } function main() { - # Default values - SEED="" - COUNT=1 + local seed="" + local count=1 # Parse options while [[ $# -gt 0 ]]; do case "$1" in -h|--help) show_help + exit 0 ;; -s|--seed) - if [[ -n "${2:-}" && "$2" =~ ^[0-9]+$ ]]; then - SEED="$2" + if [[ "${2:-}" =~ ^[0-9]+$ ]]; then + seed=$((10#$2)) shift 2 else - echo "Error: --seed option requires a positive integer argument." - exit 1 + die "--seed option requires a non-negative integer argument." fi ;; -c|--count) - if [[ -n "${2:-}" && "$2" =~ ^[0-9]+$ && "$2" -gt 0 ]]; then - COUNT="$2" + if [[ "${2:-}" =~ ^[0-9]+$ ]] && ((10#$2 > 0)); then + count=$((10#$2)) shift 2 else - echo "Error: --count option requires a positive integer argument." - exit 1 + die "--count option requires a positive integer argument." fi ;; + --) + shift + break + ;; + -[0-9]*) + # A negative number: start of the positional arguments. + break + ;; -*) - echo "Unknown option: $1" - show_help + die "Unknown option: $1 (see --help)" ;; *) break @@ -85,30 +100,39 @@ function main() { # Check for the correct number of arguments if [[ $# -ne 2 ]]; then - echo "Error: Must provide exactly two numbers for lower_bound and upper_bound." - show_help + die "Must provide exactly two numbers for lower_bound and upper_bound (see --help)." fi local min="$1" local max="$2" # Validate that inputs are integers - if ! [[ "$min" =~ ^-?[0-9]+$ ]]; then - echo "Error: lower_bound '$min' is not an integer." - exit 1 + if ! [[ "$min" =~ ^-?[0-9]{1,18}$ ]]; then + die "lower_bound '$min' is not an integer." + fi + + if ! [[ "$max" =~ ^-?[0-9]{1,18}$ ]]; then + die "upper_bound '$max' is not an integer." + fi + + # Normalise (strip leading zeros so they aren't read as octal) + min=$(( ${min%%[0-9]*}10#${min#-} )) + max=$(( ${max%%[0-9]*}10#${max#-} )) + + if ((min > max)); then + die "lower_bound cannot be greater than upper_bound." fi - if ! [[ "$max" =~ ^-?[0-9]+$ ]]; then - echo "Error: upper_bound '$max' is not an integer." - exit 1 + if ((max - min + 1 > MAX_RANGE)); then + die "The range may contain at most $MAX_RANGE values." fi # Seed the random number generator if seed is provided - if [[ -n "$SEED" ]]; then - RANDOM="$SEED" + if [[ -n "$seed" ]]; then + RANDOM=$seed fi - generate_random_integer "$min" "$max" "$COUNT" + generate_random_integer "$min" "$max" "$count" } main "$@" diff --git a/src/random_password.sh b/src/random_password.sh index 8cf1b2c..5b6d2af 100755 --- a/src/random_password.sh +++ b/src/random_password.sh @@ -2,6 +2,7 @@ # Script Name: random_password.sh # Description: Generates a random password with various customization options. +# Randomness comes from /dev/urandom (unbiased, via rejection sampling). # # Usage: random_password.sh [options] # @@ -13,7 +14,9 @@ # -u, --uppercase Include uppercase letters in the password. # -e, --exclude CHARS Exclude specific characters from the password. # -c, --count N Generate N passwords (default: 1). -# -r, --repeat-allowed Allow characters to be repeated (default: true). +# -r, --repeat-allowed Allow characters to be repeated (default). +# --no-repeat Do not repeat any character within a password. +# Flags without a value can be combined, e.g. -snu. # # Examples: # ./random_password.sh --length 15 --special --numbers @@ -23,58 +26,74 @@ set -euo pipefail function show_help() { - grep '^#' "$0" | cut -c 4- - exit 0 + # Print the header comment block (from "Script Name" up to the first non-comment line). + sed -n '3,/^[^#]/{/^#/s/^# \{0,1\}//p}' "$0" +} + +function die() { + echo "Error: $*" >&2 + exit 1 +} + +# Print a uniformly distributed random integer in [0, $1). +function random_below() { + local n="$1" value + local limit=$(( 4294967296 - 4294967296 % n )) + while true; do + value=$(od -An -N4 -tu4 /dev/urandom) + value=${value//[[:space:]]/} + if (( value < limit )); then + echo $(( value % n )) + return + fi + done } function generate_password() { local length="$1" local charset="$2" local repeat_allowed="$3" - - if [ -z "$charset" ]; then - echo "Error: Character set is empty. Cannot generate password." - exit 1 - fi + local password="" idx i if [ "${#charset}" -lt "$length" ] && [ "$repeat_allowed" = false ]; then - echo "Error: Not enough unique characters in character set to generate a password of length $length without repeating characters." - exit 1 + die "Not enough unique characters in character set to generate a password of length $length without repeating characters." fi - local password="" - if [ "$repeat_allowed" = true ]; then - password=$(LC_ALL=C tr -dc "$charset" 0 )); then + LENGTH=$(( 10#$2 )) shift 2 else - echo "Error: --length option requires a positive integer argument." - exit 1 + die "--length option requires a positive integer argument." fi ;; -s|--special) @@ -94,17 +113,15 @@ function main() { EXCLUDE_CHARS="$2" shift 2 else - echo "Error: --exclude option requires an argument." - exit 1 + die "--exclude option requires an argument." fi ;; -c|--count) - if [[ -n "${2:-}" && "$2" =~ ^[0-9]+$ && "$2" -gt 0 ]]; then - COUNT="$2" + if [[ "${2:-}" =~ ^[0-9]+$ ]] && (( 10#$2 > 0 )); then + COUNT=$(( 10#$2 )) shift 2 else - echo "Error: --count option requires a positive integer argument." - exit 1 + die "--count option requires a positive integer argument." fi ;; -r|--repeat-allowed) @@ -115,13 +132,16 @@ function main() { REPEAT_ALLOWED=false shift ;; - -*) - echo "Unknown option: $1" - show_help + -[snur][snur]*) + # Split combined flags such as -snu into -s -n -u. + if [[ "$1" =~ ^-[snur]+$ ]]; then + set -- "${1:0:2}" "-${1:2}" "${@:2}" + else + die "Unknown option: $1 (see --help)" + fi ;; *) - echo "Unknown argument: $1" - show_help + die "Unknown option or argument: $1 (see --help)" ;; esac done @@ -135,18 +155,21 @@ function main() { charset+="0123456789" fi if [ "$INCLUDE_SPECIAL" = true ]; then - charset+="!\"#\$%&'()*+,-./:;<=>?@[\]^_\`{|}~" + charset+='!"#$%&'\''()*+,-./:;<=>?@[\]^_`{|}~' fi - # Remove excluded characters - if [ -n "$EXCLUDE_CHARS" ]; then - for (( i=0; i<${#EXCLUDE_CHARS}; i++ )); do - charset="${charset//${EXCLUDE_CHARS:i:1}/}" - done + # Remove excluded characters (quoted so they are matched literally, not as globs) + local i + for (( i = 0; i < ${#EXCLUDE_CHARS}; i++ )); do + charset="${charset//"${EXCLUDE_CHARS:i:1}"/}" + done + + if [ -z "$charset" ]; then + die "Character set is empty. Cannot generate password." fi # Generate passwords - for (( i=0; i&2 exit 1 fi ;; @@ -85,7 +86,7 @@ while [[ $# -gt 0 ]]; do NUMBER="$2" shift 2 else - echo "Error: --number requires a non-empty argument." + echo "Error: --number requires a non-empty argument." >&2 exit 1 fi ;; @@ -94,7 +95,7 @@ while [[ $# -gt 0 ]]; do TIME_TYPE="$2" shift 2 else - echo "Error: --time requires a non-empty argument." + echo "Error: --time requires a non-empty argument." >&2 exit 1 fi ;; @@ -107,7 +108,7 @@ while [[ $# -gt 0 ]]; do EXCLUDE_PATTERN="$2" shift 2 else - echo "Error: --exclude requires a non-empty argument." + echo "Error: --exclude requires a non-empty argument." >&2 exit 1 fi ;; @@ -116,7 +117,7 @@ while [[ $# -gt 0 ]]; do INCLUDE_PATTERN="$2" shift 2 else - echo "Error: --include requires a non-empty argument." + echo "Error: --include requires a non-empty argument." >&2 exit 1 fi ;; @@ -125,7 +126,7 @@ while [[ $# -gt 0 ]]; do LOG_FILE="$2" shift 2 else - echo "Error: --log-file requires a non-empty argument." + echo "Error: --log-file requires a non-empty argument." >&2 exit 1 fi ;; @@ -138,8 +139,8 @@ while [[ $# -gt 0 ]]; do exit 0 ;; *) - echo "Unknown option: $1" - usage + echo "Unknown option: $1" >&2 + usage >&2 exit 1 ;; esac @@ -147,19 +148,19 @@ done # Validate directory if [[ ! -d "$DIRECTORY" ]]; then - echo "Error: Directory '$DIRECTORY' does not exist." + echo "Error: Directory '$DIRECTORY' does not exist." >&2 exit 1 fi # Validate number -if ! [[ "$NUMBER" =~ ^[0-9]+$ ]]; then - echo "Error: The number of files to list must be a positive integer." +if ! [[ "$NUMBER" =~ ^[1-9][0-9]*$ ]]; then + echo "Error: The number of files to list must be a positive integer." >&2 exit 1 fi # Validate time type if [[ "$TIME_TYPE" != "mtime" && "$TIME_TYPE" != "atime" && "$TIME_TYPE" != "ctime" ]]; then - echo "Error: Invalid time type '$TIME_TYPE'. Must be 'mtime', 'atime', or 'ctime'." + echo "Error: Invalid time type '$TIME_TYPE'. Must be 'mtime', 'atime', or 'ctime'." >&2 exit 1 fi @@ -178,37 +179,37 @@ fi # Determine time format case "$TIME_TYPE" in - mtime) - TIME_FLAG="-printf" - TIME_FORMAT='%TY-%Tm-%Td %TT %p\n' - ;; - atime) - TIME_FLAG="-printf" - TIME_FORMAT='%AY-%Am-%Ad %AT %p\n' - ;; - ctime) - TIME_FLAG="-printf" - TIME_FORMAT='%CY-%Cm-%Cd %CT %p\n' - ;; + mtime) TIME_FORMAT='%TY-%Tm-%Td %TT %p\n' ;; + atime) TIME_FORMAT='%AY-%Am-%Ad %AT %p\n' ;; + ctime) TIME_FORMAT='%CY-%Cm-%Cd %CT %p\n' ;; esac -# Build the full command -FIND_CMD+=("$TIME_FLAG" "$TIME_FORMAT") +# Build the full command (GNU find is required for -printf) +FIND_CMD+=(-printf "$TIME_FORMAT") log "Executing command: ${FIND_CMD[*]}" -# Execute find command and sort results -if [[ "$REVERSE" == true ]]; then - SORT_ORDER="" -else - SORT_ORDER="-r" +# Execute find command and sort results (newest first unless --reverse) +SORT_OPTS=() +if [[ "$REVERSE" != true ]]; then + SORT_OPTS=(-r) fi -RESULTS=$( "${FIND_CMD[@]}" | sort $SORT_ORDER | head -n "$NUMBER" ) +# Unreadable subdirectories make find exit non-zero; report them but keep the +# results. awk (unlike head) reads all input, so sort never dies of SIGPIPE. +RESULTS=$( { "${FIND_CMD[@]}" || true; } | sort "${SORT_OPTS[@]}" | awk -v n="$NUMBER" 'NR <= n' ) # Output results -echo "Most recently modified files in '$DIRECTORY':" -echo "$RESULTS" +if [[ "$REVERSE" == true ]]; then + echo "Files in '$DIRECTORY' by $TIME_TYPE (oldest first):" +else + echo "Files in '$DIRECTORY' by $TIME_TYPE (newest first):" +fi +if [[ -n "$RESULTS" ]]; then + echo "$RESULTS" +else + echo "No matching files found." +fi # Log results log "Found files:" diff --git a/src/release_package.sh b/src/release_package.sh index 28c683f..8273cba 100755 --- a/src/release_package.sh +++ b/src/release_package.sh @@ -10,9 +10,11 @@ # -t, --test Upload the package to TestPyPI. # -p, --production Upload the package to PyPI. # -s, --skip-tests Skip running tests before building. -# -v, --version VERSION Specify the package version to release. +# -v, --version VERSION Expected package version; the release aborts if the +# built distributions have a different version. # -n, --name NAME Specify the package name. -# -c, --config FILE Specify a configuration file with default settings. +# -c, --config FILE Configuration file (key=value lines) with default +# settings; command-line options take precedence. # -e, --env VENV_PATH Specify a virtual environment to use. # -i, --interpreter PATH Specify the Python interpreter to use. # --dry-run Perform a dry run without uploading. @@ -40,10 +42,15 @@ VENV_PATH="" PYTHON_INTERPRETER="python" DRY_RUN=false -# Function to display help message +# Function to display help message (the header comment block above) function show_help() { - grep '^#' "$0" | cut -c 4- - exit 0 + awk 'NR > 2 && /^#/ { sub(/^# ?/, ""); print; next } NR > 2 { exit }' "$0" +} + +# Print an error message and exit +function die() { + echo -e "${RED}Error: $*${NC}" >&2 + exit 1 } # Function to ensure a Python package is installed @@ -55,17 +62,14 @@ function ensure_python_package() { fi } -# Function to run tests +# Function to run tests ('setup.py test' was removed from setuptools, so use pytest) function run_tests() { - if [ -f "setup.py" ]; then - echo -e "${GREEN}Running tests...${NC}" - "$PYTHON_INTERPRETER" setup.py test - elif [ -f "pytest.ini" ] || [ -d "tests" ]; then + if [ -f "pytest.ini" ] || [ -d "tests" ] || [ -d "test" ]; then echo -e "${GREEN}Running pytest...${NC}" ensure_python_package pytest "$PYTHON_INTERPRETER" -m pytest else - echo -e "${RED}No tests found.${NC}" + echo -e "${RED}No tests found.${NC}" >&2 fi } @@ -114,27 +118,37 @@ function upload_pypi() { function check_git_status() { if git rev-parse --is-inside-work-tree > /dev/null 2>&1; then if [ -n "$(git status --porcelain)" ]; then - echo -e "${RED}Uncommitted changes detected. Please commit or stash them before releasing.${NC}" - exit 1 + die "Uncommitted changes detected. Please commit or stash them before releasing." fi else - echo -e "${RED}Not a git repository. Skipping git status check.${NC}" + echo -e "${RED}Not a git repository. Skipping git status check.${NC}" >&2 fi } -# Function to confirm the package version +# Function to read the version from the built sdist (dist/-.tar.gz) +function built_version() { + local sdist + for sdist in dist/*.tar.gz; do + [ -f "$sdist" ] || continue + sdist="${sdist##*/}" + sdist="${sdist%.tar.gz}" + echo "${sdist##*-}" + return 0 + done + echo "Unknown" +} + +# Function to check and confirm the package version function confirm_version() { - local version - if [ -n "$PACKAGE_VERSION" ]; then - version="$PACKAGE_VERSION" - else - version=$("$PYTHON_INTERPRETER" setup.py --version 2>/dev/null || echo "Unknown") + local version answer + version=$(built_version) + if [ -n "$PACKAGE_VERSION" ] && [ "$version" != "$PACKAGE_VERSION" ]; then + die "Built version '$version' does not match the requested version '$PACKAGE_VERSION'." fi - echo -e "${GREEN}Current package version is: $version${NC}" - read -r -p "Is this the correct version to upload? (y/n): " confirm_version - if [ "$confirm_version" != "y" ]; then - echo -e "${RED}Please update your package version before proceeding.${NC}" - exit 1 + echo -e "${GREEN}Built package version is: $version${NC}" + read -r -p "Is this the correct version to upload? (y/n): " answer || answer="" + if [ "$answer" != "y" ]; then + die "Please update your package version before proceeding." fi } @@ -142,8 +156,14 @@ function confirm_version() { function parse_config() { if [ -f "$CONFIG_FILE" ]; then echo -e "${GREEN}Loading configuration from $CONFIG_FILE...${NC}" - while IFS='=' read -r key value; do + while IFS='=' read -r key value || [ -n "$key" ]; do + # Ignore comments/blank lines; trim whitespace and CRs around key and value + key="${key//[[:space:]]/}" + value="${value%$'\r'}" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" case "$key" in + ""|"#"*) ;; upload_to_test) UPLOAD_TO_TEST="$value" ;; upload_to_prod) UPLOAD_TO_PROD="$value" ;; skip_tests) SKIP_TESTS="$value" ;; @@ -152,20 +172,26 @@ function parse_config() { python_interpreter) PYTHON_INTERPRETER="$value" ;; venv_path) VENV_PATH="$value" ;; dry_run) DRY_RUN="$value" ;; + *) echo -e "${RED}Ignoring unknown config key: $key${NC}" >&2 ;; esac done < "$CONFIG_FILE" else - echo -e "${RED}Configuration file $CONFIG_FILE not found.${NC}" - exit 1 + die "Configuration file $CONFIG_FILE not found." fi } +# Ensure an option that needs a value got one +function require_value() { + [[ $# -ge 2 && -n "$2" ]] || die "Option $1 requires a value." +} + # Parse command-line arguments function parse_args() { while [[ $# -gt 0 ]]; do case $1 in -h|--help) show_help + exit 0 ;; -t|--test) UPLOAD_TO_TEST=true @@ -180,22 +206,27 @@ function parse_args() { shift ;; -v|--version) + require_value "$@" PACKAGE_VERSION="$2" shift 2 ;; -n|--name) + require_value "$@" PACKAGE_NAME="$2" shift 2 ;; -c|--config) - CONFIG_FILE="$2" + # Already loaded in main() before the other options + require_value "$@" shift 2 ;; -e|--env) + require_value "$@" VENV_PATH="$2" shift 2 ;; -i|--interpreter) + require_value "$@" PYTHON_INTERPRETER="$2" shift 2 ;; @@ -204,8 +235,9 @@ function parse_args() { shift ;; *) - echo -e "${RED}Unknown option: $1${NC}" - show_help + echo -e "${RED}Unknown option: $1${NC}" >&2 + show_help >&2 + exit 1 ;; esac done @@ -219,21 +251,34 @@ function activate_virtualenv() { PYTHON_INTERPRETER="$VENV_PATH/bin/python" echo -e "${GREEN}Using virtual environment at $VENV_PATH${NC}" else - echo -e "${RED}Virtual environment at $VENV_PATH not found.${NC}" - exit 1 + die "Virtual environment at $VENV_PATH not found." fi fi } # Main script execution function main() { - parse_args "$@" - - # Parse configuration file if specified + # Load the configuration file first so command-line options override it + local i + for (( i = 1; i <= $#; i++ )); do + if [[ "${!i}" == "-c" || "${!i}" == "--config" ]]; then + i=$((i + 1)) + CONFIG_FILE="${!i:-}" + fi + done if [ -n "$CONFIG_FILE" ]; then parse_config fi + parse_args "$@" + + # build_package wipes dist/ and build/, so make sure we are in a project root + if [ ! -f "pyproject.toml" ] && [ ! -f "setup.py" ] && [ ! -f "setup.cfg" ]; then + die "No pyproject.toml, setup.py or setup.cfg found; run this from the package root." + fi + + command -v "$PYTHON_INTERPRETER" >/dev/null 2>&1 || die "Python interpreter not found: $PYTHON_INTERPRETER" + # Activate virtual environment if specified activate_virtualenv @@ -258,7 +303,9 @@ function main() { check_package # Confirm version before uploading - confirm_version + if [ "$UPLOAD_TO_TEST" = true ] || [ "$UPLOAD_TO_PROD" = true ]; then + confirm_version + fi # Upload to TestPyPI or PyPI if [ "$UPLOAD_TO_TEST" = true ]; then diff --git a/src/remove_branch.sh b/src/remove_branch.sh index 40c577c..996909f 100755 --- a/src/remove_branch.sh +++ b/src/remove_branch.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Script Name: remove_branch.sh -# Description: Removes a branch from a git repository, both locally and remotely. +# Description: Removes a branch from a git repository, both locally and remotely (origin). # Usage: remove_branch.sh branch_name # branch_name - the name of the branch to remove. # Example: ./remove_branch.sh test @@ -26,21 +26,28 @@ remove_branch() { # Removes a branch, both locally and remotely # $1: branch name local branch_name="$1" + local is_remote_branch=false + local is_local_branch=false - is_remote_branch=$(git branch -r | grep -Fw "$branch_name" > /dev/null) - is_local_branch=$(git branch -l | grep -Fw "$branch_name" > /dev/null) + if git show-ref --verify --quiet "refs/remotes/origin/$branch_name"; then + is_remote_branch=true + fi + + if git show-ref --verify --quiet "refs/heads/$branch_name"; then + is_local_branch=true + fi - if [ -z "$is_remote_branch" ] && [ -z "$is_local_branch" ]; then - echo "Provided branch doesn't exist." + if ! $is_remote_branch && ! $is_local_branch; then + echo "Provided branch doesn't exist." >&2 exit 1 fi - if [ -n "$is_remote_branch" ]; then - remove_remote_branch "$branch_name" + if $is_remote_branch; then + remove_remote_branch "$branch_name" || { echo "Failed to remove remote branch '$branch_name'." >&2; exit 1; } fi - if [ -n "$is_local_branch" ]; then - remove_local_branch "$branch_name" + if $is_local_branch; then + remove_local_branch "$branch_name" || { echo "Failed to remove local branch '$branch_name'." >&2; exit 1; } fi echo "Branch '$branch_name' removed successfully." @@ -49,16 +56,17 @@ remove_branch() { main() { # Main function to orchestrate the script - if [ $# -eq 0 ]; then - echo "You have to provide the branch name!" + if [ $# -ne 1 ]; then + echo "Usage: remove_branch.sh branch_name" >&2 exit 1 - elif [ $# -eq 1 ]; then - branch_name="$1" - remove_branch "$branch_name" - else - echo "Currently not supported!" + fi + + if ! git rev-parse --git-dir > /dev/null 2>&1; then + echo "Not inside a git repository." >&2 exit 1 fi + + remove_branch "$1" } main "$@" diff --git a/src/remove_carriage_return.sh b/src/remove_carriage_return.sh index abc665f..e4c3b1f 100755 --- a/src/remove_carriage_return.sh +++ b/src/remove_carriage_return.sh @@ -1,10 +1,13 @@ #!/usr/bin/env bash # Script Name: remove_carriage_return.sh -# Description: Checks and removes the carriage return from all the files in a given directory. -# Usage: remove_carriage_return.sh [--check] directory_path -# --check - When specified, script will only check if the files contain carriage returns without actually removing them -# directory_path - the path to the directory to process. +# Description: Checks and removes the carriage return from all the text files in a given path. +# Binary files and .git directories are skipped. Files are edited in place, +# so their permissions are preserved. +# Usage: remove_carriage_return.sh [--check] path +# --check - When specified, script will only check if the files contain carriage returns without actually removing them. +# Exits with 1 if any file contains a carriage return. +# path - the file or directory to process. # Example: ./remove_carriage_return.sh --check path/to/directory checkonly=0 @@ -15,34 +18,17 @@ remove_carriage_return() { # $1: file path local file="$1" - if [[ $checkonly -ne 1 ]]; then - sed -i 's/\r//g' "$file" + if [[ $checkonly -eq 1 ]]; then + echo "File $file contains carriage return" + status=1 + elif sed -i --follow-symlinks 's/\r//g' "$file"; then + echo "Removed carriage return from $file" else - if grep -q $'\r' "$file"; then - echo "File $file contains carriage return" - return 1 - fi + echo "Failed to process $file" >&2 + status=1 fi } -process_directory() { - # Processes all files in a directory - # $1: directory path - local directory="$1" - - while IFS= read -r -d $'\0' file; do - remove_carriage_return "$file" && status=$? || status=$? - done < <(find "$directory" -type f -print0) -} - -process_single_file() { - # Processes a single file - # $1: file path - local file="$1" - - remove_carriage_return "$file" && status=$? || status=$? -} - main() { # Main function to orchestrate the script @@ -51,24 +37,29 @@ main() { shift fi - if [ $# -eq 0 ]; then - echo "Must provide a path!" + if [ $# -ne 1 ]; then + echo "Must provide exactly one path!" >&2 exit 1 fi local path="$1" + local file - if [ "$path" == '.' ] || [ -d "$path" ]; then - process_directory "$path" - elif [ -f "$path" ]; then - process_single_file "$path" - else - echo "$path is not a valid path!" + if [ ! -d "$path" ] && [ ! -f "$path" ]; then + echo "$path is not a valid path!" >&2 exit 1 fi - if [[ $checkonly -eq 1 && $status -eq 1 ]]; then - echo "One or more files contain carriage return." + # grep lists (NUL-separated) only the text files that contain a carriage return; + # -I skips binary files, -r doesn't follow symlinks found while recursing. + while IFS= read -r -d '' file; do + remove_carriage_return "$file" + done < <(grep -rlIZ --exclude-dir=.git -- $'\r' "$path") + + if [[ $status -ne 0 ]]; then + if [[ $checkonly -eq 1 ]]; then + echo "One or more files contain carriage return." + fi exit 1 fi diff --git a/src/remove_consecutive_blank_lines.sh b/src/remove_consecutive_blank_lines.sh index 675cd69..62ac8cc 100755 --- a/src/remove_consecutive_blank_lines.sh +++ b/src/remove_consecutive_blank_lines.sh @@ -1,7 +1,9 @@ #!/usr/bin/env bash # Script Name: remove_consecutive_blank_lines.sh -# Description: Removes repeated blank lines from files in a directory. +# Description: Collapses runs of consecutive blank (empty or whitespace-only) lines +# into a single blank line in every text file under a directory. +# Binary files and .git directories are skipped; file permissions are kept. # Usage: remove_consecutive_blank_lines.sh directory # directory - the path to the directory containing the files to process. # Example: ./remove_consecutive_blank_lines.sh /path/to/directory @@ -10,7 +12,7 @@ validate_arguments() { # Validates the number of arguments provided # $1: directory if [ "$#" -ne 1 ]; then - echo "Usage: $0 directory" + echo "Usage: $0 directory" >&2 exit 1 fi } @@ -20,7 +22,7 @@ validate_directory() { # $1: directory local dir="$1" if [ ! -d "$dir" ]; then - echo "Error: '$dir' is not a directory." + echo "Error: '$dir' is not a directory." >&2 exit 1 fi } @@ -29,11 +31,25 @@ remove_repeated_blank_lines() { # Removes repeated blank lines from files in the directory # $1: directory local dir="$1" + local file tmp - find "$dir" -type f -print0 | while IFS= read -r -d $'\0' file; do - awk 'BEGIN {RS="\n"; ORS="\n"; last_line=""} {if (NF == 0 && last_line == "") {next} else {print; last_line=$0}}' "$file" > "${file}.tmp" - mv "${file}.tmp" "$file" - done + tmp=$(mktemp) || exit 1 + trap 'rm -f "$tmp"' EXIT + + while IFS= read -r -d '' file; do + # Skip binary and empty files + grep -Iq . "$file" || continue + + awk 'NF == 0 { if (blank) next; blank = 1; print; next } { blank = 0; print }' "$file" > "$tmp" || { + echo "Error: failed to process '$file'." >&2 + continue + } + + # Write back in place (keeps permissions/ownership) only when something changed + if ! cmp -s "$tmp" "$file"; then + cat "$tmp" > "$file" + fi + done < <(find "$dir" -name .git -prune -o -type f -print0) } main() { diff --git a/src/remove_diacritics.sh b/src/remove_diacritics.sh index 4f85fa4..c5bedc5 100755 --- a/src/remove_diacritics.sh +++ b/src/remove_diacritics.sh @@ -1,33 +1,52 @@ #!/usr/bin/env bash # Script Name: remove_diacritics.sh -# Description: Removes the diacritics from all the files in a given directory. -# Usage: remove_diacritics.sh [] -# [] - the path to the directory to process. +# Description: Removes the diacritics (e.g. ą -> a, Ż -> Z) from a file, or from all text +# files in a directory (up to 10 levels deep), in place. +# Binary files and .git directories are skipped. Requires a UTF-8 locale. +# Usage: remove_diacritics.sh +# - the file or directory to process. # Example: ./remove_diacritics.sh path/to/directory -remove_diacritics () -{ - sed -i 'y/ąāáǎàćēéěèęīíǐìłńōóǒòóśūúǔùǖǘǚǜżźĄĀÁǍÀĆĒĘÉĚÈĪÍǏÌŁŃŌÓǑÒÓŚŪÚǓÙǕǗǙǛŻŹ/aaaaaceeeeeiiiilnooooosuuuuuuuuzzAAAAACEEEEEIIIILNOOOOOSUUUUUUUUZZ/' "$1" +FROM='ąāáǎàćēéěèęīíǐìłńōóǒòóśūúǔùǖǘǚǜżźĄĀÁǍÀĆĒĘÉĚÈĪÍǏÌŁŃŌÓǑÒÓŚŪÚǓÙǕǗǙǛŻŹ' +TO='aaaaaceeeeeiiiilnooooosuuuuuuuuzzAAAAACEEEEEIIIILNOOOOOSUUUUUUUUZZ' + +remove_diacritics() { + local file="$1" + + # Skip binary files, and files without any of the characters (avoids needless rewrites) + grep -Iq . "$file" || return 0 + grep -q "[$FROM]" "$file" || return 0 + + sed -i "y/$FROM/$TO/" "$file" } main() { - if [ $# -eq 0 ]; then - echo "Must provide a path!" + echo "Must provide a path!" >&2 + echo "Usage: remove_diacritics.sh " >&2 exit 1 fi - if [ "$1" == '.' ] || [ -d "${1}" ]; then - find "$1" -maxdepth 10 -type f -print0 | while IFS= read -r -d '' file; do - remove_diacritics "$file" - done - elif [ -f "${1}" ]; then - remove_diacritics "$1" + # sed's y command needs a multibyte-aware locale to treat each accented letter as one character + if [[ $(locale charmap 2>/dev/null) != "UTF-8" ]]; then + echo "Error: a UTF-8 locale is required (e.g. LC_ALL=C.UTF-8)." >&2 + exit 1 + fi + + local status=0 + if [ -d "$1" ]; then + while IFS= read -r -d '' file; do + remove_diacritics "$file" || status=1 + done < <(find "$1" -maxdepth 10 -name .git -prune -o -type f -print0) + elif [ -f "$1" ]; then + remove_diacritics "$1" || status=1 else - echo "$1 is not a valid path!" + echo "$1 is not a valid path!" >&2 + exit 1 fi + exit "$status" } main "$@" diff --git a/src/remove_duplicate_lines.sh b/src/remove_duplicate_lines.sh index 5c1829c..a78cac9 100755 --- a/src/remove_duplicate_lines.sh +++ b/src/remove_duplicate_lines.sh @@ -1,30 +1,32 @@ #!/usr/bin/env bash # Script Name: remove_duplicate_lines.sh -# Description: Removes duplicate lines from a given file. -# Usage: remove_duplicate_lines.sh [] -# [] - the path to the file to process. +# Description: Removes duplicate lines from a given file in place, keeping the first +# occurrence of each line. Blank (empty or whitespace-only) lines are kept. +# Usage: remove_duplicate_lines.sh +# - the path to the file to process. # Example: ./remove_duplicate_lines.sh path/to/file main() { if [ $# -eq 0 ]; then - echo "You must provide a file path!" + echo "You must provide a file path!" >&2 exit 1 fi if [ ! -f "$1" ]; then - echo "$1 is not a valid file path!" + echo "$1 is not a valid file path!" >&2 exit 1 fi local file_path=$1 - local file_name - file_name=$(basename "$file_path") - temp_name="$file_name""$(date '+%Y-%m-%d')".tmp + # Global (not local) so the EXIT trap can still see it after main returns + temp_file=$(mktemp) || exit 1 + trap 'rm -f "$temp_file"' EXIT - awk '/^\s*$/||!seen[$0]++' "$file_path" > "$temp_name" - mv "$temp_name" "$file_path" + awk '/^[[:space:]]*$/ || !seen[$0]++' "$file_path" > "$temp_file" || exit 1 + # Write back through the existing file to keep its permissions and ownership + cat "$temp_file" > "$file_path" || exit 1 } main "$@" diff --git a/src/remove_duplicates_in_array.sh b/src/remove_duplicates_in_array.sh index 4fbd473..1137083 100755 --- a/src/remove_duplicates_in_array.sh +++ b/src/remove_duplicates_in_array.sh @@ -1,25 +1,27 @@ #!/usr/bin/env bash # Script Name: remove_duplicates_in_array.sh -# Description: Script to remove duplicates in an array. +# Description: Script to remove duplicates in an array, keeping the first +# occurrence of each value in its original order. # Usage: remove_duplicates_in_array.sh value1 value2 ... # value1, value2, ... - Values to remove duplicates from. # Example: remove_duplicates_in_array.sh a b c d a b c d # Output: a b c d remove_duplicates() { - local array=("$@") - local -A histogram=() - - for element in "${array[@]}"; do - ((histogram[$element]++)) - done - - for element in $(printf '%s\n' "${!histogram[@]}" | sort); do - echo -n "$element " + local -A seen=() + local unique=() + local element + + for element in "$@"; do + # Prefix the key so that an empty string is also a valid key. + if [[ -z "${seen["_$element"]:-}" ]]; then + seen["_$element"]=1 + unique+=("$element") + fi done - echo + echo "${unique[*]}" } validate_arguments() { @@ -27,9 +29,11 @@ validate_arguments() { # Arguments: # $1: The number of arguments provided if [ "$1" -eq 0 ]; then - echo "Usage: remove_duplicates_in_array.sh value1 value2 ..." - echo " value1, value2, ... - Values to remove duplicates from." - echo "Example: remove_duplicates_in_array.sh a b c d a b c d" + { + echo "Usage: remove_duplicates_in_array.sh value1 value2 ..." + echo " value1, value2, ... - Values to remove duplicates from." + echo "Example: remove_duplicates_in_array.sh a b c d a b c d" + } >&2 exit 1 fi } diff --git a/src/remove_empty_lines.sh b/src/remove_empty_lines.sh index dbf9339..db9e471 100755 --- a/src/remove_empty_lines.sh +++ b/src/remove_empty_lines.sh @@ -1,30 +1,24 @@ #!/usr/bin/env bash # Script Name: remove_empty_lines.sh -# Description: Removes all that contain only whitespaces in a given file. -# Usage: remove_empty_lines.sh [] -# [] - the path to the file to process. +# Description: Removes all lines that are empty or contain only whitespace from a given file (in place). +# Usage: remove_empty_lines.sh +# - the path to the file to process. # Example: ./remove_empty_lines.sh path/to/file main() { - if [ $# -eq 0 ]; then - echo "You must provide a file path!" + if [ $# -ne 1 ]; then + echo "Usage: remove_empty_lines.sh " >&2 exit 1 fi if [ ! -f "$1" ]; then - echo "$1 is not a valid file path!" + echo "$1 is not a valid file path!" >&2 exit 1 fi - local file_path=$1 - local file_name - file_name=$(basename "$file_path") - temp_name="$file_name""$(date '+%Y-%m-%d')".tmp - - awk 'NF' "$file_path" > "$temp_name" - mv "$temp_name" "$file_path" - + # -i edits in place, keeping the file's permissions and ownership. + sed -i '/^[[:space:]]*$/d' "$1" } main "$@" diff --git a/src/remove_leading_whitespace.sh b/src/remove_leading_whitespace.sh index 02ba11a..001c1e9 100755 --- a/src/remove_leading_whitespace.sh +++ b/src/remove_leading_whitespace.sh @@ -2,13 +2,14 @@ # Script Name: remove_leading_whitespace.sh # Description: Removes leading whitespace (spaces or tabs) from each line of a file or piped input. +# With a file argument the file is edited in place; without one, stdin is cleaned to stdout. # Usage: ./remove_leading_whitespace.sh filename # cat file | ./remove_leading_whitespace.sh set -euo pipefail if [ "$#" -eq 1 ]; then - # File mode: clean file in place + # File mode: clean file in place (sed -i keeps permissions and ownership) file="$1" if [ ! -f "$file" ]; then echo "Error: File '$file' does not exist." >&2 @@ -18,18 +19,13 @@ if [ "$#" -eq 1 ]; then echo "Error: File '$file' is not writable." >&2 exit 3 fi - tmpfile=$(mktemp --tmpdir "rlw.XXXXXX") - trap 'rm -f "$tmpfile"' EXIT - if sed 's/^[ \t]*//' "$file" > "$tmpfile"; then - mv -- "$tmpfile" "$file" - trap - EXIT - else + if ! sed -i 's/^[[:blank:]]*//' "$file"; then echo "Error: Failed to process file." >&2 exit 4 fi elif [ "$#" -eq 0 ]; then # Pipe mode: clean stdin, output to stdout - sed 's/^[ \t]*//' + sed 's/^[[:blank:]]*//' else echo "Usage: $0 [filename]" >&2 exit 1 diff --git a/src/remove_n_last_commits.sh b/src/remove_n_last_commits.sh index ee99ee3..1401680 100755 --- a/src/remove_n_last_commits.sh +++ b/src/remove_n_last_commits.sh @@ -1,45 +1,48 @@ #!/usr/bin/env bash # Script Name: remove_n_last_commits.sh -# Description: Removes the last n commits from a git repository. +# Description: Removes the last n commits from a git branch (git reset --hard) and, +# if an 'origin' remote exists, force-pushes the branch to it +# (--force-with-lease). Refuses to run with uncommitted changes. # Usage: remove_n_last_commits.sh number_of_commits [branch_name] [directory_path] # number_of_commits - the number of commits to remove. # branch_name - the name of the branch to remove commits from (optional, defaults to current branch). # directory_path - the path to the git repository (optional, defaults to current working directory). # Example: ./remove_n_last_commits.sh 10 my_branch /path/to/repository +die() { + echo "Error: $*" >&2 + exit 1 +} + validate_arguments() { - # Validates the number of arguments provided - # Arguments: - # $1: The number of arguments provided - if [ "$1" -lt 1 ] || [ "$1" -gt 3 ]; then - echo "Usage: remove_n_last_commits.sh number_of_commits [branch_name] [directory_path]" - echo " number_of_commits - the number of commits to remove." - echo " branch_name - the name of the branch to remove commits from (optional, defaults to current branch)." - echo " directory_path - the path to the git repository (optional, defaults to current working directory)." + # Validates the arguments provided + if [ "$#" -lt 1 ] || [ "$#" -gt 3 ]; then + echo "Usage: remove_n_last_commits.sh number_of_commits [branch_name] [directory_path]" >&2 + echo " number_of_commits - the number of commits to remove." >&2 + echo " branch_name - the name of the branch to remove commits from (optional, defaults to current branch)." >&2 + echo " directory_path - the path to the git repository (optional, defaults to current working directory)." >&2 exit 1 fi + + if ! [[ "$1" =~ ^[1-9][0-9]*$ ]]; then + die "number_of_commits must be a positive integer, got '$1'." + fi } set_working_directory() { - # Sets the working directory to the specified path or the current working directory + # Sets the working directory to the specified path # Arguments: - # $1: The path to the working directory (optional) - if [ -n "$1" ]; then - local working_dir="$1" - if [ ! -d "$working_dir" ]; then - echo "The specified directory '$working_dir' does not exist." - exit 1 - fi - cd "$working_dir" || exit - fi + # $1: The path to the working directory + local working_dir="$1" + [ -d "$working_dir" ] || die "The specified directory '$working_dir' does not exist." + cd "$working_dir" || die "Cannot change to directory '$working_dir'." } check_git_repository() { # Checks if the current directory is a Git repository if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then - echo "Not inside a Git repository! Please provide a correct path." - exit 1 + die "Not inside a Git repository! Please provide a correct path." fi } @@ -49,8 +52,7 @@ check_branch_exists() { # $1: The name of the branch to check local branch_name="$1" if ! git show-ref --verify --quiet "refs/heads/$branch_name"; then - echo "The specified branch '$branch_name' does not exist." - exit 1 + die "The specified branch '$branch_name' does not exist." fi } @@ -60,30 +62,44 @@ remove_n_last_commits() { # $1: The number of commits to remove # $2: The name of the branch to remove commits from local number_of_commits="$1" - local branch_name="${2:-$(git symbolic-ref --short HEAD)}" + local branch_name="$2" + + # git reset --hard would silently destroy uncommitted work + if [ -n "$(git status --porcelain --untracked-files=no)" ]; then + die "The working tree has uncommitted changes. Commit or stash them first." + fi - git checkout "$branch_name" - git reset --hard HEAD~"$number_of_commits" - git push -f origin "$branch_name" + git checkout "$branch_name" || die "Failed to check out '$branch_name'." + + if ! git rev-parse --verify --quiet "HEAD~${number_of_commits}^{commit}" >/dev/null; then + die "Branch '$branch_name' has fewer than $((number_of_commits + 1)) commits." + fi + + git reset --hard "HEAD~${number_of_commits}" || die "git reset failed." + + if git remote get-url origin >/dev/null 2>&1; then + git push --force-with-lease origin "$branch_name" || die "Force push to origin failed." + else + echo "No 'origin' remote; skipping push." + fi } main() { - validate_arguments "$#" + validate_arguments "$@" local number_of_commits="$1" - local branch_name - local working_dir - - if [ "$#" -ge 2 ]; then - branch_name="$2" - fi + local branch_name="${2:-}" if [ "$#" -eq 3 ]; then - working_dir="$3" - set_working_directory "$working_dir" + set_working_directory "$3" fi check_git_repository + + if [ -z "$branch_name" ]; then + branch_name=$(git symbolic-ref --quiet --short HEAD) || die "HEAD is detached; please specify a branch name." + fi + check_branch_exists "$branch_name" remove_n_last_commits "$number_of_commits" "$branch_name" } diff --git a/src/remove_trailing_whitespaces.sh b/src/remove_trailing_whitespaces.sh index 139c1ba..01e6da0 100755 --- a/src/remove_trailing_whitespaces.sh +++ b/src/remove_trailing_whitespaces.sh @@ -1,50 +1,37 @@ #!/usr/bin/env bash # Script Name: remove_trailing_whitespaces.sh -# Description: This script removes trailing whitespaces from all files in the provided directory. -# Usage: remove_trailing_whitespaces.sh [--check] directory_path +# Description: This script removes trailing whitespaces from all text files in the provided path. +# Binary files and .git directories are skipped. Files are edited in place, +# so their permissions are preserved. +# Usage: remove_trailing_whitespaces.sh [--check] path # --check: Check for trailing whitespaces without actually modifying the files. -# directory_path: The path to the directory to be processed. If not provided, an error will be raised. -# Usage: ./remove_trailing_whitespaces.sh [--check] path/to/directory +# Exits with 1 if any file contains trailing whitespaces. +# path: The file or directory to be processed. If not provided, an error will be raised. +# Example: ./remove_trailing_whitespaces.sh --check path/to/directory -# Global variables checkonly=0 status=0 -scriptname=$(basename "$0") -remove_trailing_whitespaces() { - local file="$1" - - echo "Checking each line of ${file} for trailing whitespaces..." - - if [[ $checkonly -eq 0 ]]; then - touch "${file}".tmp - fi +# Prints (NUL-separated) every text file under $1 that has a line ending in whitespace. +# -I skips binary files, -r doesn't follow symlinks found while recursing. +find_offending_files() { + grep -rIlZE --exclude-dir=.git -- '[[:space:]]$' "$1" +} - while IFS= read -r line; do - if [[ $line == *[[:space:]] ]]; then - echo "Found trailing whitespaces in line: ${line}" - if [[ $checkonly -eq 0 ]]; then - # Remove all trailing whitespace using parameter expansion: - # ${line##*[![:space:]]} finds trailing whitespace (everything after last non-space char) - # ${line%...} removes that trailing portion from line - echo "${line%"${line##*[![:space:]]}"}" >> "${file}".tmp - else - status=1 - fi - else - if [[ $checkonly -eq 0 ]]; then - echo "${line}" >> "${file}".tmp - fi - fi - done < <(grep '' "${file}") +process_file() { + local file="$1" + local lines - if [[ $checkonly -eq 0 ]]; then - mv "${file}".tmp "${file}" - echo "Done!" + if [[ $checkonly -eq 1 ]]; then + lines=$(grep -nE '[[:space:]]$' "$file" | cut -d: -f1 | paste -sd, -) + echo "Trailing whitespaces found in ${file} (line(s): ${lines})" + status=1 + elif sed -i --follow-symlinks 's/[[:space:]]*$//' "$file"; then + echo "Removed trailing whitespaces from ${file}" else - # Clean up any .tmp file that may have been created accidentally - [[ -f "${file}".tmp ]] && rm -f "${file}".tmp + echo "Failed to process ${file}" >&2 + status=1 fi } @@ -55,31 +42,29 @@ main() { fi if [ $# -eq 0 ]; then - echo "Must provide a path!" + echo "Must provide a path!" >&2 exit 1 elif [ $# -gt 1 ]; then - echo "Only one path is supported!" + echo "Only one path is supported!" >&2 exit 1 fi local path="$1" + local file - if [ "$path" == '.' ] || [ -d "$path" ]; then - while IFS= read -r -d '' file; do - remove_trailing_whitespaces "$file" - done < <(find "$path" -maxdepth 10 -type f ! -name "*.tmp" ! -regex ".*/$(basename "$0")" -print0) - elif [ -f "$path" ]; then - if [ "$(basename "$path")" != "$scriptname" ]; then - remove_trailing_whitespaces "$path" - fi - else - echo "$path is not a valid path!" + if [ ! -d "$path" ] && [ ! -f "$path" ]; then + echo "$path is not a valid path!" >&2 exit 1 fi - if [[ $status -eq 1 ]]; then - exit 1 + while IFS= read -r -d '' file; do + process_file "$file" + done < <(find_offending_files "$path") + + if [[ $status -eq 0 ]]; then + echo "Trailing whitespaces checked successfully." fi + exit "$status" } main "$@" diff --git a/src/rename_extension.sh b/src/rename_extension.sh index 1122df6..fee3152 100755 --- a/src/rename_extension.sh +++ b/src/rename_extension.sh @@ -1,70 +1,54 @@ #!/usr/bin/env bash # Script Name: rename_extension.sh -# Description: Renames the file extension of files in a given directory. -# Usage: ./rename_extension.sh -# Example: rename_extension "file.txt" ".md" +# Description: Renames the file extension of all files (recursively) in a given directory. +# Extensions may be given with or without the leading dot. Existing files are never overwritten. +# Usage: ./rename_extension.sh +# Example: ./rename_extension.sh path/to/dir .txt .md rename_extension() { # $1: file name - # $3: new extension - - # the number of arguments must be 2 - if [ $# -ne 2 ]; then - echo "Usage: rename_extension " - return 1 - fi + # $2: old extension (with leading dot) + # $3: new extension (with leading dot) local file_name="$1" - local new_extension="$2" - local old_extension - local new_file_name - - old_extension=$([[ "$file_name" = *.* ]] && echo ".${file_name##*.}" || echo '') - new_file_name="${file_name%"$old_extension"}$new_extension" + local new_file_name="${file_name%"$2"}$3" - mv "$file_name" "$new_file_name" - echo "Renamed $file_name to $new_file_name" -} - - -find_files_with_extension() { - # $1: search directory - # $2: extension - - # the number of arguments must be 1 - if [ $# -ne 2 ]; then - echo "Usage: find_files_with_extension " + if [ -e "$new_file_name" ]; then + echo "Skipping $file_name: $new_file_name already exists" >&2 return 1 fi - local search_directory="$1" - local extension="$2" - - find "$search_directory" -type f -name "*$extension" + mv -- "$file_name" "$new_file_name" && echo "Renamed $file_name to $new_file_name" } - rename_files_with_extension() { # $1: search directory # $2: extension # $3: new extension - # the number of arguments must be 2 if [ $# -ne 3 ]; then - echo "Usage: rename_files_with_extension " + echo "Usage: rename_extension.sh " >&2 return 1 fi local search_directory="$1" - local extension="$2" - local new_extension="$3" + local extension=".${2#.}" + local new_extension=".${3#.}" + local status=0 + local file_name - find_files_with_extension "$search_directory" "$extension" | while read -r file_name; do - rename_extension "$file_name" "$new_extension" - done -} + if [ ! -d "$search_directory" ]; then + echo "Error: $search_directory is not a directory" >&2 + return 1 + fi + while IFS= read -r -d '' file_name; do + rename_extension "$file_name" "$extension" "$new_extension" || status=1 + done < <(find "$search_directory" -type f -name "*$extension" -print0) + + return "$status" +} rename_files_with_extension "$@" diff --git a/src/replace_everywhere.sh b/src/replace_everywhere.sh index 7aeb2e4..e2c795f 100755 --- a/src/replace_everywhere.sh +++ b/src/replace_everywhere.sh @@ -1,12 +1,14 @@ #!/usr/bin/env bash # Script Name: replace_everywhere.sh -# Description: Replaces string a with string b in all files in the current directory and subdirectories. -# Skips hidden directories by default. Optionally excludes specific subdirectories. +# Description: Replaces string a with string b (literally, not as a regex) in all text files +# in the current directory and subdirectories. Binary files and files that +# don't contain string a are left untouched. +# Skips hidden files and directories by default. Optionally excludes specific subdirectories. # Usage: ./replace_everywhere.sh [options] # Options: # -x, --exclude DIR Exclude a subdirectory (repeatable). -# --include-hidden Include hidden directories (like .git, .venv). +# --include-hidden Include hidden files and directories (like .git, .venv). # -y, --yes Skip confirmation prompt. # -h, --help Show help and exit. # Examples: @@ -16,7 +18,8 @@ set -euo pipefail print_usage() { - sed -n '2,45p' "$0" + # Print the header comment block (everything up to the first non-comment line) + sed -n '3,/^[^#]/{/^#/s/^# \{0,1\}//p}' "$0" } # Escape a string for use as a *literal* sed pattern (s/…/…/) @@ -29,6 +32,7 @@ escape_sed_replacement() { local s=$1 s=${s//\\/\\\\} # escape backslashes first s=${s//&/\\&} # then ampersands + s=${s//\//\\/} # and the '/' delimiter printf '%s' "$s" } @@ -65,6 +69,15 @@ main() { local search_raw="$1" local replace_raw="$2" + if [[ -z "$search_raw" ]]; then + echo "Error: the search string must not be empty." >&2 + exit 1 + fi + if [[ "$search_raw" == *$'\n'* || "$replace_raw" == *$'\n'* ]]; then + echo "Error: multi-line strings are not supported." >&2 + exit 1 + fi + local search replace search=$(escape_sed_pattern "$search_raw") replace=$(escape_sed_replacement "$replace_raw") @@ -89,26 +102,37 @@ main() { # Build the find prune predicates local -a PRUNE_BLOCK=() if [[ "$include_hidden" == false ]]; then - PRUNE_BLOCK+=( -path './.*' -prune -o ) + PRUNE_BLOCK+=( -name '.*' ! -name . -prune -o ) fi if [[ ${#excludes[@]} -gt 0 ]]; then for d in "${excludes[@]}"; do + d="${d%/}" [[ "$d" != ./* ]] && d="./$d" PRUNE_BLOCK+=( -path "$d" -prune -o ) done fi - # Execute replacement - if [[ ${#PRUNE_BLOCK[@]} -gt 0 ]]; then - find . \( "${PRUNE_BLOCK[@]}" -false \) -o -type f \ - -exec sed "${SED_INPLACE[@]}" "s/${search}/${replace}/g" {} + - else - find . -type f \ - -exec sed "${SED_INPLACE[@]}" "s/${search}/${replace}/g" {} + + # Only rewrite text files that actually contain the search string + # (grep -I skips binary files, -F matches literally, -Z/-0 handle any file name) + local -a find_cmd=(find . \( "${PRUNE_BLOCK[@]}" -false \) -o -type f -exec grep -lIZF -e "$search_raw" -- {} +) + local -a files=() + local f + while IFS= read -r -d '' f; do + files+=("$f") + done < <("${find_cmd[@]}" 2>/dev/null) + + if [[ ${#files[@]} -eq 0 ]]; then + echo "No files contain '$search_raw'." + exit 0 fi - echo "Done." + for f in "${files[@]}"; do + sed "${SED_INPLACE[@]}" "s/${search}/${replace}/g" "$f" + echo "Updated: $f" + done + + echo "Done. ${#files[@]} file(s) updated." } main "$@" diff --git a/src/reset_to_origin.sh b/src/reset_to_origin.sh index 4220c5a..ed96a04 100755 --- a/src/reset_to_origin.sh +++ b/src/reset_to_origin.sh @@ -2,39 +2,27 @@ # Script Name: reset_to_origin.sh # Description: Resets the local repository to match the remote repository. -# Usage: reset_to_origin.sh branch_name repo_path +# WARNING: discards all local changes and commits on the branch and +# runs `git clean -fdx`, which also deletes untracked AND ignored +# files (build output, .env files, node_modules, ...). +# Usage: reset_to_origin.sh branch_name [repo_path] # branch_name - the name of the branch to reset. -# repo_path - the path to the repository. +# repo_path - the path to the repository (default: current directory). # Example: ./reset_to_origin.sh master . validate_arguments() { if [ $# -eq 0 ]; then - echo "You have to specify the branch name!" + echo "You have to specify the branch name!" >&2 exit 1 fi - if [ $# -eq 1 ] || [ $# -eq 2 ]; then - local branch_name="$1" - local is_remote_branch - local is_local_branch - - is_remote_branch=$(git branch -r | grep -Fw "$branch_name" > /dev/null) - is_local_branch=$(git branch -l | grep -Fw "$branch_name" > /dev/null) - - if [ "$is_remote_branch" -ne 0 ] && [ "$is_local_branch" -ne 0 ]; then - echo "The specified branch doesn't exist." - exit 1 - fi - - if [ $# -eq 2 ]; then - local working_dir="$2" - if [ ! -d "$working_dir" ]; then - echo "$working_dir is not a directory." - exit 1 - fi - fi - else - echo "You can't specify more than 2 parameters!" + if [ $# -gt 2 ]; then + echo "You can't specify more than 2 parameters!" >&2 + exit 1 + fi + + if [ $# -eq 2 ] && [ ! -d "$2" ]; then + echo "$2 is not a directory." >&2 exit 1 fi } @@ -43,11 +31,22 @@ reset_to_origin() { local branch_name="$1" local working_dir="${2:-.}" - cd "$working_dir" || exit + cd "$working_dir" || exit 1 + + if ! git rev-parse --git-dir > /dev/null 2>&1; then + echo "$working_dir is not a git repository." >&2 + exit 1 + fi + + git fetch origin || exit 1 + + if ! git show-ref --verify --quiet "refs/remotes/origin/$branch_name"; then + echo "The branch '$branch_name' doesn't exist on origin." >&2 + exit 1 + fi - git fetch origin - git checkout "$branch_name" - git reset --hard origin/"$branch_name" + git checkout "$branch_name" || exit 1 + git reset --hard "origin/$branch_name" || exit 1 git clean -fdx } diff --git a/src/resize_to_a4.sh b/src/resize_to_a4.sh index 872ae60..3742345 100755 --- a/src/resize_to_a4.sh +++ b/src/resize_to_a4.sh @@ -1,9 +1,12 @@ #!/usr/bin/env bash # Script Name: resize_to_a4.sh -# Description: Resize image files in a specified directory to a target dimension with various options. +# Description: Resize image files in a specified directory to a target dimension +# (A4 at 300 DPI, 2480x3508, by default) with various options. +# Resized copies go to ./resized unless --overwrite is given. # Usage: ./resize_to_a4.sh [options] -# Dependencies: Requires ImageMagick's 'convert' command. +# Run with --help for the list of options. +# Dependencies: Requires ImageMagick ('magick' or 'convert'); GNU Parallel for --threads > 1. set -euo pipefail IFS=$'\n\t' @@ -12,7 +15,7 @@ IFS=$'\n\t' TARGET_WIDTH=2480 TARGET_HEIGHT=3508 INPUT_DIR="." -OUTPUT_DIR="." +OUTPUT_DIR="./resized" IMAGE_FORMATS=("jpg" "jpeg") OVERWRITE=false PRESERVE_ASPECT_RATIO=false @@ -26,20 +29,25 @@ usage() { cat < 1 needs GNU Parallel) -h, --help Display this help message and exit EOF } +die() { + echo "Error: $*" >&2 + exit 1 +} + # Log function log() { local msg="$1" @@ -51,33 +59,35 @@ log() { fi } -# Check for ImageMagick's 'convert' command -if ! command -v convert >/dev/null 2>&1; then - echo "This script requires ImageMagick's 'convert'. Please install it and rerun the script." - exit 1 -fi +# Ensure an option received a value. +require_value() { + [[ -n "${2:-}" ]] || die "Option $1 requires a value." +} # Parse command-line arguments while [[ "$#" -gt 0 ]]; do case "$1" in -i|--input-dir) + require_value "$1" "${2:-}" INPUT_DIR="$2" shift 2 ;; -o|--output-dir) + require_value "$1" "${2:-}" OUTPUT_DIR="$2" shift 2 ;; -s|--size) + require_value "$1" "${2:-}" if [[ "$2" =~ ^[0-9]+x[0-9]+$ ]]; then IFS='x' read -r TARGET_WIDTH TARGET_HEIGHT <<< "$2" else - echo "Invalid size format. Use WIDTHxHEIGHT (e.g., 2480x3508)." - exit 1 + die "Invalid size format. Use WIDTHxHEIGHT (e.g., 2480x3508)." fi shift 2 ;; -f|--formats) + require_value "$1" "${2:-}" IFS=',' read -r -a IMAGE_FORMATS <<< "$2" shift 2 ;; @@ -98,15 +108,16 @@ while [[ "$#" -gt 0 ]]; do shift ;; -l|--log-file) + require_value "$1" "${2:-}" LOG_FILE="$2" shift 2 ;; -t|--threads) - if [[ "$2" =~ ^[0-9]+$ ]]; then + require_value "$1" "${2:-}" + if [[ "$2" =~ ^[1-9][0-9]*$ ]]; then THREADS="$2" else - echo "Invalid number of threads. Please specify an integer." - exit 1 + die "Invalid number of threads. Please specify a positive integer." fi shift 2 ;; @@ -115,100 +126,114 @@ while [[ "$#" -gt 0 ]]; do exit 0 ;; *) - echo "Unknown option: $1" - usage + echo "Unknown option: $1" >&2 + usage >&2 exit 1 ;; esac done +# Check for ImageMagick (v7 'magick', or v6 'convert') +if command -v magick >/dev/null 2>&1; then + IM_CMD=magick +elif command -v convert >/dev/null 2>&1; then + IM_CMD=convert +else + die "This script requires ImageMagick ('magick' or 'convert'). Please install it and rerun the script." +fi + +# Check for GNU Parallel if threads > 1 +if [ "$THREADS" -gt 1 ] && ! command -v parallel >/dev/null 2>&1; then + die "GNU Parallel is not installed. Please install it or set threads to 1." +fi + # Validate input directory if [ ! -d "$INPUT_DIR" ]; then - echo "Input directory '$INPUT_DIR' does not exist." - exit 1 + die "Input directory '$INPUT_DIR' does not exist." fi -# Create output directory if it doesn't exist -if [ "$OVERWRITE" = false ] && [ ! -d "$OUTPUT_DIR" ]; then +# Create output directory if needed, and refuse to silently overwrite originals +if [ "$OVERWRITE" = false ]; then mkdir -p -- "$OUTPUT_DIR" + if [ "$(cd -- "$INPUT_DIR" && pwd -P)" = "$(cd -- "$OUTPUT_DIR" && pwd -P)" ]; then + die "Output directory is the input directory; use --overwrite to replace the originals." + fi fi -# Find image files +# Find image files (top level of INPUT_DIR only; -iname is case-insensitive) FILES=() for format in "${IMAGE_FORMATS[@]}"; do while IFS= read -r -d '' file; do FILES+=("$file") - done < <(find "$INPUT_DIR" -type f \( -iname "*.${format}" -o -iname "*.${format^^}" \) -print0) + done < <(find "$INPUT_DIR" -maxdepth 1 -type f -iname "*.${format}" -print0) done # Check if there are any image files if [ "${#FILES[@]}" -eq 0 ]; then - echo "No image files found in '$INPUT_DIR' with formats: ${IMAGE_FORMATS[*]}." - exit 1 + die "No image files found in '$INPUT_DIR' with formats: $(IFS=','; echo "${IMAGE_FORMATS[*]}")." fi # Function to resize images resize_image() { local file="$1" local output_file="$2" - local options=() + local geometry="${TARGET_WIDTH}x${TARGET_HEIGHT}" - if [ "$PRESERVE_ASPECT_RATIO" = true ]; then - options+=("-resize" "${TARGET_WIDTH}x${TARGET_HEIGHT}") - else - options+=("-resize" "${TARGET_WIDTH}x${TARGET_HEIGHT}!") + if [ "$PRESERVE_ASPECT_RATIO" = false ]; then + geometry+="!" fi log "Processing '$file'..." if [ "$BACKUP" = true ] && [ "$OVERWRITE" = true ]; then - cp -- "$file" "${file}.bak" + if ! cp -- "$file" "${file}.bak"; then + echo "Error: could not back up '$file'; skipping it." >&2 + return 1 + fi log "Backup created for '$file'." fi - if ! convert "$file" "${options[@]}" "$output_file"; then - log "Error resizing '$file'." + if ! "$IM_CMD" "$file" -resize "$geometry" "$output_file"; then + echo "Error resizing '$file'." >&2 + if [ -n "$LOG_FILE" ]; then + echo "Error resizing '$file'." >> "$LOG_FILE" + fi return 1 fi log "Successfully resized '$file' -> '$output_file'." } -# Export variables and functions for GNU Parallel -export TARGET_WIDTH TARGET_HEIGHT PRESERVE_ASPECT_RATIO BACKUP OVERWRITE LOG_FILE VERBOSE -export -f resize_image log - -# Check for GNU Parallel if threads > 1 -if [ "$THREADS" -gt 1 ]; then - if ! command -v parallel >/dev/null 2>&1; then - echo "GNU Parallel is not installed. Please install it or set threads to 1." - exit 1 +# Print "inputoutput" for a file +output_path_for() { + if [ "$OVERWRITE" = true ]; then + printf '%s\t%s\n' "$1" "$1" + else + printf '%s\t%s\n' "$1" "$OUTPUT_DIR/$(basename -- "$1")" fi -fi +} # Process images +failures=0 if [ "$THREADS" -gt 1 ]; then - # Use a here-string to safely pass arguments to parallel, handling spaces and special characters - parallel -j "$THREADS" --bar --colsep '\t' resize_image :::: <( - for file in "${FILES[@]}"; do - if [ "$OVERWRITE" = true ]; then - printf '%s\t%s\n' "$file" "$file" - else - filename="$(basename -- "$file")" - printf '%s\t%s\n' "$file" "$OUTPUT_DIR/$filename" - fi - done - ) + # Export variables and functions for GNU Parallel + export TARGET_WIDTH TARGET_HEIGHT PRESERVE_ASPECT_RATIO BACKUP OVERWRITE LOG_FILE VERBOSE IM_CMD + export -f resize_image log + for file in "${FILES[@]}"; do + output_path_for "$file" + done | parallel -j "$THREADS" --bar --colsep '\t' resize_image || failures=$? else for file in "${FILES[@]}"; do - filename="$(basename -- "$file")" - if [ "$OVERWRITE" = true ]; then - output_file="$file" - else - output_file="$OUTPUT_DIR/$filename" + output_file="$file" + if [ "$OVERWRITE" = false ]; then + output_file="$OUTPUT_DIR/$(basename -- "$file")" fi - resize_image "$file" "$output_file" + resize_image "$file" "$output_file" || failures=$((failures + 1)) done fi +if [ "$failures" -ne 0 ]; then + echo "Image resizing finished with $failures failure(s)." >&2 + exit 1 +fi echo "Image resizing complete." diff --git a/src/run_audiocraft.sh b/src/run_audiocraft.sh old mode 100644 new mode 100755 index ca33f22..f3d31be --- a/src/run_audiocraft.sh +++ b/src/run_audiocraft.sh @@ -1,6 +1,51 @@ #!/usr/bin/env bash + +# Script Name: run_audiocraft.sh +# Description: Sets up a private Meta AudioCraft environment in ~/audiocraft-local +# (uv, Python 3.10 venv, PyTorch for CUDA 12.8) and generates music +# (MusicGen) or sound effects (AudioGen) from a text prompt on an NVIDIA GPU. +# Installs missing system tools with sudo apt and downloads packages/models +# from the network. Output .wav files go to ~/audiocraft-local/outputs. +# Usage: ./run_audiocraft.sh ["prompt"] [duration] [model] [kind] +# Run with --help for details and examples. +# Example: ./run_audiocraft.sh "horse cavalry charge, galloping" 5 sfx + set -Eeuo pipefail +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + cat <<'EOF' +Usage: + ./run_audiocraft.sh ["prompt"] [duration] [model] [kind] + +Examples: + + Music: + ./run_audiocraft.sh "ancient roman battle music, war drums and horns" 30 facebook/musicgen-medium music + + Sound effects: + ./run_audiocraft.sh "arrows whistling overhead, impacts into wooden shields, dry foley recording" 5 facebook/audiogen-medium sfx + + Shortcut for AudioGen: + ./run_audiocraft.sh "horse cavalry charge, galloping, dust, shouting soldiers" 5 sfx + +Arguments: + prompt Text prompt (default: "dark fantasy dungeon ambience with distant choir") + duration Seconds (default: 10) + model Hugging Face model name, or shortcut: sfx/audiogen (facebook/audiogen-medium), + music/musicgen (facebook/musicgen-medium) (default: facebook/musicgen-small) + kind auto/music/sfx (default: auto, detected from the model name) + +Recommended models: + facebook/musicgen-small + facebook/musicgen-medium + facebook/musicgen-large + facebook/audiogen-medium + +Output is written to ~/audiocraft-local/outputs. +EOF + exit 0 +fi + PROJECT_DIR="$HOME/audiocraft-local" VENV_DIR="$PROJECT_DIR/.venv" TOOLS_DIR="$PROJECT_DIR/.tools" @@ -18,6 +63,11 @@ KIND="${4:-auto}" # music = force MusicGen # sfx = force AudioGen +if ! [[ "$DURATION" =~ ^[0-9]+$ ]] || (( 10#$DURATION == 0 )); then + echo "Error: duration must be a positive whole number of seconds, got '$DURATION'." >&2 + exit 1 +fi + if [[ "$MODEL" == "sfx" || "$MODEL" == "audiogen" ]]; then MODEL="facebook/audiogen-medium" KIND="sfx" @@ -38,37 +88,6 @@ echo "Model: $MODEL" echo "Kind: $KIND" echo "" -if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then - cat <<'EOF' -Usage: - ./run_audiocraft.sh "prompt" duration model kind - -Examples: - - Music: - ./run_audiocraft.sh "ancient roman battle music, war drums and horns" 30 facebook/musicgen-medium music - - Sound effects: - ./run_audiocraft.sh "arrows whistling overhead, impacts into wooden shields, dry foley recording" 5 facebook/audiogen-medium sfx - - Shortcut for AudioGen: - ./run_audiocraft.sh "horse cavalry charge, galloping, dust, shouting soldiers" 5 sfx - -Arguments: - prompt Text prompt - duration Seconds - model Hugging Face model name, or shortcut: sfx/music - kind auto/music/sfx - -Recommended models: - facebook/musicgen-small - facebook/musicgen-medium - facebook/musicgen-large - facebook/audiogen-medium -EOF - exit 0 -fi - echo "Checking required system tools..." if ! command -v ffmpeg >/dev/null 2>&1 || ! command -v curl >/dev/null 2>&1; then @@ -126,7 +145,7 @@ source "$VENV_DIR/bin/activate" echo "Using Python:" python --version -which python +command -v python echo "" echo "Installing package tools..." diff --git a/src/run_videogen.sh b/src/run_videogen.sh old mode 100644 new mode 100755 index 971f577..36c48e0 --- a/src/run_videogen.sh +++ b/src/run_videogen.sh @@ -1,6 +1,24 @@ #!/usr/bin/env bash + +# Script Name: run_videogen.sh +# Description: Sets up a self-contained local video generation environment in +# ~/videogen-local (uv, private Python 3.10 venv, CUDA PyTorch, +# Diffusers) and generates a video with an LTX-Video model using +# low-VRAM settings (~8GB GPU). May install apt packages via sudo. +# Usage: run_videogen.sh [prompt] [duration_seconds] [model] [reference_image] +# reference_image - optional; switches to image-to-video mode. +# Tunables via environment: WIDTH, HEIGHT, FPS, STEPS, GUIDANCE, SEED, +# MAX_SEQUENCE_LENGTH, STYLE_LOCK, NEGATIVE_PROMPT, LORA_PATH, +# LORA_WEIGHT_NAME, LORA_SCALE, DECODE_TIMESTEP, DECODE_NOISE_SCALE. +# Example: SEED=42 ./run_videogen.sh "a knight walking in the rain" 3 + set -Eeuo pipefail +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + sed -n '3,13p' "$0" | sed 's/^# \{0,1\}//' + exit 0 +fi + PROJECT_DIR="$HOME/videogen-local" VENV_DIR="$PROJECT_DIR/.venv" TOOLS_DIR="$PROJECT_DIR/.tools" @@ -14,6 +32,21 @@ DURATION="${2:-2}" MODEL="${3:-Lightricks/LTX-Video}" REFERENCE_IMAGE="${4:-}" +if ! [[ "$DURATION" =~ ^[0-9]+([.][0-9]+)?$ ]]; then + echo "Error: duration must be a positive number of seconds, got '$DURATION'." >&2 + exit 1 +fi + +# Resolve the reference image now, before changing into the project directory, +# so relative paths keep working. +if [ -n "$REFERENCE_IMAGE" ]; then + if [ ! -f "$REFERENCE_IMAGE" ]; then + echo "Error: reference image not found: $REFERENCE_IMAGE" >&2 + exit 1 + fi + REFERENCE_IMAGE="$(cd "$(dirname "$REFERENCE_IMAGE")" && pwd)/$(basename "$REFERENCE_IMAGE")" +fi + # Low-VRAM defaults for an ~8GB GPU. WIDTH="${WIDTH:-512}" HEIGHT="${HEIGHT:-320}" @@ -32,6 +65,11 @@ LORA_PATH="${LORA_PATH:-}" LORA_WEIGHT_NAME="${LORA_WEIGHT_NAME:-}" LORA_SCALE="${LORA_SCALE:-0.8}" +# The generator below is a Python program that reads these settings from the +# environment, so the shell defaults above must be exported to take effect. +export WIDTH HEIGHT FPS STEPS GUIDANCE SEED MAX_SEQUENCE_LENGTH \ + STYLE_LOCK NEGATIVE_PROMPT LORA_PATH LORA_WEIGHT_NAME LORA_SCALE + mkdir -p "$PROJECT_DIR" "$TOOLS_DIR" "$OUTPUT_DIR" "$INPUT_DIR" cd "$PROJECT_DIR" @@ -105,7 +143,7 @@ source "$VENV_DIR/bin/activate" echo "Using Python:" python --version -which python +command -v python echo "" echo "Installing package tools..." @@ -279,8 +317,8 @@ pipe.transformer.enable_group_offload( use_stream=True, ) -# This is the key fix for your crash: -# avoid moving the entire T5 text encoder onto the GPU at once. +# Offload the T5 text encoder block by block instead of moving it onto the +# GPU at once, which runs out of memory on ~8GB cards. apply_group_offloading( pipe.text_encoder, onload_device=onload_device, diff --git a/src/server_health_monitor.sh b/src/server_health_monitor.sh index c376dda..49216c7 100755 --- a/src/server_health_monitor.sh +++ b/src/server_health_monitor.sh @@ -131,11 +131,11 @@ get_mem_usage() { fi } -# Get disk usage for all mounted filesystems (percentage and mount point per line) -# Filter with '$1+0 == $1' to exclude non-numeric values (e.g., headers or errors) +# Get disk usage for all mounted filesystems (" " per line) +# Lines without a numeric percentage (e.g. "-" for pseudo filesystems) are skipped; +# the whole rest of the line is kept so mount points containing spaces survive get_all_disk_usage() { - df -h --output=pcent,target 2>/dev/null | tail -n +2 | \ - awk '{gsub(/%/,"",$1); if ($1+0 == $1) print $1, $2}' + df --output=pcent,target 2>/dev/null | tail -n +2 | sed -nE 's/^ *([0-9]+)% +/\1 /p' } # Check CPU usage against threshold @@ -267,7 +267,7 @@ while [[ $# -gt 0 ]]; do ;; *) echo "Unknown option: $1" >&2 - print_usage + print_usage >&2 exit 1 ;; esac @@ -277,7 +277,7 @@ done for param_name in INTERVAL CPU_THRESHOLD MEM_THRESHOLD DISK_THRESHOLD; do param_value="${!param_name}" if ! [[ "$param_value" =~ ^[0-9]+$ ]]; then - echo "Error: $param_name must be a positive integer." >&2 + echo "Error: $param_name must be a non-negative integer." >&2 exit 1 fi done @@ -297,7 +297,7 @@ if [[ "$RUN_ONCE" == false && "$INTERVAL" -lt 1 ]]; then exit 1 fi -# Ensure the log file directory exists and is writable +# Ensure the log file directory exists and the log file is writable log_dir="$(dirname "$LOG_FILE")" if [[ ! -d "$log_dir" ]]; then mkdir -p "$log_dir" 2>/dev/null || { @@ -305,6 +305,10 @@ if [[ ! -d "$log_dir" ]]; then exit 1 } fi +if ! { : >> "$LOG_FILE"; } 2>/dev/null; then + echo "Error: Cannot write to log file: $LOG_FILE (use -l to choose another path)" >&2 + exit 1 +fi # Main execution log_message "INFO" "Server health monitor started." diff --git a/src/setup_obs_background_blur.sh b/src/setup_obs_background_blur.sh old mode 100644 new mode 100755 index 08bb553..f76e35a --- a/src/setup_obs_background_blur.sh +++ b/src/setup_obs_background_blur.sh @@ -7,13 +7,13 @@ set -euo pipefail if [[ "${EUID}" -eq 0 ]]; then - echo "Run this script as your normal user, not as root." + echo "Run this script as your normal user, not as root." >&2 exit 1 fi need_cmd() { if ! command -v "$1" >/dev/null 2>&1; then - echo "Missing required command: $1" + echo "Missing required command: $1" >&2 exit 1 fi } @@ -23,21 +23,24 @@ need_cmd curl need_cmd grep need_cmd sed need_cmd mktemp +need_cmd pgrep +need_cmd apt-get +need_cmd dpkg echo "Checking OBS installation..." if ! command -v obs >/dev/null 2>&1; then - echo "OBS Studio is not installed." - echo "Install it first with: sudo apt-get install obs-studio" + echo "OBS Studio is not installed." >&2 + echo "Install it first with: sudo apt-get install obs-studio" >&2 exit 1 fi -OBS_VERSION="$(obs --version 2>/dev/null | sed 's/^OBS Studio - //')" +OBS_VERSION="$(obs --version 2>/dev/null | sed 's/^OBS Studio - //' || true)" echo "Found OBS Studio ${OBS_VERSION}" if pgrep -x obs >/dev/null 2>&1; then - echo - echo "OBS is currently running." - echo "Close OBS before running this script so the new plugins load cleanly." + echo >&2 + echo "OBS is currently running." >&2 + echo "Close OBS before running this script so the new plugins load cleanly." >&2 exit 1 fi @@ -64,9 +67,9 @@ curl -fsSL "${PAGE_URL}" -o "${PAGE_HTML}" grep -Eo 'https://[^"]+x86_64-linux-gnu\.deb' "${PAGE_HTML}" > "${DEB_URL_FILE}" || true if [[ ! -s "${DEB_URL_FILE}" ]]; then - echo "Could not find the Ubuntu .deb download URL on ${PAGE_URL}" - echo "Open this page in a browser and download the Ubuntu package manually:" - echo " ${PAGE_URL}" + echo "Could not find the Ubuntu .deb download URL on ${PAGE_URL}" >&2 + echo "Open this page in a browser and download the Ubuntu package manually:" >&2 + echo " ${PAGE_URL}" >&2 exit 1 fi @@ -89,7 +92,7 @@ find /usr/lib /usr/share -type f 2>/dev/null | grep -E 'ashmanix|backgroundremov cat <<'EOF' -Setup is installed. Finish the effect in OBS like this: +Plugins are installed. Finish the effect in OBS like this: 1. Open OBS. 2. Add your webcam source. diff --git a/src/sort_string.sh b/src/sort_string.sh index a998e97..6e51f69 100755 --- a/src/sort_string.sh +++ b/src/sort_string.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash # Script Name: sort_string.sh -# Description: Sorts a string alphabetically. +# Description: Sorts the characters of a string (byte order, uppercase first); +# whitespace is dropped. # Usage: sort_string.sh string # string - a string to be sorted # Example: sort_string.sh "Ala ma kota" @@ -9,9 +10,9 @@ validate_arguments() { if [ $# -ne 1 ]; then - echo "Usage: sort_string.sh string" - echo " string - a string to be sorted" - echo "Example: sort_string.sh \"Ala ma kota\"" + echo "Usage: sort_string.sh string" >&2 + echo " string - a string to be sorted" >&2 + echo "Example: sort_string.sh \"Ala ma kota\"" >&2 exit 1 fi } @@ -19,7 +20,7 @@ validate_arguments() { sort_string() { local input_string="$1" local sorted_string - sorted_string=$(echo "$input_string" | grep -o . | sort | tr -d "\n") + sorted_string=$(printf '%s' "$input_string" | grep -o '[^[:space:]]' | LC_ALL=C sort | tr -d "\n") echo "$sorted_string" } diff --git a/src/speed_up_video.sh b/src/speed_up_video.sh index 0783b72..7ac2cd0 100755 --- a/src/speed_up_video.sh +++ b/src/speed_up_video.sh @@ -2,28 +2,51 @@ # Script Name: speed_up_video.sh # Description: This script speeds up a video file by a specified multiplier using ffmpeg. -# It checks for the correct number of arguments, processes the video, and saves the output. +# The audio track is dropped. The output is written to the current directory +# as _x.. # Usage: speed_up_video.sh # - Full path to the video file to be processed. -# - Numeric value indicating how much faster the video should be. +# - Positive number indicating how much faster the video should be. # Example: ./speed_up_video.sh /path/to/video.mp4 2.0 if [ "$#" -ne 2 ]; then - echo "Usage: $0 " + echo "Usage: $0 " >&2 exit 1 fi VIDEO_PATH=$1 SPEED=$2 +if [ ! -f "$VIDEO_PATH" ]; then + echo "Error: '$VIDEO_PATH' is not a file." >&2 + exit 1 +fi + +if ! [[ "$SPEED" =~ ^([0-9]+\.?[0-9]*|\.[0-9]+)$ ]] || ! awk -v s="$SPEED" 'BEGIN { exit !(s > 0) }'; then + echo "Error: speed multiplier must be a positive number." >&2 + exit 1 +fi + +if ! command -v ffmpeg >/dev/null 2>&1; then + echo "Error: this script requires 'ffmpeg'." >&2 + exit 1 +fi + FILENAME=$(basename -- "$VIDEO_PATH") -EXTENSION="${FILENAME##*.}" -FILENAME="${FILENAME%.*}" +if [[ "$FILENAME" == ?*.* ]]; then + EXTENSION=".${FILENAME##*.}" + FILENAME="${FILENAME%.*}" +else + EXTENSION="" +fi -OUTPUT="${FILENAME}_x${SPEED}.${EXTENSION}" +OUTPUT="${FILENAME}_x${SPEED}${EXTENSION}" # Speeding up the video using ffmpeg -ffmpeg -i "$VIDEO_PATH" -filter:v "setpts=PTS/${SPEED}" -an "$OUTPUT" +if ! ffmpeg -i "$VIDEO_PATH" -filter:v "setpts=PTS/${SPEED}" -an "$OUTPUT"; then + echo "Error: ffmpeg failed to process '$VIDEO_PATH'." >&2 + exit 1 +fi echo "Output saved as $OUTPUT" diff --git a/src/sqrt.sh b/src/sqrt.sh index b29ead9..0acdf00 100755 --- a/src/sqrt.sh +++ b/src/sqrt.sh @@ -2,22 +2,28 @@ # Script Name: sqrt.sh # Description: This script calculates the square root of a specified number. -# Usage: sqrt.sh number precision -# number - The number to compute the square root for. -# [precision] - The number of decimal places for rounding the result (optional). +# (Newton's method, computed with bc). +# Usage: sqrt.sh number [precision] +# number - The non-negative number to compute the square root for. +# [precision] - The number of decimal places for rounding the result (optional, default 0). # Example: ./sqrt.sh 16 # Output: 4 +# Don't let bc wrap long results with backslash-newlines. +export BC_LINE_LENGTH=0 + calculate_sqrt() { local number=$1 local precision=$2 + # Work with a few extra digits so the final rounding is accurate. local scale - scale=$((precision + 1)) - local guess - guess=$(bc -l <<< "$number / 2") + scale=$((precision + 5)) + + # Start from a guess >= 1 (number / 2 would be 0 or tiny for small inputs). + local guess new_guess=0 + guess=$(bc -l <<< "if ($number > 1) $number / 2 else 1") - while true; do - local new_guess + while (( $(bc -l <<< "$number != 0") )); do new_guess=$(bc -l <<< "scale=$scale;($guess + $number / $guess) / 2") local difference difference=$(bc -l <<< "scale=$scale; $guess - $new_guess") @@ -26,23 +32,26 @@ calculate_sqrt() { difference=$(bc -l <<< "-1 * $difference") fi - if (( $(echo "$difference < 1 * 10^-$scale" | bc -l) )); then + if (( $(bc -l <<< "scale=$scale; $difference < 10^-($scale - 1)") )); then break fi guess=$new_guess done - # Use bc to round the result - bc -l <<< "scale=$precision; $new_guess / 1" + # Round half up to the requested number of decimal places (bc's "/ 1" truncates). + # Add a leading 0 for results below 1 (bc prints .5 instead of 0.5). + bc -l <<< "scale=$scale; r = $new_guess + 5 * 10^-($precision + 1); scale=$precision; r / 1" | sed 's/^\./0./' } main() { if [[ $# -lt 1 || $# -gt 2 ]]; then - echo "Error: Invalid number of arguments provided." - echo "Usage: sqrt.sh number [precision]" - echo " number - The number to compute the square root for." - echo " [precision] - The number of decimal places for rounding the result (optional)." + { + echo "Error: Invalid number of arguments provided." + echo "Usage: sqrt.sh number [precision]" + echo " number - The non-negative number to compute the square root for." + echo " [precision] - The number of decimal places for rounding the result (optional)." + } >&2 exit 1 fi @@ -54,18 +63,21 @@ main() { fi if [[ ! $number =~ ^[0-9]+(\.[0-9]+)?$ ]]; then - echo "Error: The provided number ($number) is not a positive number!" + echo "Error: The provided number ($number) is not a non-negative number!" >&2 exit 1 fi if [[ ! $precision =~ ^[0-9]+$ ]]; then - echo "Error: The provided precision ($precision) is not a positive integer!" + echo "Error: The provided precision ($precision) is not a non-negative integer!" >&2 + exit 1 + fi + + if ! command -v bc >/dev/null 2>&1; then + echo "Error: this script requires 'bc'." >&2 exit 1 fi - local result - result=$(calculate_sqrt "$number" "$precision") - echo "$result" + calculate_sqrt "$number" "$precision" } main "$@" diff --git a/src/squash_branch.sh b/src/squash_branch.sh index 503a38b..3ba0465 100755 --- a/src/squash_branch.sh +++ b/src/squash_branch.sh @@ -1,22 +1,26 @@ #!/usr/bin/env bash # Script Name: squash_branch.sh -# Description: This script squashes all commits on a specified Git branch into a single commit. +# Description: This script squashes all commits on a specified Git branch (its whole +# history, down to the root commit) into a single commit. It opens an +# interactive rebase: mark every commit except the first as "squash". # Usage: squash_branch.sh # Example: squash_branch.sh dev # Check if a branch name was provided -if [ $# -eq 0 ]; then - echo "Error: No branch name provided." +if [ $# -ne 1 ]; then + echo "Error: provide exactly one branch name." >&2 + echo "Usage: $0 " >&2 exit 1 fi # Check out the specified branch -git checkout "$1" - -# Get the number of commits on the branch -num_commits=$(git rev-list HEAD --count) +if ! git checkout "$1"; then + echo "Error: could not check out branch '$1'." >&2 + exit 1 +fi # Squash all commits on the branch into a single commit -git rebase -i HEAD~"$num_commits" +# (HEAD~ does not exist, so rebase from the root instead). +git rebase -i --root diff --git a/src/squash_n_last_commits.sh b/src/squash_n_last_commits.sh index e8a2d46..f7a32da 100755 --- a/src/squash_n_last_commits.sh +++ b/src/squash_n_last_commits.sh @@ -1,22 +1,28 @@ #!/usr/bin/env bash # Script Name: squash_n_last_commits.sh -# Description: Squashes the last n commits in a git repository. -# Usage: squash_n_last_commits.sh number_of_commits branch_name directory_path -# number_of_commits - the number of commits to squash. -# branch_name - the name of the branch to squash. -# directory_path - the path to the working directory. +# Description: Squashes the last n commits in a git repository into a single commit +# (opens the editor, pre-filled with the squashed commits' messages). +# Usage: squash_n_last_commits.sh number_of_commits [branch_name [directory_path]] +# number_of_commits - the number of commits to squash (at least 2). +# branch_name - the name of the branch to squash (default: current branch). +# directory_path - the path to the working directory (default: current directory). # Example: ./squash_n_last_commits.sh 10 master . validate_arguments() { if [ $# -eq 0 ]; then - echo "You have to provide the number of commits to squash." - echo "Optionally, you can provide the branch name and the working directory." + echo "You have to provide the number of commits to squash." >&2 + echo "Optionally, you can provide the branch name and the working directory." >&2 exit 1 fi if [ $# -gt 3 ]; then - echo "You can't specify more than 3 parameters!" + echo "You can't specify more than 3 parameters!" >&2 + exit 1 + fi + + if ! [[ "$1" =~ ^[0-9]+$ ]] || [ "$((10#$1))" -lt 2 ]; then + echo "The number of commits must be an integer of at least 2." >&2 exit 1 fi } @@ -25,43 +31,54 @@ set_working_directory() { if [ $# -eq 3 ]; then local working_dir="$3" if [ ! -d "$working_dir" ]; then - echo "$working_dir is not a directory." + echo "$working_dir is not a directory." >&2 exit 1 fi - cd "$working_dir" || exit + cd "$working_dir" || exit 1 fi } check_git_repository() { - local is_git_repo=0 - is_git_repo=$(git rev-parse --git-dir > /dev/null 2>&1) - - if [ ${#is_git_repo} -gt 0 ]; then - echo "Not inside a git repo! Please provide a correct path." + if ! git rev-parse --git-dir > /dev/null 2>&1; then + echo "Not inside a git repo! Please provide a correct path." >&2 exit 1 fi } check_branch() { - if [ $# -eq 3 ]; then - local branch_name="$2" - local is_local_branch=0 - is_local_branch=$(git branch -l | grep -Fw "$branch_name") - - if [ ${#is_local_branch} -eq 0 ]; then - echo "Provided branch doesn't exist." - exit 1 - fi + if [ $# -ge 2 ] && ! git show-ref --verify --quiet "refs/heads/$2"; then + echo "Provided branch doesn't exist." >&2 + exit 1 fi } squash_commits() { - local n="$1" - local branch_name="$2" + local n=$((10#$1)) + local branch_name="${2:-}" + local original_head - git checkout "$branch_name" - git reset --soft HEAD~"$n" - git commit + if [ -n "$branch_name" ]; then + git checkout "$branch_name" || exit 1 + fi + + if ! git diff --cached --quiet; then + echo "There are staged changes; commit or unstage them first so they aren't squashed in." >&2 + exit 1 + fi + + if ! git rev-parse --verify --quiet "HEAD~$n" > /dev/null; then + echo "The branch has fewer than $((n + 1)) commits; cannot squash the last $n." >&2 + exit 1 + fi + + original_head=$(git rev-parse HEAD) + git reset --soft "HEAD~$n" || exit 1 + # Pre-fill the editor with the squashed commits' messages (oldest first). + if ! git commit --edit -m "$(git log --format=%B --reverse "HEAD..$original_head")"; then + echo "Commit aborted; restoring the original history." >&2 + git reset --soft "$original_head" + exit 1 + fi } main() { diff --git a/src/stretch_mp4_vertically.sh b/src/stretch_mp4_vertically.sh index e0eab43..7563185 100755 --- a/src/stretch_mp4_vertically.sh +++ b/src/stretch_mp4_vertically.sh @@ -2,19 +2,42 @@ # Script Name: stretch_mp4_vertically.sh # Description: Stretches an MP4 video vertically by a given scale factor using ffmpeg. +# The frame is scaled to height*factor and then padded (black bars, centered) +# by the same amount again. The output is written to the current directory +# as _x.. # Usage: ./stretch_mp4_vertically.sh input_file scale_factor +# scale_factor - a number >= 1 (e.g. 1.5) # Example: ./stretch_mp4_vertically.sh video.mp4 1.5 +set -euo pipefail + # Check if the correct number of arguments is provided if [ "$#" -ne 2 ]; then - echo "Usage: $0 input_file scale_factor" + echo "Usage: $0 input_file scale_factor" >&2 exit 1 fi +for cmd in ffmpeg ffprobe; do + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "Error: $cmd is required but not installed." >&2 + exit 1 + fi +done + # Assign arguments to variables input_file=$1 scale_factor=$2 +if [ ! -f "$input_file" ]; then + echo "Error: '$input_file' is not a file." >&2 + exit 1 +fi + +if ! [[ $scale_factor =~ ^[0-9]+(\.[0-9]+)?$ ]] || ! awk -v f="$scale_factor" 'BEGIN { exit !(f >= 1) }'; then + echo "Error: scale_factor must be a number >= 1." >&2 + exit 1 +fi + # Extract the filename without extension filename=$(basename "$input_file") extension="${filename##*.}" @@ -27,17 +50,20 @@ output_file="${filename}_${scale_factor}x.${extension}" original_width=$(ffprobe -v error -select_streams v:0 -show_entries stream=width -of csv=p=0 "$input_file") original_height=$(ffprobe -v error -select_streams v:0 -show_entries stream=height -of csv=p=0 "$input_file") -# Calculate the new scaled height -new_height=$(echo "$original_height * $scale_factor" | bc) +if ! [[ $original_width =~ ^[0-9]+$ && $original_height =~ ^[0-9]+$ ]]; then + echo "Error: could not read video dimensions from '$input_file'." >&2 + exit 1 +fi -# Calculate the total padding (new height minus original height) -total_padding=$(echo "$new_height - $original_height" | bc) +# Calculate the new scaled height, rounded to an even integer (required by most encoders) +new_height=$(awk -v h="$original_height" -v f="$scale_factor" 'BEGIN { printf "%d", int(h * f / 2 + 0.5) * 2 }') -# Calculate padding for top and bottom (equally distributed) -padding_top_bottom=$(echo "$total_padding / 2" | bc) +# Calculate the total padding (new height minus original height) and the top offset +total_padding=$((new_height - original_height)) +padding_top=$((total_padding / 2)) # Execute the ffmpeg command with proper top and bottom padding -ffmpeg -i "$input_file" -vf "scale=$original_width:$new_height,pad=$original_width:$new_height+$total_padding:0:$padding_top_bottom" "$output_file" +ffmpeg -i "$input_file" -vf "scale=$original_width:$new_height,pad=$original_width:$((new_height + total_padding)):0:$padding_top" "$output_file" echo "Output file created: $output_file" diff --git a/src/strip_cpp_comments.sh b/src/strip_cpp_comments.sh index a918b38..0e646f7 100755 --- a/src/strip_cpp_comments.sh +++ b/src/strip_cpp_comments.sh @@ -3,10 +3,13 @@ # Script Name: strip_cpp_comments.sh # Description: Removes C/C++ comments from source files in place using pure Bash. # Supports directory scanning with configurable depth and file pattern matching. +# Files given explicitly are always processed; directories (default: the +# current one) are scanned for files whose path matches REGEX. # Usage: ./strip_cpp_comments.sh [-r REGEX] [-n DEPTH] [path1 path2 ...] # Options: # -r, --regex REGEX Extended regexp for directory scanning (default: C/C++ extensions). -# -n, --max-depth N Recurse into directories at most N levels (default: 3). +# -n, --max-depth N Recurse into directories at most N levels (default: 3, 0 = no recursion). +# -h, --help Show this help and exit. set -euo pipefail IFS=$'\n\t' @@ -18,7 +21,9 @@ DEFAULT_RE='\.([ch](pp|xx|c)?|cc|hh|hpp)$' # .c .h .cpp .cc .cxx .hpp … MAX_DEPTH=3 # default recursion depth ############################################################################## -# FUNCTION: strip_file (unchanged comment-removal FSM) +# FUNCTION: strip_file (comment-removal state machine) +# A block comment is replaced by the newlines it contained (keeps line +# numbers) or, if it had none, by one space (so "int/**/x" stays "int x"). ############################################################################## strip_file() { local file=$1 @@ -27,88 +32,78 @@ strip_file() { # State machine variables local in_block=0 in_line=0 in_str=0 in_char=0 escape=0 - local prev='' c='' - - exec 3> "$tmp" - - while IFS= read -r -N1 c || [[ -n $c ]]; do - # ---------- end of single-line comment ---------- - if (( in_line )); then - if [[ $c == $'\n' ]]; then - in_line=0 - printf '\n' >&3 + local prev='' c='' block_nl='' + + { + while IFS= read -r -N1 c || [[ -n $c ]]; do + # ---------- inside single-line comment ---------- + if (( in_line )); then + if [[ $c == $'\n' ]]; then + in_line=0 + printf '\n' + fi + continue fi - continue - fi - # ---------- end of block comment ---------- - if (( in_block )); then - if [[ $prev == '*' && $c == '/' ]]; then - in_block=0 - prev='' - else - prev=$c + # ---------- inside block comment ---------- + if (( in_block )); then + if [[ $prev == '*' && $c == '/' ]]; then + in_block=0 + prev='' + printf '%s' "${block_nl:- }" + else + [[ $c == $'\n' ]] && block_nl+=$'\n' + prev=$c + fi + continue fi - continue - fi - # ---------- inside string literal ---------- - if (( in_str )); then - printf '%s' "$c" >&3 - if (( escape )); then - escape=0 - elif [[ $c == \\ ]]; then - escape=1 - elif [[ $c == '"' ]]; then - in_str=0 + # ---------- inside string / character literal ---------- + if (( in_str || in_char )); then + printf '%s' "$c" + if (( escape )); then + escape=0 + elif [[ $c == \\ ]]; then + escape=1 + elif (( in_str )) && [[ $c == '"' ]]; then + in_str=0 + elif (( in_char )) && [[ $c == "'" ]]; then + in_char=0 + fi + continue fi - continue - fi - # ---------- inside character literal ---------- - if (( in_char )); then - printf '%s' "$c" >&3 - if (( escape )); then - escape=0 - elif [[ $c == \\ ]]; then - escape=1 - elif [[ $c == "'" ]]; then - in_char=0 + # ---------- previous char was '/' ---------- + if [[ -n $prev ]]; then + prev='' + case $c in + /) in_line=1; continue ;; # "//" + '*') in_block=1; block_nl=''; continue ;; # "/*" + *) printf '/' ;; # plain '/': c is handled below + esac fi - continue - fi - # ---------- neutral state ---------- - if [[ -z $prev ]]; then + # ---------- neutral state ---------- case $c in - /) - prev='/' - ;; - '"') - in_str=1 - printf '%s' "$c" >&3 - ;; - "'") - in_char=1 - printf '%s' "$c" >&3 - ;; - *) - printf '%s' "$c" >&3 - ;; + /) prev='/' ;; + '"') in_str=1; printf '%s' "$c" ;; + \') in_char=1; printf '%s' "$c" ;; + *) printf '%s' "$c" ;; esac - else - case $c in - /) in_line=1 ; prev='' ;; # "//" - '*') in_block=1; prev='' ;; # "/*" - *) printf '/%s' "$c" >&3; prev='' ;; - esac - fi - done < "$file" + done < "$file" - [[ -n $prev ]] && printf '%s' "$prev" >&3 # lone '/' + if [[ -n $prev ]]; then + printf '%s' "$prev" # lone '/' at end of file + fi + } > "$tmp" || { rm -f "$tmp"; return 2; } - chmod --reference="$file" "$tmp" - mv "$tmp" "$file" + # Write back through the original file to keep its permissions and ownership + if ! cat "$tmp" > "$file"; then + echo "Error: cannot write $file" >&2 + rm -f "$tmp" + return 2 + fi + rm -f "$tmp" } ############################################################################## @@ -151,9 +146,18 @@ main() { ;; -n|--max-depth) shift - [[ $# -eq 0 || ! $1 =~ ^[0-9]+$ ]] && { echo "Error: -n needs positive integer" >&2; exit 1; } + [[ $# -eq 0 || ! $1 =~ ^[0-9]+$ ]] && { echo "Error: -n needs a non-negative integer" >&2; exit 1; } depth=$1 ;; + -h|--help) + sed -n '3,/^[^#]/{/^#/s/^# \{0,1\}//p}' "$0" + exit 0 + ;; + --) + shift + positional+=("$@") + break + ;; -*) echo "Unknown option: $1" >&2; exit 1 ;; *) diff --git a/src/strip_digits.sh b/src/strip_digits.sh index c5602df..ce5bf14 100755 --- a/src/strip_digits.sh +++ b/src/strip_digits.sh @@ -1,33 +1,24 @@ #!/usr/bin/env bash # Script Name: strip_digits.sh -# Description: Removes all digits from each string in a given file. -# Usage: strip_digits.sh [file_path] -# [file_path] - the path to the file to process. +# Description: Removes all digits from each string in a given file (in place). +# Usage: strip_digits.sh +# - the path to the file to process. # Example: ./strip_digits.sh path/to/file main() { - if [ $# -eq 0 ]; then - echo "You must provide a file path!" + if [ $# -ne 1 ]; then + echo "Usage: strip_digits.sh " >&2 exit 1 fi if [ ! -f "$1" ]; then - echo "$1 is not a valid file path!" + echo "$1 is not a valid file path!" >&2 exit 1 fi - local file_path - local temp_file - - file_path=$1 - # file_name is not used in the script so I've removed it. - - temp_file=$(mktemp) - - sed -r 's/[^[:space:]]*[0-9][^[:space:]]* ?//g' "$file_path" > "$temp_file" - mv "$temp_file" "$file_path" - + # -i edits in place, keeping the file's permissions and ownership. + sed -i 's/[0-9]//g' "$1" } main "$@" diff --git a/src/strip_python_comments.sh b/src/strip_python_comments.sh index 3807fe3..c70b1b8 100755 --- a/src/strip_python_comments.sh +++ b/src/strip_python_comments.sh @@ -2,7 +2,8 @@ # Script Name: strip_python_comments.sh # Description: Removes comments from Python files with support for interactive mode, -# verbose output, dry-run, backups, and logging. +# verbose output, dry-run, backups, and logging. Strings, docstrings, +# the shebang line and the encoding declaration are left untouched. # Usage: ./strip_python_comments.sh [options] [file|directory|pattern] # Options: # -i, --interactive Ask for confirmation before removing comments from each file. @@ -37,13 +38,14 @@ Options: -h, --help Display this help message By default, the script finds all .py files in the current directory and subdirectories, -and removes comments from them. +and removes comments from them. The shebang line and encoding declaration are kept; +strings and docstrings are left untouched. A backup of every modified file is created. You can provide a file, directory, or pattern to specify which files to process. EOF } -# Logging function +# Logging function: errors and warnings always go to stderr, other messages only with --verbose log() { local LEVEL="$1" shift @@ -51,7 +53,9 @@ log() { if [ "$LOG_FILE" ]; then echo "[$LEVEL] $MESSAGE" >> "$LOG_FILE" fi - if [ $VERBOSE -eq 1 ]; then + if [[ "$LEVEL" == "ERROR" || "$LEVEL" == "WARNING" ]]; then + echo "[$LEVEL] $MESSAGE" >&2 + elif [ $VERBOSE -eq 1 ]; then echo "[$LEVEL] $MESSAGE" fi } @@ -62,7 +66,7 @@ if ! command -v python3 &> /dev/null; then exit 1 fi -# Parse command-line options using getopts +# Parse command-line options while [[ $# -gt 0 ]]; do case "$1" in -i|--interactive) @@ -77,12 +81,16 @@ while [[ $# -gt 0 ]]; do DRY_RUN=1 shift ;; - -b|--backup-ext) - BACKUP_EXT="$2" - shift 2 - ;; - -l|--log-file) - LOG_FILE="$2" + -b|--backup-ext|-l|--log-file) + if [[ $# -lt 2 || -z "$2" ]]; then + echo "Error: $1 requires a non-empty argument." >&2 + exit 1 + fi + if [[ "$1" == -b || "$1" == --backup-ext ]]; then + BACKUP_EXT="$2" + else + LOG_FILE="$2" + fi shift 2 ;; -h|--help) @@ -91,7 +99,7 @@ while [[ $# -gt 0 ]]; do ;; -*) echo "Unknown option: $1" >&2 - show_help + show_help >&2 exit 1 ;; *) @@ -121,9 +129,9 @@ process_file() { return fi - # Interactive mode confirmation + # Interactive mode confirmation (read from the terminal, not from the file list) if [ $INTERACTIVE -eq 1 ]; then - read -rp "Remove comments from $FILE? [y/N]: " CONFIRM + read -rp "Remove comments from $FILE? [y/N]: " CONFIRM < /dev/tty || CONFIRM="" if [[ "$CONFIRM" != "y" && "$CONFIRM" != "Y" ]]; then log "INFO" "Skipping $FILE" return @@ -137,48 +145,53 @@ process_file() { fi # Create a backup of the file - cp "$FILE" "$FILE$BACKUP_EXT" + if ! cp -p -- "$FILE" "$FILE$BACKUP_EXT"; then + log "ERROR" "Could not create backup of $FILE; skipping it" + return + fi log "INFO" "Backup created: $FILE$BACKUP_EXT" - # Use Python to remove comments and docstrings + # Use Python's tokenizer to find comments, so '#' inside strings is left alone. + # Only the comment text is cut from each line; all other code is kept byte-for-byte. if python3 - "$FILE" << 'EOF' +import io +import re import sys -import token import tokenize -def remove_comments_and_docstrings(source): - """ - Removes comments and docstrings from Python source code. - """ - tokens = tokenize.generate_tokens(source.readline) - result = [] - prev_toktype = token.INDENT - for tok in tokens: - tok_type, tok_string, start, end, line = tok - if tok_type == token.COMMENT: + +def remove_comments(source): + lines = io.StringIO(source, newline="").readlines() + reader = io.StringIO(source, newline="").readline + for tok in tokenize.generate_tokens(reader): + if tok.type != tokenize.COMMENT: + continue + row, col = tok.start + idx = row - 1 + text = lines[idx] + # Keep the shebang and the encoding declaration + if idx == 0 and text.startswith("#!"): + continue + if idx < 2 and col == 0 and re.match(r"^#.*coding[:=]", text): continue - elif tok_type == token.STRING: - if prev_toktype != token.INDENT: - continue - else: - result.append(tok_string) - else: - result.append(tok_string) - prev_toktype = tok_type - return ''.join(result) - -if __name__ == "__main__": - try: - filename = sys.argv[1] - with open(filename, 'r') as f: - source = f.read() - from io import StringIO - cleaned_code = remove_comments_and_docstrings(StringIO(source)) - with open(filename, 'w') as f: - f.write(cleaned_code) - except Exception as e: - print(f"Error processing {filename}: {e}", file=sys.stderr) - sys.exit(1) + ending = text[len(text.rstrip("\r\n")):] + code = text[:col].rstrip() + # Drop lines that contained nothing but a comment + lines[idx] = code + ending if code else "" + return "".join(lines) + + +filename = sys.argv[1] +try: + with open(filename, "rb") as f: + raw = f.read() + encoding, _ = tokenize.detect_encoding(io.BytesIO(raw).readline) + cleaned_code = remove_comments(raw.decode(encoding)) + with open(filename, "wb") as f: + f.write(cleaned_code.encode(encoding)) +except Exception as e: + print(f"Error processing {filename}: {e}", file=sys.stderr) + sys.exit(1) EOF then log "INFO" "Comments removed from $FILE" @@ -190,6 +203,7 @@ EOF # Function to find and process files find_and_process_files() { local TARGET="$1" + local FILE if [ -f "$TARGET" ]; then process_file "$TARGET" @@ -203,18 +217,18 @@ find_and_process_files() { shopt -s nullglob # shellcheck disable=SC2206 local FILES=($TARGET) + shopt -u nullglob if [ ${#FILES[@]} -eq 0 ]; then log "WARNING" "No files matched pattern: $TARGET" else for FILE in "${FILES[@]}"; do - if [ -e "$FILE" ]; then + if [ -f "$FILE" ]; then process_file "$FILE" else - log "WARNING" "File does not exist: $FILE" + log "WARNING" "Not a regular file: $FILE" fi done fi - shopt -u nullglob fi } diff --git a/src/sum_args.sh b/src/sum_args.sh index f918c8c..42083ea 100755 --- a/src/sum_args.sh +++ b/src/sum_args.sh @@ -3,7 +3,7 @@ # Script Name: sum_args.sh # Description: Sums up all arguments passed to the script. # Usage: sum_args.sh arg1 arg2 ... -# arg1 arg2 ... - the arguments to sum up. +# arg1 arg2 ... - the integers to sum up (negative values allowed). # Example: ./sum_args.sh 1 2 3 4 5 print_arguments() { @@ -14,18 +14,34 @@ print_arguments() { done } +validate_arguments() { + for arg; do + # Only plain integers: anything else would be evaluated as an + # arithmetic expression (variable names, command substitutions...). + if ! [[ $arg =~ ^-?[0-9]+$ ]]; then + echo "Error: '$arg' is not an integer." >&2 + exit 1 + fi + done +} + calculate_sum() { local sum=0 - # Iterate over all the arguments + # Iterate over all the arguments (10# avoids octal parsing of e.g. 08) for arg; do - sum=$((sum + arg)) + if [[ $arg == -* ]]; then + sum=$((sum - 10#${arg#-})) + else + sum=$((sum + 10#$arg)) + fi done echo "$sum" } main() { + validate_arguments "$@" print_arguments "$@" local sum=0 diff --git a/src/sum_smaller_numbers.sh b/src/sum_smaller_numbers.sh index ff3735a..5114009 100755 --- a/src/sum_smaller_numbers.sh +++ b/src/sum_smaller_numbers.sh @@ -5,18 +5,18 @@ # Usage: sum_smaller_numbers.sh number # number - the number to sum integers smaller than # Example: ./sum_smaller_numbers.sh 10 -# Output: The sum of integers smaller than 10 is 45 +# Output: The sum of integers smaller than 10 is 45. validate_arguments() { if [ $# -ne 1 ]; then - echo "Usage: sum_smaller_numbers.sh number" - echo " number - the number to sum integers smaller than" + echo "Usage: sum_smaller_numbers.sh number" >&2 + echo " number - the number to sum integers smaller than" >&2 exit 1 fi re='^[0-9]+$' if ! [[ $1 =~ $re ]]; then - echo "You must provide a positive integer." + echo "You must provide a non-negative integer." >&2 exit 1 fi } diff --git a/src/swap_files.sh b/src/swap_files.sh index 8dcaaa7..60adeec 100755 --- a/src/swap_files.sh +++ b/src/swap_files.sh @@ -22,21 +22,22 @@ set -euo pipefail -# Function to display the help message +# Function to display the help message (the header comment block) and exit +# $1: exit status (default 0) function show_help() { - grep '^#' "$0" | cut -c 4- - exit 0 + sed -n '3,/^$/s/^# \{0,1\}//p' "$0" + exit "${1:-0}" } # Function to check if a file exists and is a regular file function check_file_existence() { local file="$1" if [[ ! -e "$file" ]]; then - echo "Error: File '$file' does not exist." + echo "Error: File '$file' does not exist." >&2 exit 1 fi if [[ ! -f "$file" ]]; then - echo "Error: '$file' is not a regular file." + echo "Error: '$file' is not a regular file." >&2 exit 1 fi } @@ -47,7 +48,9 @@ function create_backup() { local backup_file backup_file="${file}_backup_$(date +%Y%m%d%H%M%S)" cp -- "$file" "$backup_file" - [[ "$VERBOSE" = true ]] && echo "Backup of '$file' created as '$backup_file'." + if [[ "$VERBOSE" = true ]]; then + echo "Backup of '$file' created as '$backup_file'." + fi } # Function to swap the contents of two files @@ -56,20 +59,28 @@ function swap_file_contents() { local file2="$2" # Check if both files are the same - if [ "$(realpath "$file1")" = "$(realpath "$file2")" ] && [ "$FORCE" = false ]; then - echo "Error: Cannot swap the same file. Use --force to override." - exit 1 + if [ "$(realpath "$file1")" = "$(realpath "$file2")" ]; then + if [ "$FORCE" = false ]; then + echo "Error: Cannot swap the same file. Use --force to override." >&2 + exit 1 + fi + # Swapping a file with itself is a no-op. + return 0 fi local temp temp=$(mktemp) - cp -- "$file1" "$temp" - cp -- "$file2" "$file1" - cp -- "$temp" "$file2" + # cp overwrites the contents in place, so permissions of both files are kept. + if ! cp -- "$file1" "$temp" || ! cp -- "$file2" "$file1" || ! cp -- "$temp" "$file2"; then + echo "Error: Swap failed. The original contents of '$file1' are kept in '$temp'." >&2 + exit 1 + fi rm -- "$temp" - [ "$VERBOSE" = true ] && echo "Swapped contents of '$file1' and '$file2'." + if [ "$VERBOSE" = true ]; then + echo "Swapped contents of '$file1' and '$file2'." + fi } # Main function to parse arguments and execute script logic @@ -102,8 +113,8 @@ function main() { break ;; -*) - echo "Unknown option: $1" - show_help + echo "Unknown option: $1" >&2 + show_help 1 >&2 ;; *) break @@ -113,8 +124,8 @@ function main() { # Check for the correct number of arguments if [[ $# -ne 2 ]]; then - echo "Error: Must provide exactly two file paths." - show_help + echo "Error: Must provide exactly two file paths." >&2 + show_help 1 >&2 fi local file1="$1" diff --git a/src/switch_slashes.sh b/src/switch_slashes.sh index 3c8570a..42311b4 100755 --- a/src/switch_slashes.sh +++ b/src/switch_slashes.sh @@ -1,10 +1,11 @@ #!/usr/bin/env bash # Script Name: switch_slashes.sh -# Description: Script to replace left slashes with right slashes and vice versa -# Usage: switch_slashes.sh [file_name] [-v] -# [file_name] - file to be changed -# - if not specified, the direction is set to invert +# Description: Script to replace forward slashes (/) with backslashes (\) and vice versa in a file (in place). +# Usage: switch_slashes.sh [] [-v] +# - file to be changed +# [left_to_right | right_to_left | invert] - left_to_right turns / into \, right_to_left turns \ into /, +# invert swaps both; if not specified, the direction is set to invert # [-v] - verbose mode; if set, logs the actions # Example: ./switch_slashes.sh myfile.txt left_to_right -v @@ -16,21 +17,36 @@ log() { fi } +usage() { + echo "Usage: switch_slashes.sh [] [-v]" + echo " - file to be changed" + echo " [left_to_right | right_to_left | invert] - if not specified, the direction is set to invert" + echo " [-v] - verbose mode; if set, logs the actions" + echo "Example: ./switch_slashes.sh myfile.txt left_to_right -v" +} + main() { if [ $# -lt 1 ] || [ $# -gt 3 ]; then - echo "Usage: switch_slashes.sh [file_name] [] [-v]" - echo " [file_name] - file to be changed" - echo " - if not specified, the direction is set to invert" - echo " [-v] - verbose mode; if set, logs the actions" - echo "Example: ./switch_slashes.sh myfile.txt left_to_right -v" + usage >&2 exit 1 fi - file_name="$1" - direction="${2:-invert}" + local file_name="$1" + local direction="invert" + shift - if [[ "$3" == "-v" ]] || [[ "$2" == "-v" ]]; then - verbose=true + local arg + for arg in "$@"; do + if [ "$arg" = "-v" ]; then + verbose=true + else + direction="$arg" + fi + done + + if [ ! -f "$file_name" ]; then + echo "Error: '$file_name' is not a file." >&2 + exit 1 fi log "Processing file: $file_name" @@ -38,20 +54,20 @@ main() { case "$direction" in left_to_right) - log "Replacing left slashes with right slashes..." + log "Replacing forward slashes with backslashes..." sed -i 's|/|\\|g' "$file_name" ;; right_to_left) - log "Replacing right slashes with left slashes..." + log "Replacing backslashes with forward slashes..." sed -i 's|\\|/|g' "$file_name" ;; invert) log "Inverting slashes..." - sed -i 's|/|TEMP_SLASH|g; s|\\|/|g; s|TEMP_SLASH|\\|g' "$file_name" + sed -i 'y|/\\|\\/|' "$file_name" ;; *) - echo "Invalid direction: $direction" - echo "Valid options are: left_to_right, right_to_left, invert" + echo "Invalid direction: $direction" >&2 + echo "Valid options are: left_to_right, right_to_left, invert" >&2 exit 1 ;; esac diff --git a/src/system_info.sh b/src/system_info.sh index 2bd92c7..d4b760f 100755 --- a/src/system_info.sh +++ b/src/system_info.sh @@ -2,6 +2,7 @@ # Script Name: system_info.sh # Description: This script displays detailed information about the system. +# Without options, all available information is displayed. # Usage: ./system_info.sh [options] # Example: ./system_info.sh --memory --disk @@ -9,12 +10,13 @@ set -euo pipefail # Constants -readonly SCRIPT_NAME SCRIPT_NAME=$(basename "${0}") +readonly SCRIPT_NAME print_usage() { echo "Usage: ${SCRIPT_NAME} [OPTIONS]" echo "Displays detailed information about the system." + echo "Without options, all available information is displayed." echo echo "Options:" echo " -h, --help Display this help message and exit." @@ -30,10 +32,6 @@ print_usage() { echo " --all Display all available information." } -print_help() { - print_usage -} - # Default options show_memory=false show_disk=false @@ -47,10 +45,14 @@ show_load=false show_all=false # Parse command-line options +if [[ $# -eq 0 ]]; then + show_all=true +fi + while [[ $# -gt 0 ]]; do case "$1" in -h|--help) - print_help + print_usage exit 0 ;; --memory) @@ -84,43 +86,75 @@ while [[ $# -gt 0 ]]; do show_all=true ;; *) - echo "Error: Unknown option: $1" - print_usage + echo "Error: Unknown option: $1" >&2 + print_usage >&2 exit 1 ;; esac shift done +# Print a section title followed by the output of a command. +# Output is printed verbatim (no echo -e escape interpretation). +section() { + local title="$1" + shift + + printf '%s:\n' "$title" + "$@" || printf '(could not retrieve %s)\n' "$title" >&2 + echo +} + +disk_usage_top10() { + # Keep the header line on top and sort the rest by use percentage + df -Ph | { + IFS= read -r header + printf '%s\n' "$header" + sort -k 5,5 -n -r | head -n 10 + } +} + +os_details() { + if command -v lsb_release >/dev/null 2>&1; then + lsb_release -a 2>/dev/null + else + cat /etc/os-release + fi +} + +dev_filesystems() { + mount | grep '^/dev' || true +} + main() { if $show_memory || $show_all; then - echo -e "Memory Usage: \n$(free -h)\n" + section "Memory Usage" free -h fi if $show_disk || $show_all; then - echo -e "Disk Usage (Top 10): \n$(df -Ph | sort -k 5 -h -r | head)\n" + section "Disk Usage (Top 10)" disk_usage_top10 fi if $show_cpu || $show_all; then - echo -e "CPU Information: \n$(lscpu)\n" + section "CPU Information" lscpu fi if $show_network || $show_all; then - echo -e "Network Interfaces: \n$(ip -br address)\n" + section "Network Interfaces" ip -br address fi if $show_processes || $show_all; then - echo -e "Running Processes: \n$(ps aux)\n" + section "Running Processes" ps aux fi if $show_os || $show_all; then - echo -e "Operating System: \n$(lsb_release -a)\n" + section "Operating System" os_details fi if $show_kernel || $show_all; then - echo -e "Kernel Version: \n$(uname -r)\n" + section "Kernel Version" uname -r fi if $show_filesystems || $show_all; then - echo -e "Mounted Filesystems: \n$(mount | grep '^/dev')\n" + section "Mounted Filesystems" dev_filesystems fi if $show_load || $show_all; then - echo -e "System Load: \n$(uptime)\n" + section "System Load" uptime fi } -main "$@" +main diff --git a/src/time_execution.sh b/src/time_execution.sh index 1b97815..64c17bc 100755 --- a/src/time_execution.sh +++ b/src/time_execution.sh @@ -3,13 +3,13 @@ # Script Name: time_execution.sh # Description: Displays the average time it takes for a command to execute. # Usage: time_execution.sh 'command' [number of runs] -# 'command' is the command to execute. +# 'command' is the command to execute (evaluated by the shell). # number of runs is optional and defaults to 10 if not provided. # Example: ./time_execution.sh 'sleep 1' 20 main() { - if [ -z "$1" ]; then - echo "Usage: $0 'command' [number of runs]" + if [ -z "${1:-}" ] || [ $# -gt 2 ]; then + echo "Usage: $0 'command' [number of runs]" >&2 exit 1 fi @@ -17,17 +17,28 @@ main() { local N=${2:-10} local total_time=0 - for _ in $(seq 1 "$N"); do - local start_time + if ! [[ "$N" =~ ^[0-9]+$ ]] || (( 10#$N == 0 )); then + echo "Error: number of runs must be a positive integer." >&2 + exit 1 + fi + N=$((10#$N)) + + if ! command -v bc >/dev/null 2>&1; then + echo "Error: this script requires 'bc'." >&2 + exit 1 + fi + + local start_time end_time + for ((run = 0; run < N; run++)); do start_time=$(date +%s.%N) eval "$command" - local end_time end_time=$(date +%s.%N) total_time=$(echo "$total_time + ($end_time - $start_time)" | bc) done local avg_time - avg_time=$(echo "scale=3; $total_time / $N" | bc) + # bc prints .123 for values below 1; add the leading zero. + avg_time=$(echo "scale=3; $total_time / $N" | bc | sed 's/^\./0./') echo "Average execution time for \"$command\" over $N runs: $avg_time seconds" } diff --git a/src/timer.sh b/src/timer.sh index 87a7b61..d79f760 100755 --- a/src/timer.sh +++ b/src/timer.sh @@ -3,20 +3,22 @@ # Script Name: timer.sh # Description: This script functions as a simple timer, displaying elapsed time in HH:MM:SS format. # It updates the time display every second on a single line in the terminal. +# Press Ctrl+C to stop. # Usage: ./timer.sh # Example: ./timer.sh -secs=0 -mins=0 -hours=0 +# Print a newline on Ctrl+C so the prompt doesn't overwrite the last time shown +trap 'echo; exit 0' INT + +# SECONDS is a Bash builtin counting seconds since it was set, so the +# display doesn't drift even though each loop takes slightly over a second +SECONDS=0 while true; do - ((++secs)) - mins=$((secs / 60)) - hours=$((mins / 60)) + secs=$SECONDS # Format and display the time in HH:MM:SS format, updating on the same line - printf "\r%02d:%02d:%02d" $((hours % 24)) $((mins % 60)) $((secs % 60)) + printf "\r%02d:%02d:%02d" $((secs / 3600)) $((secs / 60 % 60)) $((secs % 60)) # Wait for one second before the next update sleep 1 diff --git a/src/upper.sh b/src/upper.sh index 500a416..fb01467 100755 --- a/src/upper.sh +++ b/src/upper.sh @@ -8,8 +8,8 @@ # Output: HELLO WORLD convert_to_uppercase() { - # Converts a string to uppercase using tr - echo "$1" | tr '[:lower:]' '[:upper:]' + # Converts a string to uppercase using tr (printf, unlike echo, prints "-n" or "-e" literally) + printf '%s\n' "$1" | tr '[:lower:]' '[:upper:]' } main() { diff --git a/src/web_block.sh b/src/web_block.sh index 5b064ba..6cda9c6 100755 --- a/src/web_block.sh +++ b/src/web_block.sh @@ -2,8 +2,13 @@ # Script Name: web_block.sh # Description: Block or unblock websites by modifying the hosts file, with advanced options and features. +# Blocked domains are mapped to 127.0.0.1 inside a section delimited by +# "# WEB_BLOCK_START" / "# WEB_BLOCK_END" marker lines; entries outside that +# section (e.g. "127.0.0.1 localhost") are never listed, changed or removed. +# A copy of the hosts file is saved to the backup file before every change. # # Usage: sudo ./web_block.sh [options] domain1 [domain2 ... domainN] +# Root is only needed if the hosts file is not writable by the current user. # # Options: # -h, --help Display this help message and exit. @@ -12,12 +17,15 @@ # -l, --list List all currently blocked domains. # -b, --backup FILE Specify a backup file for the hosts file (default: '/etc/hosts.bak'). # -d, --dry-run Show what would be done without making changes. -# -L, --log FILE Enable logging to the specified file (default: '/var/log/web_block.log'). +# -L, --log FILE Enable logging to the specified file. # -f, --force Force the operation without prompting for confirmation. # -V, --verbose Enable verbose output. -# -c, --config FILE Specify a configuration file. +# -c, --config FILE Specify a configuration file (key = value lines; keys: hosts_file, +# backup_file, log_file, log_enabled, dry_run, force, verbose, +# with_www; values for flags are true/false). Command-line +# options take precedence over the configuration file. # -H, --hosts FILE Specify a custom hosts file (default: '/etc/hosts'). -# -w, --with-www Also block www subdomain when adding domains. +# -w, --with-www Also process the www subdomain of each domain. # -s, --status Show status of specified domain(s). # -R, --restore Restore hosts file from backup. # -C, --clear Remove all blocked domains. @@ -59,14 +67,17 @@ readonly YELLOW='\033[1;33m' readonly BLUE='\033[0;34m' readonly NC='\033[0m' # No Color -# Block marker comments -readonly BLOCK_START="# WEB_BLOCK_START - Managed by $SCRIPT_NAME" -readonly BLOCK_END="# WEB_BLOCK_END - Managed by $SCRIPT_NAME" +# Block marker comments (matched by prefix, so sections written under another script name still work) +readonly BLOCK_START_PREFIX="# WEB_BLOCK_START" +readonly BLOCK_END_PREFIX="# WEB_BLOCK_END" +readonly BLOCK_START="$BLOCK_START_PREFIX - Managed by web_block.sh" +readonly BLOCK_END="$BLOCK_END_PREFIX - Managed by web_block.sh" +# Show the header comment block; exits with the given status (default 0) function show_help() { - grep '^#' "$0" | cut -c 4- + sed -n '3,/^[^#]/{/^#/s/^# \{0,1\}//p}' "$0" echo -e "\n${BLUE}Version:${NC} $SCRIPT_VERSION" - exit 0 + exit "${1:-0}" } function print_error() { @@ -94,20 +105,16 @@ function log_action() { # Ensure log directory exists local log_dir log_dir=$(dirname "$LOG_FILE") - if [[ ! -d "$log_dir" ]]; then - mkdir -p "$log_dir" 2>/dev/null || { - print_warning "Could not create log directory: $log_dir" - return 1 - } - fi - - echo "$timestamp [$SCRIPT_NAME]: $message" >> "$LOG_FILE" || { + if [[ ! -d "$log_dir" ]] && ! mkdir -p "$log_dir" 2>/dev/null; then + print_warning "Could not create log directory: $log_dir" + elif ! { echo "$timestamp [$SCRIPT_NAME]: $message" >> "$LOG_FILE"; } 2>/dev/null; then print_warning "Could not write to log file: $LOG_FILE" - return 1 - } + fi fi - [[ "$VERBOSE" == true ]] && print_info "$message" + if [[ "$VERBOSE" == true ]]; then + print_info "$message" + fi } function validate_domain() { @@ -135,7 +142,7 @@ function validate_domain() { } function check_dependencies() { - local deps=("sed" "grep" "awk" "cp" "cat") + local deps=("getopt" "grep" "awk" "cp" "cat" "mktemp" "sort" "wc") local missing_deps=() for dep in "${deps[@]}"; do @@ -199,71 +206,106 @@ function get_domains_to_process() { printf '%s\n' "${domains_list[@]}" } +# Print the domains blocked inside the managed section, one per line +function managed_domains() { + awk -v start="$BLOCK_START_PREFIX" -v end="$BLOCK_END_PREFIX" ' + index($0, start) == 1 { inside = 1; next } + index($0, end) == 1 { inside = 0; next } + inside && $1 == "127.0.0.1" && NF >= 2 { print $2 } + ' "$HOSTS_FILE" 2>/dev/null +} + function is_domain_blocked() { - local domain="$1" - grep -qF "127.0.0.1 $domain" "$HOSTS_FILE" 2>/dev/null + managed_domains | grep -qxF -- "$1" +} + +# Rewrite the hosts file with the output of: awk . +# The result is written back through the existing file (cat >), which keeps its +# permissions, ownership and inode (also works for bind-mounted /etc/hosts). +function rewrite_hosts() { + local tmp + tmp=$(mktemp) || { print_error "Cannot create temporary file"; exit 1; } + if ! awk "$@" "$HOSTS_FILE" > "$tmp" || ! cat "$tmp" > "$HOSTS_FILE"; then + rm -f "$tmp" + print_error "Failed to update $HOSTS_FILE (backup: $BACKUP_FILE)" + exit 1 + fi + rm -f "$tmp" } function add_managed_section() { - if [[ "$DRY_RUN" == false ]]; then - if ! grep -qF "$BLOCK_START" "$HOSTS_FILE" 2>/dev/null; then - { - echo "" - echo "$BLOCK_START" - echo "$BLOCK_END" - } >> "$HOSTS_FILE" - fi + if ! grep -q "^$BLOCK_START_PREFIX" "$HOSTS_FILE" 2>/dev/null; then + { + echo "" + echo "$BLOCK_START" + echo "$BLOCK_END" + } >> "$HOSTS_FILE" fi } +function add_entry() { + add_managed_section + # Insert just before the end marker of the managed section + # shellcheck disable=SC2016 # $0 is awk's, not the shell's + rewrite_hosts -v end="$BLOCK_END_PREFIX" -v entry="127.0.0.1 $1" ' + index($0, end) == 1 && !done { print entry; done = 1 } + { print } + ' +} + +function remove_entry() { + # shellcheck disable=SC2016 # $0, $1, $2 are awk's, not the shell's + rewrite_hosts -v start="$BLOCK_START_PREFIX" -v end="$BLOCK_END_PREFIX" -v domain="$1" ' + index($0, start) == 1 { inside = 1 } + index($0, end) == 1 { inside = 0 } + inside && $1 == "127.0.0.1" && $2 == domain { next } + { print } + ' +} + function modify_hosts() { local action="$1" shift local input_domains=("$@") local processed_count=0 local skipped_count=0 + local invalid_count=0 + local domain target_domain action_msg + local -a domains_to_process for domain in "${input_domains[@]}"; do - validate_domain "$domain" || continue + if ! validate_domain "$domain"; then + invalid_count=$((invalid_count + 1)) + continue + fi # Get all domains to process (including www if requested) mapfile -t domains_to_process < <(get_domains_to_process "$domain") for target_domain in "${domains_to_process[@]}"; do - local entry="127.0.0.1 $target_domain" - local action_msg="" - case "$action" in add) if is_domain_blocked "$target_domain"; then action_msg="Domain '$target_domain' is already blocked" - ((skipped_count++)) + skipped_count=$((skipped_count + 1)) + elif [[ "$DRY_RUN" == false ]]; then + add_entry "$target_domain" + action_msg="Blocked domain '$target_domain'" + processed_count=$((processed_count + 1)) else - if [[ "$DRY_RUN" == false ]]; then - add_managed_section - # Insert before the end marker - sed -i.tmp "/^${BLOCK_END//\//\\/}$/i\\ -$entry" "$HOSTS_FILE" && rm -f "$HOSTS_FILE.tmp" - action_msg="Blocked domain '$target_domain'" - ((processed_count++)) - else - action_msg="[Dry Run] Would block domain '$target_domain'" - fi + action_msg="[Dry Run] Would block domain '$target_domain'" fi ;; remove) - if is_domain_blocked "$target_domain"; then - if [[ "$DRY_RUN" == false ]]; then - # shellcheck disable=SC2016 - sed -i.tmp "/^127\.0\.0\.1[[:space:]]\+$(printf '%s\n' "$target_domain" | sed 's/[[\.*^$()+?{|]/\\&/g')$/d" "$HOSTS_FILE" && rm -f "$HOSTS_FILE.tmp" - action_msg="Unblocked domain '$target_domain'" - ((processed_count++)) - else - action_msg="[Dry Run] Would unblock domain '$target_domain'" - fi - else + if ! is_domain_blocked "$target_domain"; then action_msg="Domain '$target_domain' is not currently blocked" - ((skipped_count++)) + skipped_count=$((skipped_count + 1)) + elif [[ "$DRY_RUN" == false ]]; then + remove_entry "$target_domain" + action_msg="Unblocked domain '$target_domain'" + processed_count=$((processed_count + 1)) + else + action_msg="[Dry Run] Would unblock domain '$target_domain'" fi ;; *) @@ -284,32 +326,41 @@ $entry" "$HOSTS_FILE" && rm -f "$HOSTS_FILE.tmp" if [[ $skipped_count -gt 0 ]]; then print_info "Skipped $skipped_count domain(s)" fi + if [[ $invalid_count -gt 0 ]]; then + return 1 + fi } function list_blocked_domains() { print_info "Currently blocked domains:" local blocked_domains - blocked_domains=$(grep "^127\.0\.0\.1[[:space:]]" "$HOSTS_FILE" 2>/dev/null | awk '{print $2}' | sort -u) + blocked_domains=$(managed_domains | sort -u) if [[ -z "$blocked_domains" ]]; then echo " No domains are currently blocked." else - echo "$blocked_domains" | while read -r domain; do + local domain + while read -r domain; do echo " - $domain" - done + done <<< "$blocked_domains" echo "" - echo "Total: $(echo "$blocked_domains" | wc -l) blocked domain(s)" + echo "Total: $(wc -l <<< "$blocked_domains") blocked domain(s)" fi } function show_domain_status() { local domains=("$@") + local domain target_domain status=0 + local -a domains_to_check print_info "Domain status:" for domain in "${domains[@]}"; do - validate_domain "$domain" || continue + if ! validate_domain "$domain"; then + status=1 + continue + fi mapfile -t domains_to_check < <(get_domains_to_process "$domain") @@ -321,13 +372,14 @@ function show_domain_status() { fi done done + return "$status" } function clear_all_blocked() { local blocked_count - blocked_count=$(grep -c "^127\.0\.0\.1[[:space:]]" "$HOSTS_FILE" 2>/dev/null || echo "0") + blocked_count=$(managed_domains | wc -l) - if [[ $blocked_count -eq 0 ]]; then + if ! grep -q "^$BLOCK_START_PREFIX" "$HOSTS_FILE" 2>/dev/null; then print_info "No blocked domains found" return 0 fi @@ -335,8 +387,16 @@ function clear_all_blocked() { print_warning "This will remove all $blocked_count blocked domain(s)" if [[ "$DRY_RUN" == false ]]; then - # Remove all 127.0.0.1 entries and managed section - sed -i.tmp '/^127\.0\.0\.1[[:space:]]/d; /^# WEB_BLOCK_START/,/^# WEB_BLOCK_END/d' "$HOSTS_FILE" && rm -f "$HOSTS_FILE.tmp" + # Drop the whole managed section (and the blank line added before it) + # shellcheck disable=SC2016 # $0 is awk's, not the shell's + rewrite_hosts -v start="$BLOCK_START_PREFIX" -v end="$BLOCK_END_PREFIX" ' + index($0, start) == 1 { inside = 1; blank = 0; next } + inside { if (index($0, end) == 1) inside = 0; next } + blank { print ""; blank = 0 } + $0 == "" { blank = 1; next } + { print } + END { if (blank) print "" } + ' print_success "Cleared all blocked domains" log_action "Cleared all blocked domains ($blocked_count total)" else @@ -346,8 +406,8 @@ function clear_all_blocked() { function confirm_operation() { if [[ "$FORCE" == false ]]; then - local response - read -r -p "Are you sure you want to proceed? (y/N): " response + local response="" + read -r -p "Are you sure you want to proceed? (y/N): " response || true case "$response" in [yY]|[yY][eE][sS]) return 0 @@ -360,44 +420,53 @@ function confirm_operation() { fi } +function trim() { + local s="$1" + s="${s#"${s%%[![:space:]]*}"}" + s="${s%"${s##*[![:space:]]}"}" + printf '%s' "$s" +} + +function parse_bool() { + local key="$1" value="$2" + case "${value,,}" in + true|yes|1) echo true ;; + false|no|0) echo false ;; + *) + print_error "Invalid value for '$key' in $CONFIG_FILE: '$value' (use true or false)" + exit 1 + ;; + esac +} + function parse_config_file() { if [[ ! -f "$CONFIG_FILE" ]]; then print_error "Configuration file not found: '$CONFIG_FILE'" exit 1 fi - log_action "Loading configuration from: $CONFIG_FILE" - + local key value while IFS='=' read -r key value || [[ -n "$key" ]]; do - # Skip comments and empty lines - [[ "$key" =~ ^[[:space:]]*# ]] && continue - [[ -z "$key" ]] && continue + key=$(trim "$key") + value=$(trim "$value") - # Trim whitespace - key=$(echo "$key" | xargs) - value=$(echo "$value" | xargs) + # Skip comments and empty lines + [[ -z "$key" || "$key" == \#* ]] && continue case "$key" in hosts_file) HOSTS_FILE="$value" ;; backup_file) BACKUP_FILE="$value" ;; log_file) LOG_FILE="$value" ;; - log_enabled) LOG_ENABLED="$value" ;; - dry_run) DRY_RUN="$value" ;; - force) FORCE="$value" ;; - verbose) VERBOSE="$value" ;; - with_www) WITH_WWW="$value" ;; - *) - [[ "$VERBOSE" == true ]] && print_warning "Unknown configuration option: '$key'" - ;; + log_enabled) LOG_ENABLED=$(parse_bool "$key" "$value") ;; + dry_run) DRY_RUN=$(parse_bool "$key" "$value") ;; + force) FORCE=$(parse_bool "$key" "$value") ;; + verbose) VERBOSE=$(parse_bool "$key" "$value") ;; + with_www) WITH_WWW=$(parse_bool "$key" "$value") ;; + *) print_warning "Unknown configuration option: '$key'" ;; esac done < "$CONFIG_FILE" -} -function check_root() { - if [[ $EUID -ne 0 ]]; then - print_error "This script must be run with sudo or as root" - exit 1 - fi + log_action "Loaded configuration from: $CONFIG_FILE" } function validate_files() { @@ -408,7 +477,7 @@ function validate_files() { fi if [[ ! -w "$HOSTS_FILE" ]]; then - print_error "Hosts file is not writable: $HOSTS_FILE" + print_error "Hosts file is not writable: $HOSTS_FILE (run with sudo or as root)" exit 1 fi @@ -423,6 +492,8 @@ function validate_files() { fi } +check_dependencies + # Parse command line options if ! TEMP=$(getopt -o harlb:dL:fVc:H:wsRC --long help,add,remove,list,backup:,dry-run,log:,force,verbose,config:,hosts:,with-www,status,restore,clear -n "$SCRIPT_NAME" -- "$@"); then print_error "Failed to parse command line options" @@ -431,43 +502,60 @@ fi eval set -- "$TEMP" +# Load the configuration file first, so command-line options override it +args=("$@") +for ((i = 0; i < ${#args[@]}; i++)); do + case "${args[i]}" in + -c|--config) CONFIG_FILE="${args[i + 1]}"; i=$((i + 1)) ;; + -b|--backup|-L|--log|-H|--hosts) i=$((i + 1)) ;; + --) break ;; + esac +done +if [[ -n "$CONFIG_FILE" ]]; then + parse_config_file +fi + +set_operation() { + if [[ -n "$OPERATION" && "$OPERATION" != "$1" ]]; then + print_error "Only one operation can be given (got --$OPERATION and --$1)" + exit 1 + fi + OPERATION="$1" +} + while true; do case "$1" in -h|--help) show_help ;; - -a|--add) OPERATION="add"; shift ;; - -r|--remove) OPERATION="remove"; shift ;; - -l|--list) OPERATION="list"; shift ;; - -s|--status) OPERATION="status"; shift ;; - -R|--restore) OPERATION="restore"; shift ;; - -C|--clear) OPERATION="clear"; shift ;; + -a|--add) set_operation add; shift ;; + -r|--remove) set_operation remove; shift ;; + -l|--list) set_operation list; shift ;; + -s|--status) set_operation status; shift ;; + -R|--restore) set_operation restore; shift ;; + -C|--clear) set_operation clear; shift ;; -b|--backup) BACKUP_FILE="$2"; shift 2 ;; -d|--dry-run) DRY_RUN=true; shift ;; - -L|--log) LOG_ENABLED=true; LOG_FILE="${2:-$LOG_FILE}"; shift 2 ;; + -L|--log) LOG_ENABLED=true; LOG_FILE="$2"; shift 2 ;; -f|--force) FORCE=true; shift ;; -V|--verbose) VERBOSE=true; shift ;; -w|--with-www) WITH_WWW=true; shift ;; - -c|--config) CONFIG_FILE="$2"; shift 2 ;; + -c|--config) shift 2 ;; # already loaded above -H|--hosts) HOSTS_FILE="$2"; shift 2 ;; --) shift; break ;; - *) print_error "Unknown option: '$1'"; show_help ;; + *) print_error "Unknown option: '$1'"; show_help 1 ;; esac done # Main execution starts here main() { - # Check dependencies first - check_dependencies - - # Load configuration file if specified - if [[ -n "$CONFIG_FILE" ]]; then - parse_config_file - fi - - # Ensure the script is run as root (except for help and some read-only operations) - if [[ "$OPERATION" != "list" && "$OPERATION" != "status" && "$OPERATION" != "help" ]]; then - check_root - validate_files - fi + # Operations that change the hosts file need write access to it + # (read-only operations and dry runs don't) + case "$OPERATION" in + add|remove|restore|clear) + if [[ "$DRY_RUN" == false ]]; then + validate_files + fi + ;; + esac # Handle operations that don't require domains case "$OPERATION" in @@ -482,45 +570,38 @@ main() { ;; clear) confirm_operation - if [[ "$DRY_RUN" == false ]]; then - backup_hosts - fi + backup_hosts clear_all_blocked exit 0 ;; "") print_error "No operation specified" - show_help + show_help 1 ;; esac # Collect domains from arguments if [[ $# -lt 1 ]]; then print_error "No domain(s) specified" - show_help + show_help 1 fi - # Remove www prefix and add to domains array + # Domains are case-insensitive: store them in lowercase + local arg for arg in "$@"; do - DOMAINS+=("${arg#www.}") + DOMAINS+=("${arg,,}") done # Handle operations that require domains case "$OPERATION" in add|remove) confirm_operation - if [[ "$DRY_RUN" == false ]]; then - backup_hosts - fi + backup_hosts modify_hosts "$OPERATION" "${DOMAINS[@]}" ;; status) show_domain_status "${DOMAINS[@]}" ;; - *) - print_error "Invalid operation: $OPERATION" - show_help - ;; esac } diff --git a/src/word_histogram.sh b/src/word_histogram.sh index bca68fc..c423ff4 100755 --- a/src/word_histogram.sh +++ b/src/word_histogram.sh @@ -1,91 +1,101 @@ #!/usr/bin/env bash # Script Name: word_histogram.sh -# Description: Generates a word frequency histogram from a text file. -# Usage: ./word_histogram.sh [min_word_length] [top_n] -# Example: ./word_histogram.sh document.txt 3 20 +# Description: Generates a word frequency histogram from text files (or standard input). +# Words are lowercased, common diacritics are removed (ą -> a, ...) and +# non-letter characters are dropped. Counts from all files are combined +# and printed as "word:count", most frequent first. +# Usage: ./word_histogram.sh [-l min_word_length] [-n top_n] [-j] [file ...] +# -l N Only count words with at least N letters (default: 1). +# -n N Only print the N most frequent words (default: all). +# -j Output a JSON object instead (requires jq). +# Example: ./word_histogram.sh -l 3 -n 20 document.txt -# Function to remove diacritics from a line of text -remove_diacritics() -{ - echo "$1" | sed 'y/ąāáǎàćēéěèęīíǐìłńōóǒòóśūúǔùǖǘǚǜżźĄĀÁǍÀĆĒĘÉĚÈĪÍǏÌŁŃŌÓǑÒÓŚŪÚǓÙǕǗǙǛŻŹ/aaaaaceeeeeiiiilnooooosuuuuuuuuzzAAAAACEEEEEIIIILNOOOOOSUUUUUUUUZZ/' +usage() { + echo "Usage: $0 [-l min_word_length] [-n top_n] [-j] [file ...]" >&2 } -# Process text to calculate word frequencies -process_text() { - local file=$1 - local min_word_length=$2 - declare -A wordcounts +# Remove diacritics from the input text +remove_diacritics() { + sed 'y/ąāáǎàćēéěèęīíǐìłńōóǒòóśūúǔùǖǘǚǜżźĄĀÁǍÀĆĒĘÉĚÈĪÍǏÌŁŃŌÓǑÒÓŚŪÚǓÙǕǗǙǛŻŹ/aaaaaceeeeeiiiilnooooosuuuuuuuuzzAAAAACEEEEEIIIILNOOOOOSUUUUUUUUZZ/' +} - while IFS= read -r line; do - line=$(remove_diacritics "$line") - line=$(echo "$line" | tr -dc '[:alpha:][:space:]') +# Print "word:count" lines for the text on stdin, most frequent first +count_words() { + local min_word_length=$1 - for word in $line; do - word=${word,,} # Convert to lowercase - if [ ${#word} -ge "$min_word_length" ]; then - ((wordcounts[$word]++)) - fi - done - done < "$file" + remove_diacritics \ + | tr -dc '[:alpha:][:space:]' \ + | tr -s '[:space:]' '\n' \ + | tr '[:upper:]' '[:lower:]' \ + | awk -v min="$min_word_length" 'length($0) >= min && length($0) > 0 { c[$0]++ } END { for (w in c) print w ":" c[w] }' \ + | sort -t: -k2,2nr -k1,1 +} - # Output word frequencies - for word in "${!wordcounts[@]}"; do - echo "$word:${wordcounts[$word]}" - done +# Keep only the first top_n lines (all lines if top_n is 0) +limit_output() { + if (( top_n > 0 )); then + head -n "$top_n" + else + cat + fi } -export -f remove_diacritics process_text +# Print "word:count" lines as-is, or as a JSON object with -j +format_output() { + if $output_json; then + jq -Rn '[inputs | split(":") | {(.[0]): (.[1] | tonumber)}] | add // {}' + else + cat + fi +} -# Main program starts here -min_word_length=0 +min_word_length=1 +top_n=0 output_json=false -while getopts ":l:j" opt; do +while getopts ":l:n:j" opt; do case $opt in l) min_word_length=$OPTARG ;; + n) + top_n=$OPTARG + ;; j) output_json=true ;; - "?") - echo "Invalid option: -$OPTARG" >&2 - exit 1 - ;; ":") echo "Option -$OPTARG requires an argument." >&2 + usage + exit 1 + ;; + *) + echo "Invalid option: -$OPTARG" >&2 + usage exit 1 ;; esac done -shift $((OPTIND -1)) -# Define the temporary file -temp_file=$(mktemp) +shift $((OPTIND - 1)) -# Check if files are provided -if [ "$#" -eq 0 ]; then - # No files provided, reading from stdin - input="/dev/stdin" - if $output_json; then - process_text "$input" "$min_word_length" > "$temp_file" - jq -Rn '[inputs | split(":") | {(.[0]): (. [1] | tonumber)}] | add' < "$temp_file" - else - process_text "$input" "$min_word_length" - fi -else - # Process files in parallel - export min_word_length - export output_json - if $output_json; then - parallel --will-cite "process_text {} $min_word_length" ::: "$@" > "$temp_file" - jq -Rn '[inputs | split(":") | {(.[0]): (. [1] | tonumber)}] | add' < "$temp_file" - else - parallel --will-cite "process_text {} $min_word_length" ::: "$@" > "$temp_file" - sort -t: -k2,2nr < "$temp_file" - fi +if ! [[ $min_word_length =~ ^[0-9]+$ && $top_n =~ ^[0-9]+$ ]]; then + echo "Error: -l and -n require non-negative integers." >&2 + exit 1 +fi + +if $output_json && ! command -v jq &> /dev/null; then + echo "Error: jq is required for JSON output (-j)." >&2 + exit 1 fi -# Clean up the temporary file -rm "$temp_file" +for file in "$@"; do + if [[ ! -f $file || ! -r $file ]]; then + echo "Error: cannot read file: $file" >&2 + exit 1 + fi +done + +# With no files, cat reads standard input +cat -- "$@" | count_words "$min_word_length" | limit_output | format_output diff --git a/src/xfce-default.sh b/src/xfce-default.sh index b2e52af..6a4bb6d 100755 --- a/src/xfce-default.sh +++ b/src/xfce-default.sh @@ -19,9 +19,11 @@ XFCE_SESSION_FILE="/usr/share/xsessions/xfce.desktop" XFCE_PACKAGE="mint-meta-xfce" ############################################################################### -# display_help: Print usage information and exit +# display_help: Print usage information and exit with status $1 (default 0) ############################################################################### display_help() { + local status="${1:-0}" + cat < "${user_home}/.dmrc" <<'EODMRC' -[Desktop] -Session=xfce -EODMRC - - chown "${target_user}:${target_user}" "${user_home}/.dmrc" - chmod 644 "${user_home}/.dmrc" + # Write the file as the target user rather than as root, so a symlink + # planted at ~/.dmrc cannot make root overwrite an arbitrary file. + # shellcheck disable=SC2016 # $1 is expanded by the inner sh, not here + runuser -u "${target_user}" -- sh -c \ + 'printf "[Desktop]\nSession=xfce\n" > "$1" && chmod 644 "$1"' \ + sh "${user_home}/.dmrc" echo "Per-user session written to ${user_home}/.dmrc" } @@ -180,7 +181,7 @@ main() { ;; *) echo "Unknown option: $1" >&2 - display_help + display_help 1 >&2 ;; esac shift @@ -190,6 +191,7 @@ main() { need_cmd sed need_cmd grep need_cmd getent + need_cmd runuser need_cmd cp check_root diff --git a/src/youtube_to_mp3.sh b/src/youtube_to_mp3.sh index 9a52124..321dd74 100755 --- a/src/youtube_to_mp3.sh +++ b/src/youtube_to_mp3.sh @@ -1,15 +1,21 @@ #!/usr/bin/env bash # Script Name: youtube_to_mp3.sh -# Description: Downloads a YouTube video and converts it to MP3 format using yt-dlp and ffmpeg. +# Description: Downloads the audio of a YouTube video (or every video of a playlist) +# and converts it to MP3 format using yt-dlp and ffmpeg. +# Files are named after the video title (spaces become underscores). +# If yt-dlp/ffmpeg are missing, offers to install them (apt/pacman, needs sudo). # Usage: ./youtube_to_mp3.sh [output_directory] +# output_directory defaults to the current directory. # Example: ./youtube_to_mp3.sh https://www.youtube.com/watch?v=example ./music +DEPENDENCIES=("yt-dlp" "ffmpeg") + check_dependencies() { - local dependencies=("yt-dlp" "ffmpeg") - for dependency in "${dependencies[@]}"; do - if ! which "$dependency" >/dev/null 2>&1; then - echo "Error: '$dependency' is not installed." + local dependency + for dependency in "${DEPENDENCIES[@]}"; do + if ! command -v "$dependency" >/dev/null 2>&1; then + echo "Error: '$dependency' is not installed." >&2 return 1 fi done @@ -17,100 +23,83 @@ check_dependencies() { } try_to_install_dependencies() { - local dependencies=("yt-dlp" "ffmpeg") - - if [ -f /etc/os-release ]; then - if grep -q "debian" /etc/os-release; then - local command_to_install="apt install" - elif grep -q "arch" /etc/os-release; then - local command_to_install="pacman -S" - else - echo "Error: Unknown OS" - return 1 - fi + local -a command_to_install + local dependency + + if [ ! -f /etc/os-release ]; then + echo "Error: /etc/os-release not found" >&2 + return 1 + fi + + if grep -q "debian" /etc/os-release; then + command_to_install=(apt install) + elif grep -q "arch" /etc/os-release; then + command_to_install=(pacman -S) else - echo "Error: /etc/os-release not found" + echo "Error: Unknown OS; please install ${DEPENDENCIES[*]} manually." >&2 return 1 fi - for dependency in "${dependencies[@]}"; do - if ! which "$dependency" >/dev/null 2>&1; then + for dependency in "${DEPENDENCIES[@]}"; do + if ! command -v "$dependency" >/dev/null 2>&1; then echo "Trying to install '$dependency'." - eval sudo "$command_to_install" "$dependency" + sudo "${command_to_install[@]}" "$dependency" fi done } -download_video() { - local url="$1" - local output_file="$2" - echo "Attempting to download video from '$url'" - yt-dlp -f bestaudio -o "$output_file" "$url" - echo "Saving video to '$output_file'" -} - -convert_video_to_mp3() { - local input_file="$1" - local output_file="${input_file%.*}.mp3" - echo "Converting video to mp3" - ffmpeg -i "$input_file" -vn -ab 128k "$output_file" - echo "Audio file saved to '$output_file'" - rm "$input_file" - echo "Video file deleted" -} - +# Download one video's audio as "/.mp3". download_video_and_convert_to_mp3() { local url="$1" - local output_file - if [ -z "$2" ]; then - output_file=$(yt-dlp --get-title "$url") - output_file=$(echo "$output_file" | tr ' ' '_' | tr '[:upper:]' '[:lower:]') - else - output_file="$2" - fi - - download_video "$url" "$output_file" - convert_video_to_mp3 "$output_file" -} - -read_playlist_to_array() { - local url="$1" - local temp_file - temp_file="$(mktemp)" - yt-dlp -j --flat-playlist --skip-download "$url" | jq -r '.id' | sed 's_^_https://youtu.be/_' > "$temp_file" - IFS=$'\n' read -d '' -r -a all_videos_urls < "$temp_file" - unset IFS + local output_dir="$2" + echo "Downloading audio from '$url' to '$output_dir'" + yt-dlp --no-playlist --extract-audio --audio-format mp3 --audio-quality 128K \ + --restrict-filenames --paths "$output_dir" --output "%(title)s.%(ext)s" "$url" } main() { - if [ $# -lt 1 ]; then - echo "Usage: youtube_to_mp3.sh [url] <output_file>" - return + if [ $# -lt 1 ] || [ $# -gt 2 ]; then + echo "Usage: youtube_to_mp3.sh <youtube_url> [output_directory]" >&2 + return 1 fi + local url="$1" + local output_dir="${2:-.}" + if ! check_dependencies; then try_to_install_dependencies if ! check_dependencies; then - echo "Error: Dependencies not installed." + echo "Error: Dependencies not installed." >&2 return 1 fi fi - local url="$1" - read_playlist_to_array "$url" + if ! mkdir -p -- "$output_dir"; then + echo "Error: cannot create output directory '$output_dir'." >&2 + return 1 + fi + + local -a all_videos_urls + mapfile -t all_videos_urls < <(yt-dlp --flat-playlist --print "%(id)s" "$url" | sed 's_^_https://youtu.be/_') if [ "${#all_videos_urls[@]}" -eq 0 ]; then - echo "No videos found in the playlist." + echo "Error: No videos found at '$url'." >&2 return 1 fi - if [ "${#all_videos_urls[@]}" -gt "1" ]; then - echo "Downloading playlist..." + local failures=0 video_url + if [ "${#all_videos_urls[@]}" -gt 1 ]; then + echo "Downloading playlist (${#all_videos_urls[@]} videos)..." for video_url in "${all_videos_urls[@]}"; do - download_video_and_convert_to_mp3 "$video_url" + download_video_and_convert_to_mp3 "$video_url" "$output_dir" || failures=$((failures + 1)) done else - download_video_and_convert_to_mp3 "$url" "$2" + download_video_and_convert_to_mp3 "$url" "$output_dir" || failures=1 + fi + + if [ "$failures" -gt 0 ]; then + echo "Error: $failures download(s) failed." >&2 + return 1 fi } diff --git a/src/zombies.sh b/src/zombies.sh index 19560aa..9853f69 100755 --- a/src/zombies.sh +++ b/src/zombies.sh @@ -203,16 +203,18 @@ check_zombies() { local zombie_count=0 local -a ps_options - # Build ps options - avoid combining -e with -u since -e overrides user filter + # Build ps options - avoid combining -e with -u since -e overrides user filter. + # comm comes last because it may contain spaces; lstart is always 5 words. + local columns="pid,ppid,user,state,etime,lstart,comm" if [[ "$USER_ONLY" == true ]]; then - ps_options=(-u "$(id -un)" -o "pid,ppid,user,comm,state,etime,lstart") + ps_options=(-u "$(id -un)" -o "$columns") else - ps_options=(-eo "pid,ppid,user,comm,state,etime,lstart") + ps_options=(-e -o "$columns") fi # Get zombie processes local zombies - if ! zombies=$(ps "${ps_options[@]}" --no-headers 2>/dev/null | awk '$5 ~ /^Z/'); then + if ! zombies=$(ps "${ps_options[@]}" --no-headers 2>/dev/null | awk '$4 ~ /^Z/'); then echo -e "${RED}Error: Failed to retrieve process information${NC}" >&2 return 1 fi @@ -251,11 +253,13 @@ check_zombies() { fi # Process each zombie - while IFS=' ' read -r pid ppid user comm state etime lstart; do + local pid ppid user state etime lstart_day lstart_month lstart_date lstart_time lstart_year comm lstart + while read -r pid ppid user state etime lstart_day lstart_month lstart_date lstart_time lstart_year comm; do # Skip empty lines if [[ -z "$pid" ]]; then continue fi + lstart="$lstart_day $lstart_month $lstart_date $lstart_time $lstart_year" # Truncate command name if too long if [[ ${#comm} -gt 20 ]]; then @@ -289,14 +293,11 @@ check_zombies() { echo -e "${BLUE}Summary:${NC}" echo -e " • Total zombie processes: ${YELLOW}$zombie_count${NC}" echo -e " • Zombies consume minimal resources but indicate parent process issues" - - if [[ "$zombie_count" -gt 0 ]]; then - echo - echo -e "${BLUE}Recommendations:${NC}" - echo -e " • Check parent processes for proper child process handling" - echo -e " • Consider restarting problematic parent processes" - echo -e " • Zombies will be cleaned up when parent processes exit" - fi + echo + echo -e "${BLUE}Recommendations:${NC}" + echo -e " • Check parent processes for proper child process handling" + echo -e " • Consider restarting problematic parent processes" + echo -e " • Zombies will be cleaned up when parent processes exit" return 0 } @@ -351,8 +352,11 @@ main() { # Check for required dependencies check_dependencies - # Set trap for cleanup - trap cleanup EXIT INT TERM + # Set traps: Ctrl+C / TERM must actually stop the script (a trap that just + # returns would resume the watch loop); cleanup runs once on exit. + trap cleanup EXIT + trap 'exit 130' INT + trap 'exit 143' TERM # Execute appropriate mode if [[ "$WATCH_MODE" == true ]]; then From 423cb42cefa2d5bee102638b7f4a4ae9f42a60bc Mon Sep 17 00:00:00 2001 From: Adam Djellouli <addjellouli1@gmail.com> Date: Sat, 3 Oct 2026 23:38:05 +0200 Subject: [PATCH 3/5] Modernize CI and add smoke tests - rename workflow to ci.yml, use actions/checkout@v4 and setup-python, apt-get update before installing, pin beautysh, cancel stale runs - add a smoke-test job: every script is checked for shebang, executable bit and syntax, and pure scripts are run against known outputs - hooks/_run_all.sh works from any directory, also checks tests/, and prints a summary of failing hooks - add .editorconfig matching the repo's formatting rules --- .editorconfig | 18 +++++ .github/workflows/ci.yml | 56 ++++++++------ hooks/README.md | 2 +- hooks/_run_all.sh | 75 +++++++++++------- tests/smoke_test.sh | 161 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 258 insertions(+), 54 deletions(-) create mode 100644 .editorconfig create mode 100755 tests/smoke_test.sh diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..b9f1420 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.sh] +indent_style = space +indent_size = 4 + +[*.{yml,yaml,json}] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5760ce8..f76389b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,40 +1,46 @@ -# This is a basic workflow to help you get started with Actions - name: CI -# Controls when the workflow will run on: - # Triggers the workflow on push or pull request events but only for the master branch push: - branches: [ master ] + branches: [master] pull_request: - branches: [ master ] - - # Allows you to run this workflow manually from the Actions tab + branches: [master] workflow_dispatch: -# A workflow run is made up of one or more jobs that can run sequentially or in parallel +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: - # This workflow contains a single job called "build" - build: - # The type of runner that the job will run on + lint: + name: Lint and format runs-on: ubuntu-latest - - # Steps represent a sequence of tasks that will be executed as part of the job steps: - # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - # Installing shellcheck - - name: Install ShellCheck - run: sudo apt-get install shellcheck + - uses: actions/setup-python@v5 + with: + python-version: "3.12" - # Installing beautysh - - name: Install Beautysh + - name: Install ShellCheck and Beautysh run: | - sudo apt-get install python3-pip - pip3 install beautysh + sudo apt-get update + sudo apt-get install -y shellcheck + pip install beautysh==6.4.3 + shellcheck --version + beautysh --version - # Execute defined hooks - name: Run all hooks - run: ${GITHUB_WORKSPACE}/hooks/_run_all.sh + run: ./hooks/_run_all.sh + + test: + name: Smoke tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Run smoke tests + run: ./tests/smoke_test.sh diff --git a/hooks/README.md b/hooks/README.md index e088837..df4314e 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -68,7 +68,7 @@ Removes trailing whitespace characters (spaces or tabs) from the end of lines in ### CI/CD Integration -These hooks are automatically run in the CI pipeline (see `.github/workflows/blank.yml`). The CI will fail if any checks don't pass. +These hooks are automatically run in the CI pipeline (see `.github/workflows/ci.yml`). The CI will fail if any checks don't pass. ### Git Pre-commit Hook (Optional) diff --git a/hooks/_run_all.sh b/hooks/_run_all.sh index 7425202..12a970c 100755 --- a/hooks/_run_all.sh +++ b/hooks/_run_all.sh @@ -1,44 +1,63 @@ #!/usr/bin/env bash # Script Name: _run_all.sh -# Description: This script will check all scripts in the specified paths. -# It finds all the scripts (not starting with _) in the 'hooks' directory and executes them with '--check' option. -# The path for the check is specified in the 'paths' array. -# At the end, it will exit with 1 if any check failed. -# Usage: ./hooks/_run_all.sh +# Description: Runs every hook in this directory (symlinks not starting with _) +# in check mode against the given paths, and exits with 1 if any +# check failed. Works from any working directory. +# Usage: ./hooks/_run_all.sh [path...] +# Paths are relative to the repository root (default: src tests). -# Ensure tput has something to work with -export TERM=${TERM:-dumb} +set -uo pipefail -# Paths to check -paths=(src) +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" || exit 1 + +if [[ $# -gt 0 ]]; then + paths=("$@") +else + paths=(src tests) +fi + +if [[ -t 1 ]]; then + RED=$'\e[31m' + GREEN=$'\e[32m' + RESET=$'\e[0m' +else + RED="" + GREEN="" + RESET="" +fi -# Status variable to track if any check fails status=0 +failed=() -# Define color codes (now safe because TERM is set) -RED=$(tput setaf 1) -GREEN=$(tput setaf 2) -RESET=$(tput sgr0) +mapfile -t hooks < <(find hooks -maxdepth 1 -type l -name '[^_]*.sh' | sort) +if [[ ${#hooks[@]} -eq 0 ]]; then + echo "${RED}No hooks found in $repo_root/hooks${RESET}" >&2 + exit 1 +fi -# Process each path for path in "${paths[@]}"; do - # Find all scripts (not starting with _) in 'hooks' directory - # Use process substitution to avoid subshell and preserve status variable - while read -r script; do - echo -e "\nExecuting $script" - - # Execute the script with '--check' option - if "$script" --check "$path"; then - echo "${GREEN}${script} check on ${path} was successful${RESET}" + [[ -e $path ]] || continue + for hook in "${hooks[@]}"; do + echo -e "\nExecuting $hook on $path" + if "$hook" --check "$path"; then + echo "${GREEN}${hook} check on ${path} was successful${RESET}" else - echo "${RED}${script} check on ${path} failed${RESET}" + echo "${RED}${hook} check on ${path} failed${RESET}" + failed+=("$hook ($path)") status=1 fi - done < <(find hooks -type l -name "[^_]*.sh") + done done -# Exit with 1 if any check failed -if [[ $status -eq 1 ]]; then - exit 1 +if [[ $status -ne 0 ]]; then + echo -e "\n${RED}Failed checks:${RESET}" + printf ' %s\n' "${failed[@]}" + echo "Run the failing hook without --check to fix the files, e.g. ./hooks/last_line_empty.sh src" +else + echo -e "\n${GREEN}All checks passed.${RESET}" fi + +exit "$status" + diff --git a/tests/smoke_test.sh b/tests/smoke_test.sh new file mode 100755 index 0000000..182704f --- /dev/null +++ b/tests/smoke_test.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash + +# Script Name: smoke_test.sh +# Description: Lightweight tests for the scripts in src/. Every script is checked +# for a shebang, the executable bit and valid bash syntax; scripts +# that are pure (no network, root or interactive input) are also run +# against known inputs and their output is compared. +# Usage: ./tests/smoke_test.sh + +set -uo pipefail + +export LC_ALL=C + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +src="$repo_root/src" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +passed=0 +failed=0 + +pass() { + passed=$((passed + 1)) +} + +fail() { + failed=$((failed + 1)) + echo "FAIL: $*" >&2 +} + +# expect_output <description> <expected stdout> <command...> +expect_output() { + local desc="$1" expected="$2" actual + shift 2 + actual="$("$@" 2>/dev/null)" + if [[ $actual == "$expected" ]]; then + pass + else + fail "$desc"$'\n'" expected: $(printf '%q' "$expected")"$'\n'" actual: $(printf '%q' "$actual")" + fi +} + +# expect_status <description> <expected exit code> <command...> +expect_status() { + local desc="$1" expected="$2" actual + shift 2 + "$@" >/dev/null 2>&1 + actual=$? + if [[ $actual -eq $expected ]]; then + pass + else + fail "$desc (expected exit $expected, got $actual)" + fi +} + +check_structure() { + local script + for script in "$src"/*.sh; do + local name="${script#"$repo_root"/}" + if [[ $(head -n1 "$script") == "#!/usr/bin/env bash" ]]; then pass; else fail "$name: missing '#!/usr/bin/env bash' shebang"; fi + if [[ -x $script ]]; then pass; else fail "$name: not executable"; fi + if bash -n "$script" 2>/dev/null; then pass; else fail "$name: syntax error"; fi + done +} + +test_new_utilities() { + # retry.sh + expect_status "retry: succeeds immediately" 0 "$src/retry.sh" true + expect_status "retry: propagates last exit code" 3 "$src/retry.sh" -n 2 -d 0 -q -- bash -c 'exit 3' + local counter="$work/counter" + expect_status "retry: succeeds on third attempt" 0 "$src/retry.sh" -n 5 -d 0 -q -- bash -c "echo x >>'$counter'; [ \$(wc -l <'$counter') -ge 3 ]" + expect_status "retry: rejects bad attempts" 2 "$src/retry.sh" -n x -- true + + # wait_for_port.sh + expect_status "wait_for_port: times out on closed port" 1 "$src/wait_for_port.sh" -q -t 1 127.0.0.1:1 + expect_status "wait_for_port: rejects malformed target" 2 "$src/wait_for_port.sh" localhost + expect_status "wait_for_port: rejects invalid port" 2 "$src/wait_for_port.sh" localhost:70000 + + # find_duplicate_files.sh + mkdir -p "$work/dups/sub dir" "$work/dups/.hidden" + echo same >"$work/dups/a.txt" + echo same >"$work/dups/sub dir/b c.txt" + echo same >"$work/dups/.hidden/d.txt" + echo diff >"$work/dups/e.txt" + expect_output "find_duplicate_files: finds group" \ + "$work/dups/a.txt"$'\n'"$work/dups/sub dir/b c.txt"$'\n\n'"Found 1 group(s) of duplicates; 5B could be reclaimed." \ + "$src/find_duplicate_files.sh" "$work/dups" + expect_output "find_duplicate_files: no duplicates" "No duplicate files found." \ + "$src/find_duplicate_files.sh" "$work/dups/sub dir" + + # find_large_files.sh + head -c 2048 /dev/zero >"$work/dups/big.bin" + expect_output "find_large_files: largest first" " 2.0KB $work/dups/big.bin" \ + "$src/find_large_files.sh" -n 1 "$work/dups" +} + +test_math_and_strings() { + expect_output "factorial: 5" "The factorial of 5 is: 120" "$src/factorial.sh" 5 + expect_output "factorial: 0" "The factorial of 0 is: 1" "$src/factorial.sh" 0 + expect_output "is_prime: 7" "7 is a prime number!" "$src/is_prime.sh" 7 + expect_output "is_prime: leading zero is decimal" "8 is not a prime number!" "$src/is_prime.sh" 08 + expect_output "decimal_binary: d2b" "Conversion of decimal number 10 to binary:"$'\n'"1010" "$src/decimal_binary.sh" -d2b 10 + expect_output "decimal_binary: b2d" "Conversion of binary number 1010 to decimal:"$'\n'"10" "$src/decimal_binary.sh" -b2d 1010 + expect_output "sum_args" "Arguments submitted:"$'\n'"1"$'\n'"2"$'\n'"08"$'\n'"Sum of the arguments: 11" "$src/sum_args.sh" 1 2 08 + expect_output "sum_smaller_numbers" "The sum of integers smaller than 5 is 10." "$src/sum_smaller_numbers.sh" 5 + expect_output "sqrt: rounds" "2.83" "$src/sqrt.sh" 8 2 + expect_output "sqrt: zero" "0" "$src/sqrt.sh" 0 + expect_output "arith_mean" "Arithmetic mean of 1 2 3 4 is 2.50" "$src/arith_mean.sh" 1 2 3 4 + expect_output "max_array: negatives" "7" "$src/max_array.sh" 3 -1 7 + expect_output "min_array: negatives" "-1" "$src/min_array.sh" 3 -1 7 + expect_output "numbers_in_interval" "3"$'\n'"4"$'\n'"5" "$src/numbers_in_interval.sh" 3 5 + expect_output "remove_duplicates_in_array: keeps order" "b a c" "$src/remove_duplicates_in_array.sh" b a b c + expect_output "hamming_distance" 'The Hamming Distance between "abc" and "abd" is: 1' "$src/hamming_distance.sh" abc abd + expect_status "hamming_distance: unequal lengths" 2 "$src/hamming_distance.sh" abc ab + expect_status "are_anagrams: yes" 0 "$src/are_anagrams.sh" listen silent + expect_status "are_anagrams: no" 1 "$src/are_anagrams.sh" abc abd + expect_output "count_char: spaces" "2" "$src/count_char.sh" "a b c" " " + expect_output "upper" "HELLO" "$src/upper.sh" hello + expect_output "lower" "hello" "$src/lower.sh" HeLLo + expect_output "is_palindrome" "true" "$src/is_palindrome.sh" racecar + expect_output "sort_string" "abcd" "$src/sort_string.sh" dcba + expect_output "month_to_number: full name" "3" "$src/month_to_number.sh" March + expect_output "month_to_number: short name" "1" "$src/month_to_number.sh" jan + expect_output "month_to_number: number" "aug" "$src/month_to_number.sh" 08 +} + +test_file_utilities() { + local f="$work/file.txt" + + printf 'a\nb\nc\n' >"$f" + expect_output "middle_line" "b" "$src/middle_line.sh" "$f" + + echo "ab1c 22x" >"$f" + "$src/strip_digits.sh" "$f" >/dev/null 2>&1 + expect_output "strip_digits: removes digits only" "abc x" cat "$f" + + printf 'x \ny\t\n' >"$f" + chmod 755 "$f" + expect_status "remove_trailing_whitespaces: --check detects" 1 "$src/remove_trailing_whitespaces.sh" --check "$f" + "$src/remove_trailing_whitespaces.sh" "$f" >/dev/null 2>&1 + expect_output "remove_trailing_whitespaces: fixes" "x"$'\n'"y" cat "$f" + expect_output "remove_trailing_whitespaces: keeps mode" "755" stat -c %a "$f" + + printf 'x\r\n' >"$f" + expect_status "remove_carriage_return: --check detects" 1 "$src/remove_carriage_return.sh" --check "$f" + + printf 'x\n' >"$f" + expect_status "last_line_empty: --check detects" 1 "$src/last_line_empty.sh" --check "$f" + "$src/last_line_empty.sh" "$f" >/dev/null 2>&1 + expect_output "last_line_empty: keeps mode" "755" stat -c %a "$f" + expect_status "last_line_empty: fixed" 0 "$src/last_line_empty.sh" --check "$f" +} + +check_structure +test_new_utilities +test_math_and_strings +test_file_utilities + +echo "Passed: $passed, failed: $failed" +[[ $failed -eq 0 ]] + From 5ce67434506f064174f5e57917812eacba5b1b05 Mon Sep 17 00:00:00 2001 From: Adam Djellouli <addjellouli1@gmail.com> Date: Sat, 3 Oct 2026 23:38:05 +0200 Subject: [PATCH 4/5] Add new utility scripts and refresh README New scripts: - retry.sh: retry a command with exponential backoff - wait_for_port.sh: wait for a TCP port, then optionally run a command - find_duplicate_files.sh: find identical files and report wasted space - find_large_files.sh: list the largest files or directories - ssl_cert_expiry.sh: check TLS certificate expiry for hosts (cron friendly) - git_cleanup_branches.sh: delete merged or upstream-gone local branches README: list the new scripts and the previously undocumented run_audiocraft.sh, run_videogen.sh and setup_obs_background_blur.sh, correct descriptions that no longer matched behavior, document how to run the checks locally, and normalize repository link casing. --- README.md | 96 ++++++++++++++++++------------ src/find_duplicate_files.sh | 103 ++++++++++++++++++++++++++++++++ src/find_large_files.sh | 73 +++++++++++++++++++++++ src/git_cleanup_branches.sh | 115 ++++++++++++++++++++++++++++++++++++ src/retry.sh | 78 ++++++++++++++++++++++++ src/ssl_cert_expiry.sh | 89 ++++++++++++++++++++++++++++ src/wait_for_port.sh | 81 +++++++++++++++++++++++++ 7 files changed, 596 insertions(+), 39 deletions(-) create mode 100755 src/find_duplicate_files.sh create mode 100755 src/find_large_files.sh create mode 100755 src/git_cleanup_branches.sh create mode 100755 src/retry.sh create mode 100755 src/ssl_cert_expiry.sh create mode 100755 src/wait_for_port.sh diff --git a/README.md b/README.md index f08891d..05d1358 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ [![GitHub stars](https://img.shields.io/github/stars/djeada/Bash-scripts)](https://github.com/djeada/Bash-scripts/stargazers) [![GitHub forks](https://img.shields.io/github/forks/djeada/Bash-scripts)](https://github.com/djeada/Bash-scripts/network) -[![GitHub license](https://img.shields.io/github/license/djeada/Bash-scripts)](https://github.com/djeada/Bash-scripts/blob/master/LICENSE) +[![GitHub license](https://img.shields.io/github/license/djeada/Bash-scripts)](https://github.com/djeada/Bash-Scripts/blob/master/LICENSE) [![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat)]( ) # Bash-scripts @@ -584,16 +584,25 @@ Additionally we advise to use <a href="https://github.com/koalaman/shellcheck">s shellcheck **/*.sh ``` +This repository runs the same checks in CI. To run them locally (requires `shellcheck` and `beautysh`): + +```bash +./hooks/_run_all.sh # formatting, linting, whitespace and line-ending checks +./tests/smoke_test.sh # syntax checks for every script plus behaviour tests +``` + +See [hooks/README.md](hooks/README.md) for details and for installing the checks as a git pre-commit hook. + ## Available scripts ### Intro | # | Description | Code | |---|---------------------------------------------------------------------|------------------------------------------------------------------------------------------| -| 1 | Prints "Hello, world!" to the console. | [hello_world.sh](https://github.com/djeada/Bash-scripts/blob/master/src/hello_world.sh) | -| 2 | Demonstrates the use of if statements to check conditions. | [conditionals.sh](https://github.com/djeada/Bash-scripts/blob/master/src/conditionals.sh) | -| 3 | Shows the use of a while loop to repeatedly execute code. | [while_loop.sh](https://github.com/djeada/Bash-scripts/blob/master/src/while_loop.sh) | -| 4 | Demonstrates the use of a for loop to iterate over elements. | [for_loop.sh](https://github.com/djeada/Bash-scripts/blob/master/src/for_loop.sh) | +| 1 | Prints "Hello, world!" to the console. | [hello_world.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/hello_world.sh) | +| 2 | Demonstrates the use of if statements to check conditions. | [conditionals.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/conditionals.sh) | +| 3 | Shows the use of a while loop to repeatedly execute code. | [while_loop.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/while_loop.sh) | +| 4 | Demonstrates the use of a for loop to iterate over elements. | [for_loop.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/for_loop.sh) | | 5 | Displays the digits of a given number, one digit per line. | [digits.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/digits.sh) | | 6 | Prints all of the numbers within a specified range, one number per line. | [numbers_in_interval.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/numbers_in_interval.sh) | | 7 | Prints a Christmas tree pattern to the console. | [christmas_tree.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/christmas_tree.sh) | @@ -605,12 +614,12 @@ shellcheck **/*.sh | # | Description | Code | |---|------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------| -| 1 | Performs basic arithmetic operations (addition, subtraction, multiplication, and division) on two numbers. | [arithmetic_operations.sh](https://github.com/djeada/Bash-scripts/blob/master/src/arithmetic_operations.sh) | -| 2 | Calculates the sum of all the arguments passed to it, treating them as numbers. | [sum_args.sh](https://github.com/djeada/Bash-scripts/blob/master/src/sum_args.sh) | -| 3 | Converts a number from the decimal (base 10) system to its equivalent in the binary (base 2) system. | [decimal_binary.sh](https://github.com/djeada/Bash-scripts/blob/master/src/decimal_binary.sh) | -| 4 | Calculates the factorial of a given integer. | [factorial.sh](https://github.com/djeada/Bash-scripts/blob/master/src/factorial.sh) | -| 5 | Determines whether a given number is a prime number or not. | [is_prime.sh](https://github.com/djeada/Bash-scripts/blob/master/src/is_prime.sh) | -| 6 | Calculates the square root of a given number. | [sqrt.sh](https://github.com/djeada/Bash-scripts/blob/master/src/sqrt.sh) | +| 1 | Performs basic arithmetic operations (addition, subtraction, multiplication, and division) on two numbers. | [arithmetic_operations.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/arithmetic_operations.sh) | +| 2 | Calculates the sum of all the arguments passed to it, treating them as numbers. | [sum_args.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/sum_args.sh) | +| 3 | Converts a number from the decimal (base 10) system to its equivalent in the binary (base 2) system. | [decimal_binary.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/decimal_binary.sh) | +| 4 | Calculates the factorial of a given integer. | [factorial.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/factorial.sh) | +| 5 | Determines whether a given number is a prime number or not. | [is_prime.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/is_prime.sh) | +| 6 | Calculates the square root of a given number. | [sqrt.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/sqrt.sh) | | 7 | Calculates the sum of all integers smaller than a given number. | [sum_smaller_numbers.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/sum_smaller_numbers.sh) | @@ -643,40 +652,42 @@ shellcheck **/*.sh | # | Description | Code | |----|------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------| -| 1 | Counts the number of files in a specified directory. | [count_files.sh](https://github.com/djeada/Bash-scripts/blob/master/src/count_files.sh) | -| 2 | Creates a new directory with a specified name. | [make_dir.sh](https://github.com/djeada/Bash-scripts/blob/master/src/make_dir.sh) | -| 3 | Counts the number of lines in a specified text file. | [line_counter.sh](https://github.com/djeada/Bash-scripts/blob/master/src/line_counter.sh) | -| 4 | Gets the middle line from a specified text file. | [middle_line.sh](https://github.com/djeada/Bash-scripts/blob/master/src/middle_line.sh) | +| 1 | Counts the number of files in a specified directory. | [count_files.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/count_files.sh) | +| 2 | Creates a new directory with a specified name. | [make_dir.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/make_dir.sh) | +| 3 | Counts the number of lines in a specified text file. | [line_counter.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/line_counter.sh) | +| 4 | Gets the middle line from a specified text file. | [middle_line.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/middle_line.sh) | | 5 | Removes duplicate lines from a specified file. | [remove_duplicate_lines.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/remove_duplicate_lines.sh) | | 6 | Replaces all forward slashes with backward slashes and vice versa in a specified file. | [switch_slashes.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/switch_slashes.sh) | | 7 | Adds specified text to the beginning of a specified file. | [prepend_text_to_file.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/prepend_text_to_file.sh) | | 8 | Removes all lines in a specified file that contain only whitespaces. | [remove_empty_lines.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/remove_empty_lines.sh) | | 9 | Renames all files in a specified directory with a particular extension to a new extension. | [rename_extension.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/rename_extension.sh) | -| 10 | Strips digits from every string found in a given file. | [strip_digits.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/strip_digits.sh) | +| 10 | Removes all digits from a given file in place. | [strip_digits.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/strip_digits.sh) | | 11 | Lists the most recently modified files in a given directory. | [recently_modified_files.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/recently_modified_files.sh) | -| 12 | Ensures that files end with exactly one trailing newline, with optional check-only mode. | [last_line_empty.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/last_line_empty.sh) | +| 12 | Ensures that files end with exactly one empty line, with optional check-only mode. | [last_line_empty.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/last_line_empty.sh) | | 13 | Removes repeated consecutive blank lines from files in a given directory. | [remove_consecutive_blank_lines.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/remove_consecutive_blank_lines.sh) | | 14 | Recursively replaces all occurrences of a string with another across files in a directory. | [replace_everywhere.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/replace_everywhere.sh) | | 15 | Swaps the contents of two specified files with optional backup and safety checks. | [swap_files.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/swap_files.sh) | | 16 | Extracts a specified range of pages from a PDF file using Ghostscript. | [pdf_page_extractor.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/pdf_page_extractor.sh) | +| 17 | Finds files with identical content (size pre-filter, then SHA-256) and reports how much space duplicates waste. | [find_duplicate_files.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/find_duplicate_files.sh) | +| 18 | Lists the largest files or directories under a path, with size threshold and single-filesystem options. | [find_large_files.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/find_large_files.sh) | ### System administration | # | Description | Code | |----|---------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------| -| 1 | Displays detailed system information, with flags for memory, disk, CPU, network, processes, OS, kernel, filesystems, and load. | [system_info.sh](https://github.com/djeada/Bash-scripts/blob/master/src/system_info.sh) | -| 2 | Detects the host platform, distribution, and version, with optional JSON and logging output. | [check_os.sh](https://github.com/djeada/Bash-scripts/blob/master/src/check_os.sh) | +| 1 | Displays detailed system information, with flags for memory, disk, CPU, network, processes, OS, kernel, filesystems, and load. | [system_info.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/system_info.sh) | +| 2 | Detects the host platform, distribution, and version, with optional JSON and logging output. | [check_os.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/check_os.sh) | | 3 | Verifies whether the script is running with root privileges, with optional logging. | [check_if_root.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/check_if_root.sh) | | 4 | Verifies that the `apt` package manager is available on the current system. | [check_apt_avail.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/check_apt_avail.sh) | | 5 | Retrieves the size of the machine's random access memory (RAM). | [ram_memory.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/ram_memory.sh) | -| 6 | Gets the current temperature of the machine's central processing unit (CPU). | [cpu_temp.sh](https://github.com/djeada/Bash-scripts/blob/master/src/cpu_temp.sh) | +| 6 | Gets the current temperature of the machine's central processing unit (CPU). | [cpu_temp.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/cpu_temp.sh) | | 7 | Retrieves the current overall CPU usage of the machine. | [cpu_usage.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/cpu_usage.sh) | | 8 | Monitors network bandwidth in real time, highlights heavy usage, and can log or emit JSON reports. | [network_bandwidth.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/network_bandwidth.sh) | | 9 | Monitors CPU, memory, and disk usage on a schedule, logging issues and optionally sending email alerts. | [server_health_monitor.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/server_health_monitor.sh) | | 10 | Blocks certain websites from being visited on the local machine by modifying the hosts file. | [web_block.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/web_block.sh) | -| 11 | Creates a backup of the system's files, compresses the backup, and encrypts the resulting archive for storage. | [backup.sh](https://github.com/djeada/Bash-scripts/blob/master/src/backup.sh) | -| 12 | Displays processes that are not being waited on by any parent process. Orphan processes are created when the parent process terminates. | [orphans.sh](https://github.com/djeada/Bash-scripts/blob/master/src/orphans.sh) | -| 13 | Displays processes that are in an undead state, also known as a "zombie" state. Zombie processes have completed execution but remain in the process table. | [zombies.sh](https://github.com/djeada/Bash-scripts/blob/master/src/zombies.sh) | +| 11 | Creates a backup of the system's files, compresses the backup, and encrypts the resulting archive for storage. | [backup.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/backup.sh) | +| 12 | Displays processes that are not being waited on by any parent process. Orphan processes are created when the parent process terminates. | [orphans.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/orphans.sh) | +| 13 | Displays processes that are in an undead state, also known as a "zombie" state. Zombie processes have completed execution but remain in the process table. | [zombies.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/zombies.sh) | | 14 | Displays disk usage information with advanced filtering, sorting, and multiple output formats. | [disk_usage.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/disk_usage.sh) | | 15 | Adjusts the system audio volume by percentage or predefined modes using PulseAudio. | [adjust_volume.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/adjust_volume.sh) | | 16 | Adds Signal Desktop's APT repository and installs the package on Debian-based systems. | [install_signal.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/install_signal.sh) | @@ -684,19 +695,21 @@ shellcheck **/*.sh | 18 | Installs the XFCE desktop on Linux Mint and sets it as the default LightDM session. | [xfce-default.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/xfce-default.sh) | | 19 | Completely removes Node.js and npm, then reinstalls them from the distribution package manager or nodejs.org. | [purge_and_reinstall_nodejs.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/purge_and_reinstall_nodejs.sh) | | 20 | Safely uninstalls user-installed Python pip packages while preserving essential system packages. | [purge_pip.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/purge_pip.sh) | +| 21 | Checks TLS certificate expiry dates for one or more hosts and exits non-zero when any expire within a warning window (cron friendly). | [ssl_cert_expiry.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/ssl_cert_expiry.sh) | +| 22 | Installs the OBS background-removal and blur-filter plugins on Ubuntu/Debian. | [setup_obs_background_blur.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/setup_obs_background_blur.sh) | ### Programming workflow | # | Description | Code | |---|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------| -| 1 | Removes the carriage return character (`\r`) from the given files, which may be present in files transferred between systems with different line ending conventions. | [remove_carriage_return.sh](https://github.com/djeada/Bash-scripts/blob/master/src/remove_carriage_return.sh) | -| 2 | Replaces all characters with diacritical marks in the given files with their non-diacritical counterparts. Diacritical marks are small signs added above or below letters to indicate different pronunciations or tones in some languages. | [remove_diacritics.sh](https://github.com/djeada/Bash-scripts/blob/master/src/remove_diacritics.sh) | -| 3 | Changes all spaces in file names to underscores and converts them to lowercase. This can be useful for making the file names more compatible with systems that do not support spaces in file names or for making the file names easier to read or type. | [correct_file_names.sh](https://github.com/djeada/Bash-scripts/blob/master/src/correct_file_names.sh) | +| 1 | Removes the carriage return character (`\r`) from the given files, which may be present in files transferred between systems with different line ending conventions. | [remove_carriage_return.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/remove_carriage_return.sh) | +| 2 | Replaces all characters with diacritical marks in the given files with their non-diacritical counterparts. Diacritical marks are small signs added above or below letters to indicate different pronunciations or tones in some languages. | [remove_diacritics.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/remove_diacritics.sh) | +| 3 | Changes all spaces in file names to underscores and converts them to lowercase. This can be useful for making the file names more compatible with systems that do not support spaces in file names or for making the file names easier to read or type. | [correct_file_names.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/correct_file_names.sh) | | 4 | Removes any trailing whitespace characters (spaces or tabs) from the end of every file in a given directory. Trailing whitespace can cause formatting issues or interfere with certain tools and processes. | [remove_trailing_whitespaces.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/remove_trailing_whitespaces.sh) | | 5 | Formats and beautifies every shell script found in the current repository. This can make the scripts easier to read and maintain by adding consistent indentation and whitespace. | [beautify_script.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/beautify_script.sh) | | 6 | Finds functions and classes in a Python project that are not being used or called anywhere in the code. This can help identify and remove unnecessary code, which can improve the project's performance and maintainability. | [dead_code.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/dead_code.sh) | | 7 | Removes C and C++ style comments from source files using a finite state machine approach. | [strip_cpp_comments.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/strip_cpp_comments.sh) | -| 8 | Removes comments and docstrings from Python files with options for backup, dry-run, and interactive mode. | [strip_python_comments.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/strip_python_comments.sh) | +| 8 | Removes comments from Python files (strings and docstrings are kept) with options for backup, dry-run, and interactive mode. | [strip_python_comments.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/strip_python_comments.sh) | | 9 | Lists all repositories for a specified GitHub user, with pagination and private repository support via API token. | [fetch_github_repos_names.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/fetch_github_repos_names.sh) | | 10 | Automates Python package building, testing, and releasing to PyPI or TestPyPI. | [release_package.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/release_package.sh) | @@ -704,17 +717,18 @@ shellcheck **/*.sh | # | Description | Code | |---|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------| -| 1 | Resets the local repository to match the state of the remote repository, discarding any local commits and changes. This can be useful for starting over or synchronizing with the latest version on the remote repository. | [reset_to_origin.sh](https://github.com/djeada/Bash-scripts/blob/master/src/reset_to_origin.sh) | -| 2 | Deletes the specified branch both locally and on the remote repository. This can be useful for removing branches that are no longer needed or for consolidating multiple branches into a single branch. | [remove_branch.sh](https://github.com/djeada/Bash-scripts/blob/master/src/remove_branch.sh) | -| 3 | Counts the total number of lines of code in a git repository, including lines in all branches and commits. This can be useful for tracking the size and complexity of a project over time. | [count_lines_of_code.sh](https://github.com/djeada/Bash-scripts/blob/master/src/count_lines_of_code.sh) | +| 1 | Resets the local repository to match the state of the remote repository, discarding any local commits and changes. This can be useful for starting over or synchronizing with the latest version on the remote repository. | [reset_to_origin.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/reset_to_origin.sh) | +| 2 | Deletes the specified branch both locally and on the remote repository. This can be useful for removing branches that are no longer needed or for consolidating multiple branches into a single branch. | [remove_branch.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/remove_branch.sh) | +| 3 | Counts the total number of lines in the files tracked by a git repository (current working tree, binary files skipped). | [count_lines_of_code.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/count_lines_of_code.sh) | | 4 | Combines multiple commits into a single commit. This can be useful for simplifying a commit history or for cleaning up a series of small, incremental commits that were made in error. | [squash_n_last_commits.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/squash_n_last_commits.sh) | | 5 | Removes the `n` last commits from the repository. This can be useful for undoing mistakes or for removing sensitive information that was accidentally committed. | [remove_n_last_commits.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/remove_n_last_commits.sh) | | 6 | Changes the date of the last commit in the repository. This can be useful for altering the commit history for cosmetic purposes. | [change_commit_date.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/change_commit_date.sh) | | 7 | Discovers GitHub repositories into an editable JSON or CSV manifest, then backs up the enabled repositories using mirror, clone, archive, or sparse modes with resumable state for cron jobs. | [download_all_github_repos.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/download_all_github_repos.sh) | | 8 | Squashes all commits on a specified Git branch into a single commit. | [squash_branch.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/squash_branch.sh) | -| 9 | Counts the total lines changed by a specific author in a Git repository. | [contributions_by_git_author.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/contributions_by_git_author.sh) | +| 9 | Counts the commits made by each author in a Git repository, or by a single author when a name is given. | [contributions_by_git_author.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/contributions_by_git_author.sh) | GitHub repository backups use a two-stage workflow: +| 10 | Deletes local branches already merged into the base branch, and optionally branches whose upstream is gone, with dry-run and confirmation. | [git_cleanup_branches.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/git_cleanup_branches.sh) | ```bash ./src/download_all_github_repos.sh discover --user alice --output repos.json @@ -727,22 +741,24 @@ Edit the generated manifest to disable repositories or choose per-repository mod | # | Description | Code | |---|-------------|------| -| 1 | Displays public and private IP details, optional geolocation data, and interface-specific addresses. | [ip_info.sh](https://github.com/djeada/Bash-scripts/blob/master/src/ip_info.sh) | -| 2 | Deletes all files in the trash bin. | [empty_trash.sh](https://github.com/djeada/Bash-scripts/blob/master/src/empty_trash.sh) | +| 1 | Displays public and private IP details, optional geolocation data, and interface-specific addresses. | [ip_info.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/ip_info.sh) | +| 2 | Deletes all files in the trash bin. | [empty_trash.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/empty_trash.sh) | | 3 | Extracts compressed archive files (tar, tar.gz, tar.bz2, zip, rar, 7z, bz2, gz, and more) to a specified output directory. | [extract.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/extract.sh) | | 4 | Determines which programs are currently using a specified port number on the local system. | [program_on_port.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/program_on_port.sh) | -| 5 | Converts month names to numbers and vice versa in a string. For example, "January" to "1" and "1" to "January". | [month_to_number.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/month_to_number.sh) | -| 6 | Creates command aliases for all the scripts in a specified directory, allowing them to be run by simply typing their names. | [alias_all_the_scripts.sh](https://github.com/djeada/Bash-scripts/blob/master/src/alias_all_the_scripts.sh) | +| 5 | Converts month names (full or three-letter) to numbers and vice versa. For example, "January" to "1" and "1" to "jan". | [month_to_number.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/month_to_number.sh) | +| 6 | Creates command aliases for all the scripts in a specified directory, allowing them to be run by simply typing their names. | [alias_all_the_scripts.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/alias_all_the_scripts.sh) | | 7 | Generates a random integer within a given range. The range can be specified as arguments to the script. | [random_int.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/random_int.sh) | | 8 | Generates a random password of the specified length, using a combination of letters, numbers, and special characters. | [random_password.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/random_password.sh) | -| 9 | Measures the time it takes to run a program with the specified input parameters. Output the elapsed time in seconds. | [time_execution.sh](https://github.com/djeada/Bash-scripts/blob/master/src/time_execution.sh) | -| 10 | Downloads the audio from a YouTube video or playlist in MP3 format. Specify the video or playlist URL and the destination directory for the downloaded files. | [youtube_to_mp3.sh](https://github.com/djeada/Bash-scripts/blob/master/src/youtube_to_mp3.sh) | -| 11 | Clears the local caches in the user's cache directory (e.g. `~/.cache`) that are older than a specified number of days. | [clear_cache.sh](https://github.com/djeada/Bash-scripts/blob/master/src/clear_cache.sh) | +| 9 | Measures the time it takes to run a program with the specified input parameters. Output the elapsed time in seconds. | [time_execution.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/time_execution.sh) | +| 10 | Downloads the audio from a YouTube video or playlist in MP3 format. Specify the video or playlist URL and an optional output directory. | [youtube_to_mp3.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/youtube_to_mp3.sh) | +| 11 | Clears the local caches in the user's cache directory (e.g. `~/.cache`) that are older than a specified number of days. | [clear_cache.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/clear_cache.sh) | | 12 | Backs up Firefox profiles to a USB drive and restores them later with profile discovery and safety checks. | [firefox_profile_usb.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/firefox_profile_usb.sh) | -| 13 | Resizes all JPG files in the current directory to a specified dimension (A4). | [resize_to_a4.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/resize_to_a4.sh) | +| 13 | Resizes the images in a directory to A4 (2480x3508 at 300 DPI) or a custom size, writing copies to `./resized` unless `--overwrite` is given. | [resize_to_a4.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/resize_to_a4.sh) | | 14 | Fetches and displays current weather conditions for a specified city using the wttr.in service. | [display_weather.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/display_weather.sh) | | 15 | Converts Markdown files to PDF format with automatic page breaks and concatenation. | [generate_books.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/generate_books.sh) | | 16 | Displays an elapsed time counter in HH:MM:SS format, updated every second. | [timer.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/timer.sh) | +| 17 | Retries a command with exponential backoff until it succeeds or the attempt limit is reached. | [retry.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/retry.sh) | +| 18 | Waits until a TCP port accepts connections (no netcat needed), then optionally runs a command. Handy in CI and container start-up. | [wait_for_port.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/wait_for_port.sh) | ### Multimedia @@ -755,6 +771,8 @@ Edit the generated manifest to disable repositories or choose per-repository mod | 5 | Extends video height by a scale factor, adding black padding on the top and bottom. | [extend_video_height.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/extend_video_height.sh) | | 6 | Stretches a video vertically by a scale factor with proportional padding. | [stretch_mp4_vertically.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/stretch_mp4_vertically.sh) | | 7 | Encodes videos to 9:16 vertical shorts format with cropping, padding, and speed adjustment options. | [make_short.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/make_short.sh) | +| 8 | Sets up a local Meta AudioCraft environment and generates music (MusicGen) or sound effects (AudioGen) from a text prompt on an NVIDIA GPU. | [run_audiocraft.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/run_audiocraft.sh) | +| 9 | Sets up a local Diffusers environment and generates a short video from a text prompt (and optional reference image) with LTX-Video on a ~8GB GPU. | [run_videogen.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/run_videogen.sh) | ## References diff --git a/src/find_duplicate_files.sh b/src/find_duplicate_files.sh new file mode 100755 index 0000000..e9e01dd --- /dev/null +++ b/src/find_duplicate_files.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash + +# Script Name: find_duplicate_files.sh +# Description: Finds files with identical content. Files are first grouped by +# size and only same-sized candidates are hashed, so large trees +# are scanned quickly. Each group of duplicates is printed as a +# block separated by a blank line, together with wasted space. +# Usage: find_duplicate_files.sh [-m min_size] [-a] [directory...] +# Options: +# -m min_size Ignore files smaller than this many bytes (default: 1). +# -a Include hidden files and directories. +# -h Show this help message. +# Examples: +# find_duplicate_files.sh ~/Pictures +# find_duplicate_files.sh -m 1048576 ~/Downloads ~/Documents + +set -euo pipefail + +usage() { + sed -n '3,/^$/p' "$0" | sed -e '/^$/d' -e 's/^# \{0,1\}//' +} + +die() { + echo "Error: $*" >&2 + exit 2 +} + +human_size() { + numfmt --to=iec --suffix=B "$1" 2>/dev/null || echo "${1}B" +} + +main() { + local min_size=1 hidden=0 + local opt + + while getopts ":m:ah" opt; do + case "$opt" in + m) min_size="$OPTARG" ;; + a) hidden=1 ;; + h) usage; exit 0 ;; + :) die "option -$OPTARG requires an argument" ;; + *) die "unknown option -$OPTARG" ;; + esac + done + shift $((OPTIND - 1)) + + [[ $min_size =~ ^[0-9]+$ ]] || die "min_size must be a non-negative integer" + command -v sha256sum >/dev/null 2>&1 || die "sha256sum (coreutils) is required" + [[ $# -gt 0 ]] || set -- . + local dir + for dir in "$@"; do + [[ -d $dir ]] || die "'$dir' is not a directory" + done + + local -a prune=() size_filter=() + ((hidden)) || prune=(-name '.?*' -prune -o) + ((min_size > 0)) && size_filter=(-size +"$((min_size - 1))"c) + + # Pass 1: size<TAB>path for every regular file, keep only sizes seen more than once. + # Pass 2: hash those candidates and group by hash. + local groups wasted=0 count=0 + groups=$( + find "$@" -mindepth 1 "${prune[@]}" -type f "${size_filter[@]}" -printf '%s\t%p\0' | + awk 'BEGIN { RS = "\0"; ORS = "\0" } + { size = substr($0, 1, index($0, "\t") - 1); n[size]++; line[NR] = $0; sz[NR] = size } + END { for (i = 1; i <= NR; i++) if (n[sz[i]] > 1) print substr(line[i], index(line[i], "\t") + 1) }' | + xargs -0 -r sha256sum | + sort | + awk '{ hash = substr($0, 1, 64); path = substr($0, 67) + if (hash == prev) { if (!open) { print ""; print prevpath; open = 1 } print path } + else open = 0 + prev = hash; prevpath = path }' + ) + + if [[ -z $groups ]]; then + echo "No duplicate files found." + return 0 + fi + + printf '%s\n' "${groups#$'\n'}" + + # Summarise: every file in a group beyond the first is wasted space. + local first=1 size line + while IFS= read -r line; do + if [[ -z $line ]]; then + first=1 + continue + fi + if ((first)); then + first=0 + count=$((count + 1)) + continue + fi + size=$(stat -c %s -- "$line") + wasted=$((wasted + size)) + done <<<"$groups" + + echo + echo "Found $count group(s) of duplicates; $(human_size "$wasted") could be reclaimed." +} + +main "$@" + diff --git a/src/find_large_files.sh b/src/find_large_files.sh new file mode 100755 index 0000000..3cbfe9c --- /dev/null +++ b/src/find_large_files.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash + +# Script Name: find_large_files.sh +# Description: Lists the largest files (or directories) under a path, sorted +# by size, to quickly find what is eating disk space. +# Usage: find_large_files.sh [-n count] [-s min_size] [-d] [-x] [path] +# Options: +# -n count Number of entries to show (default: 20). +# -s min_size Only show files at least this big, using find's size syntax +# such as 100M or 2G (default: no limit). +# -d Rank directories by total size instead of individual files. +# -x Stay on the filesystem of the given path. +# -h Show this help message. +# Examples: +# find_large_files.sh ~ +# find_large_files.sh -n 10 -s 500M /var +# find_large_files.sh -d -x / + +set -euo pipefail + +usage() { + sed -n '3,/^$/p' "$0" | sed -e '/^$/d' -e 's/^# \{0,1\}//' +} + +die() { + echo "Error: $*" >&2 + exit 2 +} + +main() { + local count=20 min_size="" dirs=0 one_fs=0 + local opt + + while getopts ":n:s:dxh" opt; do + case "$opt" in + n) count="$OPTARG" ;; + s) min_size="$OPTARG" ;; + d) dirs=1 ;; + x) one_fs=1 ;; + h) usage; exit 0 ;; + :) die "option -$OPTARG requires an argument" ;; + *) die "unknown option -$OPTARG" ;; + esac + done + shift $((OPTIND - 1)) + + local path="${1:-.}" + [[ -d $path ]] || die "'$path' is not a directory" + [[ $count =~ ^[1-9][0-9]*$ ]] || die "count must be a positive integer" + [[ -z $min_size || $min_size =~ ^[0-9]+[kMG]?$ ]] || die "min_size must look like 500k, 100M or 2G" + + if ((dirs)); then + local -a du_opts=(--block-size=1 --max-depth=1) + ((one_fs)) && du_opts+=(-x) + du "${du_opts[@]}" -- "$path" 2>/dev/null | + sort -rn | + head -n "$((count + 1))" | + numfmt --to=iec --suffix=B --padding=8 --field=1 --delimiter=$'\t' + return 0 + fi + + local -a find_opts=() + ((one_fs)) && find_opts+=(-xdev) + [[ -n $min_size ]] && find_opts+=(-size "+$min_size") + + find "$path" "${find_opts[@]}" -type f -printf '%s\t%p\n' 2>/dev/null | + sort -rn -t $'\t' -k1,1 | + head -n "$count" | + numfmt --to=iec --suffix=B --padding=8 --field=1 --delimiter=$'\t' +} + +main "$@" + diff --git a/src/git_cleanup_branches.sh b/src/git_cleanup_branches.sh new file mode 100755 index 0000000..bc2179b --- /dev/null +++ b/src/git_cleanup_branches.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash + +# Script Name: git_cleanup_branches.sh +# Description: Deletes local branches that are already merged into the base +# branch, and optionally branches whose upstream was deleted on the +# remote ("gone"). The current branch and the base branch are never +# touched. Shows what will be deleted and asks before doing it. +# Usage: git_cleanup_branches.sh [-b base] [-g] [-n] [-y] +# Options: +# -b base Branch to compare against (default: origin's HEAD, else main/master). +# -g Also delete branches whose upstream is gone (requires a fetch, done +# automatically with --prune). These are deleted with -D because a +# squash-merged branch is not seen as merged by git. +# -n Dry run: only list the branches that would be deleted. +# -y Do not ask for confirmation. +# -h Show this help message. +# Examples: +# git_cleanup_branches.sh -n +# git_cleanup_branches.sh -g -b develop + +set -euo pipefail + +usage() { + sed -n '3,/^$/p' "$0" | sed -e '/^$/d' -e 's/^# \{0,1\}//' +} + +die() { + echo "Error: $*" >&2 + exit 1 +} + +detect_base() { + local ref + if ref=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null); then + echo "${ref#origin/}" + return + fi + for ref in main master; do + if git show-ref --verify --quiet "refs/heads/$ref"; then + echo "$ref" + return + fi + done + die "could not detect the base branch, pass it with -b" +} + +main() { + local base="" gone=0 dry_run=0 assume_yes=0 + local opt + + while getopts ":b:gnyh" opt; do + case "$opt" in + b) base="$OPTARG" ;; + g) gone=1 ;; + n) dry_run=1 ;; + y) assume_yes=1 ;; + h) usage; exit 0 ;; + :) die "option -$OPTARG requires an argument" ;; + *) die "unknown option -$OPTARG" ;; + esac + done + + git rev-parse --is-inside-work-tree >/dev/null 2>&1 || die "not inside a git repository" + [[ -n $base ]] || base=$(detect_base) + git rev-parse --verify --quiet "$base^{commit}" >/dev/null || die "base branch '$base' does not exist" + + local current + current=$(git branch --show-current) + + if ((gone)); then + git fetch --all --prune --quiet || echo "Warning: fetch failed, using cached remote state." >&2 + fi + + local -a merged=() orphaned=() + local branch track + while IFS= read -r branch; do + [[ $branch == "$current" || $branch == "$base" ]] && continue + merged+=("$branch") + done < <(git for-each-ref --format='%(refname:short)' --merged "$base" refs/heads/) + + if ((gone)); then + while IFS=$'\t' read -r branch track; do + [[ $track == "[gone]" ]] || continue + [[ $branch == "$current" || $branch == "$base" ]] && continue + [[ " ${merged[*]} " == *" $branch "* ]] && continue + orphaned+=("$branch") + done < <(git for-each-ref --format=$'%(refname:short)\t%(upstream:track)' refs/heads/) + fi + + if ((${#merged[@]} + ${#orphaned[@]} == 0)); then + echo "Nothing to clean up (base: $base)." + return 0 + fi + + ((${#merged[@]})) && printf 'Merged into %s:\n' "$base" && printf ' %s\n' "${merged[@]}" + ((${#orphaned[@]})) && printf 'Upstream gone:\n' && printf ' %s\n' "${orphaned[@]}" + + if ((dry_run)); then + echo "Dry run: nothing deleted." + return 0 + fi + + if ((!assume_yes)); then + local answer + read -r -p "Delete these branches? [y/N] " answer + [[ $answer =~ ^[Yy]([Ee][Ss])?$ ]] || { echo "Aborted."; return 1; } + fi + + ((${#merged[@]})) && git branch -d -- "${merged[@]}" + ((${#orphaned[@]})) && git branch -D -- "${orphaned[@]}" + return 0 +} + +main "$@" + diff --git a/src/retry.sh b/src/retry.sh new file mode 100755 index 0000000..c2abe99 --- /dev/null +++ b/src/retry.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash + +# Script Name: retry.sh +# Description: Runs a command and retries it with exponential backoff until it +# succeeds or the attempt limit is reached. Exits with the last +# exit code of the command. +# Usage: retry.sh [-n attempts] [-d delay] [-b factor] [-m max_delay] [-q] -- command [args...] +# Options: +# -n attempts Maximum number of attempts (default: 5). +# -d delay Initial delay between attempts in seconds (default: 1). +# -b factor Multiply the delay by this integer after each failure (default: 2). +# -m max_delay Upper bound for the delay in seconds (default: 60). +# -q Quiet: do not print retry messages. +# -h Show this help message. +# Examples: +# retry.sh -- curl -fsS https://example.com/health +# retry.sh -n 10 -d 2 -b 1 -- ping -c1 192.168.1.1 + +set -uo pipefail + +usage() { + sed -n '3,/^$/p' "$0" | sed -e '/^$/d' -e 's/^# \{0,1\}//' +} + +die() { + echo "Error: $*" >&2 + exit 2 +} + +is_uint() { + [[ $1 =~ ^[0-9]+$ ]] +} + +main() { + local attempts=5 delay=1 factor=2 max_delay=60 quiet=0 + local opt + + while getopts ":n:d:b:m:qh" opt; do + case "$opt" in + n) attempts="$OPTARG" ;; + d) delay="$OPTARG" ;; + b) factor="$OPTARG" ;; + m) max_delay="$OPTARG" ;; + q) quiet=1 ;; + h) usage; exit 0 ;; + :) die "option -$OPTARG requires an argument" ;; + *) die "unknown option -$OPTARG" ;; + esac + done + shift $((OPTIND - 1)) + [[ ${1:-} == "--" ]] && shift + + [[ $# -gt 0 ]] || { usage >&2; exit 2; } + for value in "$attempts" "$delay" "$factor" "$max_delay"; do + is_uint "$value" || die "numeric options must be non-negative integers (got '$value')" + done + ((attempts >= 1)) || die "attempts must be at least 1" + + local attempt status + for ((attempt = 1; ; attempt++)); do + "$@" + status=$? + ((status == 0)) && return 0 + + if ((attempt >= attempts)); then + ((quiet)) || echo "retry: '$1' failed after $attempt attempt(s) (exit $status)" >&2 + return "$status" + fi + + ((quiet)) || echo "retry: attempt $attempt/$attempts failed (exit $status), retrying in ${delay}s..." >&2 + sleep "$delay" + delay=$((delay * factor)) + ((delay > max_delay)) && delay=$max_delay + done +} + +main "$@" + diff --git a/src/ssl_cert_expiry.sh b/src/ssl_cert_expiry.sh new file mode 100755 index 0000000..b676532 --- /dev/null +++ b/src/ssl_cert_expiry.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash + +# Script Name: ssl_cert_expiry.sh +# Description: Checks when the TLS certificates of one or more hosts expire and +# warns about the ones expiring soon. Suitable for cron: exits 1 if +# any certificate expires within the warning window or cannot be read. +# Usage: ssl_cert_expiry.sh [-w days] [-t timeout] host[:port]... +# Options: +# -w days Warn when a certificate expires within this many days (default: 30). +# -t timeout Connection timeout in seconds (default: 10). +# -h Show this help message. +# Examples: +# ssl_cert_expiry.sh github.com example.org +# ssl_cert_expiry.sh -w 14 mail.example.com:465 + +set -uo pipefail + +usage() { + sed -n '3,/^$/p' "$0" | sed -e '/^$/d' -e 's/^# \{0,1\}//' +} + +die() { + echo "Error: $*" >&2 + exit 2 +} + +# Prints the certificate's notAfter date, or nothing on failure. +fetch_expiry() { + local host="$1" port="$2" timeout_s="$3" + timeout "$timeout_s" openssl s_client -connect "$host:$port" -servername "$host" </dev/null 2>/dev/null | + openssl x509 -noout -enddate 2>/dev/null | + cut -d= -f2 +} + +main() { + local warn_days=30 timeout_s=10 + local opt + + while getopts ":w:t:h" opt; do + case "$opt" in + w) warn_days="$OPTARG" ;; + t) timeout_s="$OPTARG" ;; + h) usage; exit 0 ;; + :) die "option -$OPTARG requires an argument" ;; + *) die "unknown option -$OPTARG" ;; + esac + done + shift $((OPTIND - 1)) + + [[ $# -gt 0 ]] || { usage >&2; exit 2; } + [[ $warn_days =~ ^[0-9]+$ ]] || die "days must be a non-negative integer" + [[ $timeout_s =~ ^[1-9][0-9]*$ ]] || die "timeout must be a positive integer" + command -v openssl >/dev/null 2>&1 || die "openssl is required" + command -v timeout >/dev/null 2>&1 || die "'timeout' (coreutils) is required" + + local status=0 now target host port expiry expiry_ts days state + now=$(date +%s) + printf '%-40s %-26s %6s %s\n' "HOST" "EXPIRES" "DAYS" "STATUS" + + for target in "$@"; do + host="${target%%:*}" + port=443 + [[ $target == *:* ]] && port="${target##*:}" + + expiry=$(fetch_expiry "$host" "$port" "$timeout_s") + if [[ -z $expiry ]] || ! expiry_ts=$(date -d "$expiry" +%s 2>/dev/null); then + printf '%-40s %-26s %6s %s\n' "$host:$port" "-" "-" "ERROR (no certificate)" + status=1 + continue + fi + + days=$(((expiry_ts - now) / 86400)) + if ((expiry_ts <= now)); then + state="EXPIRED" + status=1 + elif ((days < warn_days)); then + state="WARNING" + status=1 + else + state="OK" + fi + printf '%-40s %-26s %6s %s\n' "$host:$port" "$(date -d "@$expiry_ts" '+%Y-%m-%d %H:%M %Z')" "$days" "$state" + done + + exit "$status" +} + +main "$@" + diff --git a/src/wait_for_port.sh b/src/wait_for_port.sh new file mode 100755 index 0000000..3f1b7fb --- /dev/null +++ b/src/wait_for_port.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash + +# Script Name: wait_for_port.sh +# Description: Waits until a TCP port accepts connections (e.g. a database or +# web server starting up in CI or docker-compose), then optionally +# runs a command. Uses bash's /dev/tcp, so no netcat is required. +# Usage: wait_for_port.sh [-t timeout] [-i interval] [-q] host:port [-- command [args...]] +# Options: +# -t timeout Give up after this many seconds, 0 waits forever (default: 30). +# -i interval Seconds to sleep between connection attempts (default: 1). +# -q Quiet: only report failures. +# -h Show this help message. +# Examples: +# wait_for_port.sh localhost:5432 +# wait_for_port.sh -t 60 db:3306 -- ./run_migrations.sh + +set -uo pipefail + +usage() { + sed -n '3,/^$/p' "$0" | sed -e '/^$/d' -e 's/^# \{0,1\}//' +} + +die() { + echo "Error: $*" >&2 + exit 2 +} + +port_open() { + local host="$1" port="$2" + # shellcheck disable=SC2016 # expanded by the inner shell + timeout 2 bash -c ': </dev/tcp/"$1"/"$2"' _ "$host" "$port" 2>/dev/null +} + +main() { + local timeout_s=30 interval=1 quiet=0 + local opt + + while getopts ":t:i:qh" opt; do + case "$opt" in + t) timeout_s="$OPTARG" ;; + i) interval="$OPTARG" ;; + q) quiet=1 ;; + h) usage; exit 0 ;; + :) die "option -$OPTARG requires an argument" ;; + *) die "unknown option -$OPTARG" ;; + esac + done + shift $((OPTIND - 1)) + + [[ $# -ge 1 ]] || { usage >&2; exit 2; } + [[ $timeout_s =~ ^[0-9]+$ ]] || die "timeout must be a non-negative integer" + [[ $interval =~ ^[1-9][0-9]*$ ]] || die "interval must be a positive integer" + command -v timeout >/dev/null 2>&1 || die "'timeout' (coreutils) is required" + + local target="$1" + shift + local host="${target%:*}" port="${target##*:}" + [[ $target == *:* && -n $host ]] || die "target must be in host:port form (got '$target')" + if [[ ! $port =~ ^[0-9]+$ ]] || ((port < 1 || port > 65535)); then + die "invalid port '$port'" + fi + + ((quiet)) || echo "Waiting for $host:$port (timeout: ${timeout_s}s)..." + local start=$SECONDS + until port_open "$host" "$port"; do + if ((timeout_s > 0 && SECONDS - start >= timeout_s)); then + echo "Timed out after ${timeout_s}s waiting for $host:$port" >&2 + exit 1 + fi + sleep "$interval" + done + ((quiet)) || echo "$host:$port is available after $((SECONDS - start))s." + + [[ ${1:-} == "--" ]] && shift + if [[ $# -gt 0 ]]; then + exec "$@" + fi +} + +main "$@" + From 18bb5422c07cb543898363cc1c3c041d06fb0fe8 Mon Sep 17 00:00:00 2001 From: Adam Djellouli <addjellouli1@gmail.com> Date: Sat, 3 Oct 2026 23:47:57 +0200 Subject: [PATCH 5/5] download_all_github_repos: incremental backups and --auth modes Incremental backups: - keep backups in DEST/repos and DEST/archives instead of a fresh runs/<timestamp>/ copy on every run (runs/ now holds only the summary and checksums) - write DEST/backup-manifest.json with each repository's HEAD commit, remote refs fingerprint, settings fingerprint, output path, size, archive SHA-256 and timestamps; paths are stored relative to DEST so the backup directory can be moved - check remote refs with git ls-remote before each backup and skip repositories whose refs and settings are unchanged and whose backup still exists - download archives at the exact commit and name them owner__repo@<sha>.tar.gz; add --keep-archives N to prune old ones - add --force to back up everything and --backup-manifest FILE to relocate the record; summaries count unchanged repositories Authentication: - add --auth auto|token|gh|public for discover and backup; gh uses the GitHub CLI login via "gh auth token" - disable the user's git credential helpers for HTTPS so public mode is really anonymous and token/gh modes use exactly the chosen token - send the token to git as basic auth (x-access-token), which GitHub's git-over-HTTPS endpoint accepts --- README.md | 16 +- src/download_all_github_repos.sh | 300 ++++++++++++++++++++++++++++--- 2 files changed, 291 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 05d1358..72ce939 100644 --- a/README.md +++ b/README.md @@ -723,20 +723,30 @@ See [hooks/README.md](hooks/README.md) for details and for installing the checks | 4 | Combines multiple commits into a single commit. This can be useful for simplifying a commit history or for cleaning up a series of small, incremental commits that were made in error. | [squash_n_last_commits.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/squash_n_last_commits.sh) | | 5 | Removes the `n` last commits from the repository. This can be useful for undoing mistakes or for removing sensitive information that was accidentally committed. | [remove_n_last_commits.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/remove_n_last_commits.sh) | | 6 | Changes the date of the last commit in the repository. This can be useful for altering the commit history for cosmetic purposes. | [change_commit_date.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/change_commit_date.sh) | -| 7 | Discovers GitHub repositories into an editable JSON or CSV manifest, then backs up the enabled repositories using mirror, clone, archive, or sparse modes with resumable state for cron jobs. | [download_all_github_repos.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/download_all_github_repos.sh) | +| 7 | Discovers GitHub repositories into an editable JSON or CSV manifest, then incrementally backs up the enabled repositories (mirror, clone, archive, or sparse), skipping ones whose commits have not changed. Authenticates with a token, the `gh` CLI, or anonymously. | [download_all_github_repos.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/download_all_github_repos.sh) | | 8 | Squashes all commits on a specified Git branch into a single commit. | [squash_branch.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/squash_branch.sh) | | 9 | Counts the commits made by each author in a Git repository, or by a single author when a name is given. | [contributions_by_git_author.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/contributions_by_git_author.sh) | GitHub repository backups use a two-stage workflow: -| 10 | Deletes local branches already merged into the base branch, and optionally branches whose upstream is gone, with dry-run and confirmation. | [git_cleanup_branches.sh](https://github.com/djeada/Bash-Scripts/blob/master/src/git_cleanup_branches.sh) | ```bash ./src/download_all_github_repos.sh discover --user alice --output repos.json -./src/download_all_github_repos.sh backup --manifest repos.json --dest ~/github-backups --resume +./src/download_all_github_repos.sh backup --manifest repos.json --dest ~/github-backups ``` Edit the generated manifest to disable repositories or choose per-repository modes. Use `mode: "mirror"` for full Git backups, `mode: "archive"` for HTTPS snapshots without Git history, and `mode: "sparse"` with `paths` for selected subdirectories. +Choose how to authenticate with `--auth` (both `discover` and `backup`): + +| `--auth` | Credentials used | +|----------|------------------| +| `public` | None. Only public repositories, lower API rate limit. | +| `token` | `--token TOKEN` or the `GITHUB_TOKEN` environment variable. | +| `gh` | The GitHub CLI login (`gh auth login`), so no token has to be handled by hand. | +| `auto` | Default: a token if one is given, otherwise `gh` if logged in, otherwise public. | + +Backups are incremental. The destination holds the repositories in `repos/` and `archives/`, plus a `backup-manifest.json` that records, for every repository, the backed-up HEAD commit, a fingerprint of its remote refs, the output path, size, and timestamps. On the next run the remote refs are checked with `git ls-remote` (cheap, no API quota), and repositories that have not changed are skipped. Archives are named after the commit they contain (`owner__repo@<sha>.tar.gz`); `--keep-archives N` keeps only the newest N per repository. Use `--force` to back up everything regardless, and `--resume` to continue an interrupted run. + ### Utility | # | Description | Code | diff --git a/src/download_all_github_repos.sh b/src/download_all_github_repos.sh index c335835..532fd1f 100755 --- a/src/download_all_github_repos.sh +++ b/src/download_all_github_repos.sh @@ -6,6 +6,9 @@ # JSON/CSV manifests, HTTPS or SSH clones, mirror/clone/archive/ # sparse backup modes, resumable runs, retries, rate-limit waits, # cron-friendly logging, and checksums for downloaded archives. +# Backups are incremental: DEST/backup-manifest.json records the +# last backed-up commit of every repository, and repositories whose +# remote refs have not changed since are skipped. # Usage: # ./download_all_github_repos.sh discover [OPTIONS] # ./download_all_github_repos.sh backup [OPTIONS] @@ -16,6 +19,7 @@ # ./download_all_github_repos.sh discover --user alice --output repos.json # ./download_all_github_repos.sh discover --org my-org --token "$GITHUB_TOKEN" --output repos.csv --format csv # ./download_all_github_repos.sh discover --authenticated-user --token "$GITHUB_TOKEN" --output repos.json +# ./download_all_github_repos.sh discover --authenticated-user --auth gh --output repos.json # # Backup examples: # ./download_all_github_repos.sh backup --manifest repos.json --dest ~/github-backups @@ -36,6 +40,7 @@ GITHUB_USER="" GITHUB_ORG="" AUTHENTICATED_USER=false GITHUB_TOKEN="${GITHUB_TOKEN:-}" +AUTH_MODE="auto" VISIBILITY="all" MANIFEST="" @@ -45,6 +50,7 @@ OUTPUT_FORMAT="json" DEST_DIR="" RUN_DIR="" STATE_FILE="" +BACKUP_MANIFEST="" LOCK_FILE="" CONFIG_FILE="" @@ -60,6 +66,8 @@ NON_INTERACTIVE=false CHECKSUM=true VERIFY_AFTER_BACKUP=false PARALLEL_JOBS="" +FORCE=false +KEEP_ARCHIVES=0"" RATE_LIMIT_MODE="wait" SLEEP_BETWEEN_REPOS=0 @@ -97,6 +105,7 @@ Discovery examples: download_all_github_repos.sh discover --user alice --output repos.json download_all_github_repos.sh discover --org my-org --token "$GITHUB_TOKEN" --output repos.csv --format csv download_all_github_repos.sh discover --authenticated-user --token "$GITHUB_TOKEN" --output repos.json + download_all_github_repos.sh discover --authenticated-user --auth gh --output repos.json Backup examples: download_all_github_repos.sh backup --manifest repos.json --dest ~/github-backups @@ -113,11 +122,18 @@ print_discover_usage() { Usage: download_all_github_repos.sh discover [OPTIONS] Source options: - --user USER Discover repositories for a GitHub user. With --token, + --user USER Discover repositories for a GitHub user. When + authenticated (token or gh), include private repositories visible to that token. --org ORG Discover repositories for a GitHub organization. --authenticated-user Discover repositories visible to the token owner. --token TOKEN GitHub token. Defaults to GITHUB_TOKEN. + --auth auto|token|gh|public How to authenticate with GitHub: + token - use --token / GITHUB_TOKEN (required) + gh - use the GitHub CLI login (gh auth token) + public - no authentication, public repos only + auto - token if given, else gh if logged in, + else public (default) Manifest options: --output FILE Manifest path to write. Required. @@ -159,7 +175,19 @@ Backup behavior: Override per-repository manifest mode. --protocol https|ssh Clone protocol for git modes. Default: https. --token TOKEN GitHub token. Defaults to GITHUB_TOKEN. + --auth auto|token|gh|public How to authenticate with GitHub: + token - use --token / GITHUB_TOKEN (required) + gh - use the GitHub CLI login (gh auth token) + public - no authentication, public repos only + auto - token if given, else gh if logged in, + else public (default) --resume Reuse state and skip completed repositories. + --force Back up every repository, even if its remote refs + match the last backup recorded in the backup manifest. + --backup-manifest FILE Record of backed-up commits used to skip unchanged + repositories. Default: DEST/backup-manifest.json. + --keep-archives N Archive mode: keep only the newest N archives per + repository. Default: 0 (keep all). --checksum Write checksums for archive files. Default. --no-checksum Disable archive checksums. --verify-after-backup Run lightweight validation after each backup. @@ -263,6 +291,41 @@ need_arg() { [[ $# -ge 2 && -n "$2" ]] || die "$1 requires a value." } +# Picks the GitHub credentials according to --auth and leaves the token (if +# any) in GITHUB_TOKEN, which both API calls and git operations use. +resolve_auth() { + local gh_token="" + + case "$AUTH_MODE" in + public) + GITHUB_TOKEN="" + ;; + token) + [[ -n "$GITHUB_TOKEN" ]] || die "--auth token requires --token TOKEN or the GITHUB_TOKEN environment variable." + ;; + gh) + require_command gh + gh_token="$(gh auth token 2>/dev/null)" || true + [[ -n "$gh_token" ]] || die "GitHub CLI is not logged in. Run 'gh auth login' first." + GITHUB_TOKEN="$gh_token" + ;; + auto) + if [[ -n "$GITHUB_TOKEN" ]]; then + AUTH_MODE="token" + elif command -v gh >/dev/null 2>&1 && gh_token="$(gh auth token 2>/dev/null)" && [[ -n "$gh_token" ]]; then + AUTH_MODE="gh" + GITHUB_TOKEN="$gh_token" + else + AUTH_MODE="public" + fi + ;; + *) + die "--auth must be auto, token, gh, or public." + ;; + esac + info "GitHub authentication: $AUTH_MODE" +} + is_positive_integer() { [[ "$1" =~ ^[0-9]+$ ]] && (( "$1" > 0 )) } @@ -333,6 +396,7 @@ load_discover_config() { set_config_string GITHUB_ORG '.org' set_config_bool AUTHENTICATED_USER '.authenticated_user' set_config_string GITHUB_TOKEN '.token' + set_config_string AUTH_MODE '.auth' set_config_string OUTPUT_FILE '.output' set_config_string OUTPUT_FORMAT '.format' set_config_string INPUT_MANIFEST '.input' @@ -356,6 +420,7 @@ load_backup_config() { set_config_string BACKUP_MODE '.mode' set_config_string PROTOCOL '.protocol' set_config_string GITHUB_TOKEN '.token' + set_config_string AUTH_MODE '.auth' set_config_bool RESUME '.resume' set_config_bool CHECKSUM '.checksum' set_config_bool VERIFY_AFTER_BACKUP '.verify_after_backup' @@ -932,7 +997,7 @@ write_summary() { repo_count: $state.repo_count, done: ([.repositories[] | select(.status == "done")] | length), failed: ([.repositories[] | select(.status == "failed")] | length), - skipped: ([.repositories[] | select(.status == "skipped")] | length), + unchanged: ([.repositories[] | select(.status == "unchanged")] | length), repositories: $state.repositories } ' "$STATE_FILE" > "$summary_file" @@ -949,6 +1014,7 @@ parse_discover_args() { --org) need_arg "$@"; GITHUB_ORG="$2"; shift 2 ;; --authenticated-user) AUTHENTICATED_USER=true; shift ;; --token) need_arg "$@"; GITHUB_TOKEN="$2"; shift 2 ;; + --auth) need_arg "$@"; AUTH_MODE="$2"; shift 2 ;; --output) need_arg "$@"; OUTPUT_FILE="$2"; shift 2 ;; --format) need_arg "$@"; OUTPUT_FORMAT="$2"; shift 2 ;; --input) need_arg "$@"; INPUT_MANIFEST="$2"; shift 2 ;; @@ -995,8 +1061,9 @@ parse_discover_args() { [[ "$AUTHENTICATED_USER" == true ]] && ((sources += 1)) (( sources == 1 )) || die "Specify exactly one of --user, --org, or --authenticated-user." + resolve_auth if [[ "$AUTHENTICATED_USER" == true && -z "$GITHUB_TOKEN" ]]; then - die "--authenticated-user requires --token or GITHUB_TOKEN." + die "--authenticated-user requires authentication (--auth token or --auth gh)." fi } @@ -1100,7 +1167,11 @@ parse_backup_args() { --mode) need_arg "$@"; BACKUP_MODE="$2"; shift 2 ;; --protocol) need_arg "$@"; PROTOCOL="$2"; shift 2 ;; --token) need_arg "$@"; GITHUB_TOKEN="$2"; shift 2 ;; + --auth) need_arg "$@"; AUTH_MODE="$2"; shift 2 ;; --resume) RESUME=true; shift ;; + --force) FORCE=true; shift ;; + --backup-manifest) need_arg "$@"; BACKUP_MANIFEST="$2"; shift 2 ;; + --keep-archives) need_arg "$@"; KEEP_ARCHIVES="$2"; shift 2 ;; --checksum) CHECKSUM=true; shift ;; --no-checksum) CHECKSUM=false; shift ;; --verify-after-backup) VERIFY_AFTER_BACKUP=true; shift ;; @@ -1134,6 +1205,8 @@ parse_backup_args() { is_non_negative_integer "$RETRY_DELAY" || die "--retry-delay expects a non-negative integer." is_positive_integer "$RETRY_BACKOFF" || die "--retry-backoff expects a positive integer." is_non_negative_integer "$SLEEP_BETWEEN_REPOS" || die "--sleep expects a non-negative integer." + is_non_negative_integer "$KEEP_ARCHIVES" || die "--keep-archives expects a non-negative integer." + resolve_auth PARALLEL_JOBS="${PARALLEL_JOBS:-$(cpu_count)}" is_positive_integer "$PARALLEL_JOBS" || die "--parallel expects a positive integer." @@ -1146,6 +1219,7 @@ parse_backup_args() { mkdir -p "$DEST_DIR" STATE_FILE="${STATE_FILE:-${DEST_DIR}/.github-backup-state.json}" + BACKUP_MANIFEST="${BACKUP_MANIFEST:-${DEST_DIR}/backup-manifest.json}" LOCK_FILE="${LOCK_FILE:-${DEST_DIR}/.github-backup.lock}" } @@ -1179,15 +1253,26 @@ repo_clone_url() { fi } +# Runs git with only the credentials chosen by --auth. Credential helpers from +# the user's git config (e.g. "gh auth git-credential") are disabled for HTTPS, +# so "public" really is anonymous and token/gh use exactly that token. git_with_optional_auth() { - if [[ -n "$GITHUB_TOKEN" && "$PROTOCOL" == "https" ]]; then + if [[ "$PROTOCOL" != "https" ]]; then + GIT_TERMINAL_PROMPT=0 git "$@" + elif [[ -n "$GITHUB_TOKEN" ]]; then GIT_TERMINAL_PROMPT=0 \ - GIT_CONFIG_COUNT=1 \ - GIT_CONFIG_KEY_0='http.https://github.com/.extraheader' \ - GIT_CONFIG_VALUE_0="AUTHORIZATION: bearer $GITHUB_TOKEN" \ + GIT_CONFIG_COUNT=2 \ + GIT_CONFIG_KEY_0='credential.helper' \ + GIT_CONFIG_VALUE_0='' \ + GIT_CONFIG_KEY_1='http.https://github.com/.extraheader' \ + GIT_CONFIG_VALUE_1="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 -w0)" \ git "$@" else - GIT_TERMINAL_PROMPT=0 git "$@" + GIT_TERMINAL_PROMPT=0 \ + GIT_CONFIG_COUNT=1 \ + GIT_CONFIG_KEY_0='credential.helper' \ + GIT_CONFIG_VALUE_0='' \ + git "$@" fi } @@ -1272,16 +1357,18 @@ backup_archive_repo() { local repo_json="$1" local full_name="$2" local output_path="$3" + local ref="${4:-}" local default_branch url tmp_archive tmp_dir extract_dir root_dir excluded local paths=() local exclude_paths=() local tar_excludes=() default_branch="$(jq -r '.default_branch // "main"' <<< "$repo_json")" - url="$(archive_url_for_repo "$full_name" "$default_branch")" + ref="${ref:-$default_branch}" + url="$(archive_url_for_repo "$full_name" "$ref")" tmp_archive="$(mktemp)" - info "Downloading archive: $full_name@$default_branch" + info "Downloading archive: $full_name@$ref" download_with_retries "$url" "$tmp_archive" || { rm -f "$tmp_archive" return 1 @@ -1377,41 +1464,184 @@ log_mirror_progress() { ) 8>"$lock_file" } +# Prints where a repository is stored. Git modes update one copy in place; +# archives are named after the commit they contain, so older snapshots are kept. +repo_output_path() { + local mode="$1" + local safe="$2" + local head_commit="${3:-}" + + case "$mode" in + mirror) printf '%s' "$DEST_DIR/repos/${safe}.git" ;; + clone|sparse) printf '%s' "$DEST_DIR/repos/${safe}" ;; + archive) + if [[ -n "$head_commit" ]]; then + printf '%s' "$DEST_DIR/archives/${safe}@${head_commit:0:12}.tar.gz" + else + printf '%s' "$DEST_DIR/archives/${safe}@$(now_utc_compact).tar.gz" + fi + ;; + *) return 1 ;; + esac +} + +prune_old_archives() { + local safe="$1" + local archive + local archives=() + + (( KEEP_ARCHIVES > 0 )) || return 0 + mapfile -t archives < <(ls -1t -- "$DEST_DIR/archives/${safe}@"*.tar.gz 2>/dev/null) + for archive in "${archives[@]:KEEP_ARCHIVES}"; do + info "Removing old archive: $archive" + rm -f -- "$archive" + done +} + backup_repo_once() { local repo_json="$1" + local head_commit="${2:-}" local full_name mode safe output_path full_name="$(jq -r '.full_name' <<< "$repo_json")" safe="$(safe_repo_name "$full_name")" mode="${BACKUP_MODE:-$(jq -r '.mode // "mirror"' <<< "$repo_json")}" + output_path="$(repo_output_path "$mode" "$safe" "$head_commit")" || die "Invalid backup mode for $full_name: $mode" - case "$mode" in - mirror) output_path="$RUN_DIR/repos/${safe}.git" ;; - clone|sparse) output_path="$RUN_DIR/repos/${safe}" ;; - archive) output_path="$RUN_DIR/archives/${safe}.tar.gz" ;; - *) die "Invalid backup mode for $full_name: $mode" ;; - esac - - mkdir -p "$RUN_DIR/repos" "$RUN_DIR/archives" + mkdir -p "$DEST_DIR/repos" "$DEST_DIR/archives" case "$mode" in mirror) backup_mirror_repo "$repo_json" "$full_name" "$output_path" >&2 || return 1 ;; clone) backup_clone_repo "$repo_json" "$full_name" "$output_path" >&2 || return 1 ;; sparse) backup_sparse_repo "$repo_json" "$full_name" "$output_path" >&2 || return 1 ;; - archive) backup_archive_repo "$repo_json" "$full_name" "$output_path" >&2 || return 1 ;; + archive) backup_archive_repo "$repo_json" "$full_name" "$output_path" "$head_commit" >&2 || return 1 ;; esac verify_backup_output "$mode" "$output_path" >&2 || return 1 if [[ "$mode" == "archive" ]]; then write_archive_checksum "$output_path" || return 1 + prune_old_archives "$safe" >&2 fi printf '%s' "$output_path" } +############################################################################### +# Backup manifest (change detection) +############################################################################### +init_backup_manifest() { + local tmp + + mkdir -p "$(dirname "$BACKUP_MANIFEST")" + if [[ ! -f "$BACKUP_MANIFEST" ]]; then + jq -n --arg now "$(now_utc_iso)" --arg source "$MANIFEST" ' + {version: 1, created_at: $now, updated_at: $now, source_manifest: $source, repositories: {}} + ' > "$BACKUP_MANIFEST" + info "Created backup manifest: $BACKUP_MANIFEST" + return 0 + fi + + jq -e '.repositories | type == "object"' "$BACKUP_MANIFEST" >/dev/null 2>&1 || + die "Backup manifest is not valid: $BACKUP_MANIFEST (move it away to start fresh)" + tmp="$(mktemp "${BACKUP_MANIFEST}.XXXXXX")" + jq --arg source "$MANIFEST" '.source_manifest = $source' "$BACKUP_MANIFEST" > "$tmp" + mv "$tmp" "$BACKUP_MANIFEST" +} + +backup_manifest_entry() { + local full_name="$1" + jq -c --arg name "$full_name" '.repositories[$name] // {}' "$BACKUP_MANIFEST" +} + +# Merges the JSON object $2 into the entry for repository $1. +backup_manifest_update() { + local full_name="$1" + local fields="$2" + + ( + local tmp + + flock 8 + tmp="$(mktemp "${BACKUP_MANIFEST}.XXXXXX")" + jq --arg name "$full_name" --arg now "$(now_utc_iso)" --argjson fields "$fields" ' + .updated_at = $now + | .repositories[$name] = ((.repositories[$name] // {}) + $fields) + ' "$BACKUP_MANIFEST" > "$tmp" + mv "$tmp" "$BACKUP_MANIFEST" + ) 8>"${BACKUP_MANIFEST}.lock" +} + +# Lists the remote refs that matter for a mode, sorted, as "<sha>\t<ref>" lines. +# Mirrors track every ref, clones track branches and tags, and archive/sparse +# backups only track the default branch (HEAD). +remote_refs_for_mode() { + local repo_json="$1" + local mode="$2" + local url refs + + url="$(repo_clone_url "$repo_json")" + refs="$(git_with_optional_auth ls-remote "$url" 2>/dev/null)" || return 1 + + case "$mode" in + mirror) printf '%s\n' "$refs" ;; + clone) printf '%s\n' "$refs" | grep -E $'\t(HEAD|refs/heads/.*|refs/tags/.*)$' || true ;; + archive|sparse) printf '%s\n' "$refs" | grep -E $'\tHEAD$' || true ;; + esac | LC_ALL=C sort +} + +# Fingerprint of the backup settings, so changing mode or paths forces a new backup. +backup_config_fingerprint() { + local repo_json="$1" + local mode="$2" + jq -cS --arg mode "$mode" '{mode: $mode, paths: (.paths // []), exclude_paths: (.exclude_paths // [])}' <<< "$repo_json" | + sha256sum | awk '{print $1}' +} + +record_backup() { + local repo_json="$1" + local mode="$2" + local output_path="$3" + local head_commit="$4" + local refs_fingerprint="$5" + local config_fingerprint="$6" + local full_name size archive_sha="" + + full_name="$(jq -r '.full_name' <<< "$repo_json")" + size="$(du -sb -- "$output_path" 2>/dev/null | awk '{print $1}')" + if [[ "$mode" == "archive" ]]; then + archive_sha="$(sha256sum -- "$output_path" | awk '{print $1}')" + fi + + backup_manifest_update "$full_name" "$(jq -cn \ + --arg now "$(now_utc_iso)" \ + --arg mode "$mode" \ + --arg output_path "${output_path#"$DEST_DIR"/}" \ + --arg head_commit "$head_commit" \ + --arg default_branch "$(jq -r '.default_branch // empty' <<< "$repo_json")" \ + --arg refs_fingerprint "$refs_fingerprint" \ + --arg config_fingerprint "$config_fingerprint" \ + --arg size "${size:-0}" \ + --arg archive_sha "$archive_sha" ' + { + mode: $mode, + output_path: $output_path, + default_branch: $default_branch, + head_commit: $head_commit, + refs_fingerprint: $refs_fingerprint, + config_fingerprint: $config_fingerprint, + size_bytes: ($size | tonumber), + last_backup_at: $now, + last_checked_at: $now, + last_result: "updated" + } + + (if $archive_sha != "" then {archive_sha256: $archive_sha} else {} end) + ')" +} + backup_repo_with_retries() { local repo_json="$1" local full_name mode key status output_path + local refs refs_fingerprint="" head_commit="" config_fingerprint previous previous_output local attempt=1 local delay="$RETRY_DELAY" @@ -1428,10 +1658,34 @@ backup_repo_with_retries() { return 0 fi + config_fingerprint="$(backup_config_fingerprint "$repo_json" "$mode")" + if refs="$(remote_refs_for_mode "$repo_json" "$mode")"; then + refs_fingerprint="$(printf '%s' "$refs" | sha256sum | awk '{print $1}')" + head_commit="$(awk -F '\t' '$2 == "HEAD" {print $1; exit}' <<< "$refs")" + else + warn "Could not list remote refs for $full_name; backing it up without change detection." + fi + + previous="$(backup_manifest_entry "$full_name")" + previous_output="$(jq -r '.output_path // empty' <<< "$previous")" + [[ -z "$previous_output" ]] || previous_output="$DEST_DIR/$previous_output" + if [[ "$FORCE" != true && -n "$refs_fingerprint" && -n "$previous_output" && -e "$previous_output" ]] && + jq -e --arg refs "$refs_fingerprint" --arg config "$config_fingerprint" \ + '.refs_fingerprint == $refs and .config_fingerprint == $config' <<< "$previous" >/dev/null; then + info "Unchanged since last backup, skipping: $full_name (${head_commit:0:12})" + backup_manifest_update "$full_name" "$(jq -cn --arg now "$(now_utc_iso)" '{last_checked_at: $now, last_result: "unchanged"}')" + update_state_repo "$key" "unchanged" "$full_name" "$mode" "$previous_output" "" + if [[ "$mode" == "mirror" ]]; then + log_mirror_progress "$full_name" + fi + return 0 + fi + while true; do update_state_repo "$key" "running" "$full_name" "$mode" "" "" - if output_path="$(backup_repo_once "$repo_json")"; then + if output_path="$(backup_repo_once "$repo_json" "$head_commit")"; then update_state_repo "$key" "done" "$full_name" "$mode" "$output_path" "" + record_backup "$repo_json" "$mode" "$output_path" "$head_commit" "$refs_fingerprint" "$config_fingerprint" info "Completed $full_name -> $output_path" if [[ "$mode" == "mirror" ]]; then log_mirror_progress "$full_name" @@ -1496,10 +1750,12 @@ run_backup() { checksum="$(manifest_checksum "$manifest_json")" mkdir -p "$DEST_DIR" init_state_file "$manifest_json" "$checksum" - mkdir -p "$RUN_DIR/repos" "$RUN_DIR/archives" + init_backup_manifest + mkdir -p "$RUN_DIR" "$DEST_DIR/repos" "$DEST_DIR/archives" info "Backup run directory: $RUN_DIR" info "State file: $STATE_FILE" + info "Backup manifest: $BACKUP_MANIFEST" repos_file="$(mktemp)" jq -c '.repositories[] | select(.enabled == true)' "$manifest_json" > "$repos_file" @@ -1528,7 +1784,7 @@ run_backup() { die "$BACKUP_FAILURES repository backup(s) failed. Re-run with --resume after fixing the issue." fi - info "Backup completed successfully." + info "Backup completed successfully: $(jq -r '[.repositories[] | select(.status == "done")] | length' "$STATE_FILE") updated, $(jq -r '[.repositories[] | select(.status == "unchanged")] | length' "$STATE_FILE") unchanged." } ###############################################################################