Last active
August 29, 2026 14:06
-
-
Save dzogrim/cbf7f8e4c5e4d24deff065bf61751034 to your computer and use it in GitHub Desktop.
Safely shorten overly long filenames while preserving extensions
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| set -Eeuo pipefail | |
| # SPDX-License-Identifier: MIT | |
| # SPDX-FileCopyrightText: 2026 dzogrim | |
| # SPDX-FileContributor: dzogrim <dzogrim@dzogrim.pw> | |
| # --------------------------------------------------------------------------- | |
| # File: truncate-long-filenames.sh | |
| # Purpose: | |
| # Safely shorten overly long filenames while preserving extensions. | |
| # | |
| # Default mode: | |
| # Dry-run only. No files are modified. | |
| # | |
| # Notes: | |
| # - One or more directories can be supplied as positional arguments. | |
| # - Individual files can be supplied with --file FILE. | |
| # - If no path and no file are supplied, DEFAULT_BASE is scanned. | |
| # - Extensions are preserved. | |
| # - A short SHA-256 suffix is added to avoid collisions. | |
| # - Generated .terraform trees are skipped deliberately. | |
| # --------------------------------------------------------------------------- | |
| readonly VERSION="2.1.0" | |
| readonly VERSION_DATE="2026-08-29" | |
| readonly PROGRAM_NAME="${0##*/}" | |
| readonly DEFAULT_BASE="${HOME}/Documents/Personnels" | |
| readonly DEFAULT_MAX_BYTES=180 | |
| readonly HASH_LENGTH=8 | |
| APPLY=0 | |
| MAX_BYTES="$DEFAULT_MAX_BYTES" | |
| declare -a BASES=() | |
| declare -a EXPLICIT_FILES=() | |
| declare -A SEEN_FILES=() | |
| usage() { | |
| cat <<EOF | |
| ${PROGRAM_NAME} ${VERSION} (${VERSION_DATE}) | |
| Safely shorten overly long filenames while preserving their extensions. | |
| Usage: | |
| ${PROGRAM_NAME} [OPTIONS] [PATH...] | |
| ${PROGRAM_NAME} [OPTIONS] --file FILE [--file FILE...] | |
| Options: | |
| --apply Actually rename files. | |
| Default mode is dry-run. | |
| --max-bytes N Maximum allowed filename size in bytes. | |
| Default: ${DEFAULT_MAX_BYTES} | |
| --file FILE Process one specific file. | |
| May be specified multiple times. | |
| -V, --version Show version information and exit. | |
| -h, --help Show this help and exit. | |
| Arguments: | |
| PATH... One or more directories to scan recursively. | |
| Default: | |
| If no PATH and no --file are supplied: | |
| ${DEFAULT_BASE} | |
| Examples: | |
| ${PROGRAM_NAME} | |
| ${PROGRAM_NAME} "/path/to/tree" | |
| ${PROGRAM_NAME} \ | |
| "/path/to/first/tree" \ | |
| "/path/to/second/tree" | |
| ${PROGRAM_NAME} \ | |
| --file "/path/to/problematic-file.pdf" | |
| ${PROGRAM_NAME} \ | |
| --file "/path/to/file1.pdf" \ | |
| --file "/path/to/file2.pdf" | |
| ${PROGRAM_NAME} \ | |
| --max-bytes 180 \ | |
| --file "/path/to/problematic-file.pdf" | |
| ${PROGRAM_NAME} \ | |
| --apply \ | |
| --max-bytes 180 \ | |
| "/path/to/tree" | |
| Default behavior: | |
| Dry-run mode. Candidate renames are displayed but no files are modified. | |
| EOF | |
| } | |
| version() { | |
| printf '%s %s (%s)\n' \ | |
| "$PROGRAM_NAME" \ | |
| "$VERSION" \ | |
| "$VERSION_DATE" | |
| } | |
| die() { | |
| printf 'ERROR: %s\n' "$*" >&2 | |
| exit 1 | |
| } | |
| # Return byte length without spawning wc/tr. | |
| # | |
| # Bash normally counts characters according to locale. Temporarily forcing | |
| # LC_ALL=C makes ${#var} count raw bytes instead. | |
| byte_len() { | |
| local value="$1" | |
| local LC_ALL=C | |
| printf '%d' "${#value}" | |
| } | |
| sha8() { | |
| local value="$1" | |
| local digest | |
| digest="$(printf '%s' "$value" | shasum -a 256)" | |
| digest="${digest%% *}" | |
| printf '%s' "${digest:0:HASH_LENGTH}" | |
| } | |
| trim_trailing_space_and_dots() { | |
| local value="$1" | |
| while [[ "$value" == *' ' || "$value" == *'.' || "$value" == *$'\t' ]]; do | |
| value="${value%?}" | |
| done | |
| printf '%s' "$value" | |
| } | |
| utf8_valid() { | |
| local value="$1" | |
| printf '%s' "$value" | | |
| iconv -f UTF-8 -t UTF-8 >/dev/null 2>&1 | |
| } | |
| truncate_utf8_to_bytes() { | |
| local value="$1" | |
| local max_bytes="$2" | |
| local LC_ALL=C | |
| local current_bytes | |
| current_bytes=${#value} | |
| while ((current_bytes > max_bytes)); do | |
| value="${value:0:current_bytes-1}" | |
| current_bytes=${#value} | |
| done | |
| # If truncation ended inside a UTF-8 sequence, remove bytes until valid. | |
| while [[ -n "$value" ]] && ! utf8_valid "$value"; do | |
| current_bytes=${#value} | |
| value="${value:0:current_bytes-1}" | |
| done | |
| printf '%s' "$value" | |
| } | |
| process_file() { | |
| local file="$1" | |
| local display_base="${2:-}" | |
| local dir | |
| local name | |
| local name_bytes | |
| local stem | |
| local ext | |
| local hash | |
| local suffix | |
| local suffix_bytes | |
| local target_stem_bytes | |
| local new_stem | |
| local new_name | |
| local new_file | |
| local new_bytes | |
| local display_old | |
| local display_new | |
| [[ -n "${SEEN_FILES["$file"]+x}" ]] && { | |
| ((duplicates += 1)) | |
| return 0 | |
| } | |
| SEEN_FILES["$file"]=1 | |
| ((scanned += 1)) | |
| case "$file" in | |
| */.terraform/*) | |
| ((skipped += 1)) | |
| return 0 | |
| ;; | |
| esac | |
| if [[ ! -f "$file" ]]; then | |
| printf 'ERROR: not a regular file: %s\n\n' "$file" >&2 | |
| ((errors += 1)) | |
| return 0 | |
| fi | |
| dir="${file%/*}" | |
| name="${file##*/}" | |
| name_bytes="$(byte_len "$name")" | |
| ((name_bytes > MAX_BYTES)) || return 0 | |
| ((candidates += 1)) | |
| stem="$name" | |
| ext="" | |
| # Preserve a conventional extension conservatively. | |
| if [[ "$name" == *.* && "$name" != .* ]]; then | |
| ext=".${name##*.}" | |
| stem="${name%.*}" | |
| fi | |
| hash="$(sha8 "$file")" | |
| suffix="_${hash}${ext}" | |
| suffix_bytes="$(byte_len "$suffix")" | |
| target_stem_bytes=$((MAX_BYTES - suffix_bytes)) | |
| if ((target_stem_bytes < 1)); then | |
| printf 'ERROR: suffix leaves no room for filename\n' >&2 | |
| printf ' Source: %s\n\n' "$file" >&2 | |
| ((errors += 1)) | |
| return 0 | |
| fi | |
| new_stem="$(truncate_utf8_to_bytes "$stem" "$target_stem_bytes")" | |
| new_stem="$(trim_trailing_space_and_dots "$new_stem")" | |
| [[ -n "$new_stem" ]] || new_stem="file" | |
| # Trimming may theoretically change the byte budget. Recheck explicitly. | |
| while :; do | |
| new_name="${new_stem}_${hash}${ext}" | |
| new_bytes="$(byte_len "$new_name")" | |
| ((new_bytes <= MAX_BYTES)) && break | |
| new_stem="$(truncate_utf8_to_bytes "$new_stem" "$(( $(byte_len "$new_stem") - 1 ))")" | |
| new_stem="$(trim_trailing_space_and_dots "$new_stem")" | |
| [[ -n "$new_stem" ]] || new_stem="file" | |
| done | |
| new_file="${dir}/${new_name}" | |
| if [[ "$new_file" == "$file" ]]; then | |
| ((skipped += 1)) | |
| return 0 | |
| fi | |
| if [[ -e "$new_file" || -L "$new_file" ]]; then | |
| printf 'ERROR: target already exists\n' >&2 | |
| printf ' Source: %s\n' "$file" >&2 | |
| printf ' Target: %s\n\n' "$new_file" >&2 | |
| ((errors += 1)) | |
| return 0 | |
| fi | |
| display_old="$file" | |
| display_new="$new_file" | |
| if [[ -n "$display_base" ]]; then | |
| case "$file" in | |
| "$display_base"/*) | |
| display_old="${file#"$display_base"/}" | |
| display_new="${new_file#"$display_base"/}" | |
| ;; | |
| esac | |
| fi | |
| printf '%d bytes -> %d bytes\n' \ | |
| "$name_bytes" \ | |
| "$new_bytes" | |
| printf ' OLD: %s\n' "$display_old" | |
| printf ' NEW: %s\n' "$display_new" | |
| if ((APPLY)); then | |
| if mv -- "$file" "$new_file"; then | |
| printf ' OK: renamed\n' | |
| ((renamed += 1)) | |
| else | |
| printf ' ERROR: rename failed\n' >&2 | |
| ((errors += 1)) | |
| fi | |
| else | |
| printf ' DRY-RUN: unchanged\n' | |
| fi | |
| printf '\n' | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Argument parsing | |
| # --------------------------------------------------------------------------- | |
| while (($# > 0)); do | |
| case "$1" in | |
| --apply) | |
| APPLY=1 | |
| shift | |
| ;; | |
| --max-bytes) | |
| (($# >= 2)) || | |
| die "--max-bytes requires an argument" | |
| MAX_BYTES="$2" | |
| shift 2 | |
| ;; | |
| --file) | |
| (($# >= 2)) || | |
| die "--file requires an argument" | |
| EXPLICIT_FILES+=("$2") | |
| shift 2 | |
| ;; | |
| -V|--version) | |
| version | |
| exit 0 | |
| ;; | |
| -h|--help) | |
| usage | |
| exit 0 | |
| ;; | |
| --) | |
| shift | |
| while (($# > 0)); do | |
| BASES+=("$1") | |
| shift | |
| done | |
| ;; | |
| -*) | |
| die "Unknown option: $1" | |
| ;; | |
| *) | |
| BASES+=("$1") | |
| shift | |
| ;; | |
| esac | |
| done | |
| if ((${#BASES[@]} == 0 && ${#EXPLICIT_FILES[@]} == 0)); then | |
| BASES=("$DEFAULT_BASE") | |
| fi | |
| # --------------------------------------------------------------------------- | |
| # Validation | |
| # --------------------------------------------------------------------------- | |
| [[ "$MAX_BYTES" =~ ^[0-9]+$ ]] || | |
| die "--max-bytes must be a positive integer" | |
| ((MAX_BYTES >= 64)) || | |
| die "--max-bytes is suspiciously small: $MAX_BYTES" | |
| for base in "${BASES[@]}"; do | |
| [[ -d "$base" ]] || | |
| die "Directory does not exist: $base" | |
| [[ -r "$base" ]] || | |
| die "Directory is not readable: $base" | |
| if ((APPLY)); then | |
| [[ -w "$base" ]] || | |
| die "Directory is not writable: $base" | |
| fi | |
| done | |
| for file in "${EXPLICIT_FILES[@]}"; do | |
| [[ -e "$file" || -L "$file" ]] || | |
| die "File does not exist: $file" | |
| [[ -r "$file" ]] || | |
| die "File is not readable: $file" | |
| if ((APPLY)); then | |
| parent="${file%/*}" | |
| [[ -w "$parent" ]] || | |
| die "Parent directory is not writable: $parent" | |
| fi | |
| done | |
| mode="DRY-RUN" | |
| ((APPLY)) && mode="APPLY" | |
| printf '%s %s (%s)\n' \ | |
| "$PROGRAM_NAME" \ | |
| "$VERSION" \ | |
| "$VERSION_DATE" | |
| printf 'Mode : %s\n' "$mode" | |
| printf 'Max bytes : %s\n' "$MAX_BYTES" | |
| printf 'Paths : %d\n' "${#BASES[@]}" | |
| printf 'Files : %d\n' "${#EXPLICIT_FILES[@]}" | |
| for base in "${BASES[@]}"; do | |
| printf ' PATH: %s\n' "$base" | |
| done | |
| for file in "${EXPLICIT_FILES[@]}"; do | |
| printf ' FILE: %s\n' "$file" | |
| done | |
| printf '\n' | |
| # --------------------------------------------------------------------------- | |
| # Processing | |
| # --------------------------------------------------------------------------- | |
| scanned=0 | |
| candidates=0 | |
| renamed=0 | |
| skipped=0 | |
| errors=0 | |
| duplicates=0 | |
| for file in "${EXPLICIT_FILES[@]}"; do | |
| process_file "$file" | |
| done | |
| for base in "${BASES[@]}"; do | |
| base="${base%/}" | |
| printf 'Scanning: %s\n' "$base" | |
| printf '%s\n' '------------------------------------------------------------' | |
| while IFS= read -r -d '' file; do | |
| process_file "$file" "$base" | |
| done < <( | |
| find "$base" \ | |
| -type f \ | |
| -print0 | |
| ) | |
| printf '\n' | |
| done | |
| # --------------------------------------------------------------------------- | |
| # Summary | |
| # --------------------------------------------------------------------------- | |
| printf '%s\n' '============================================================' | |
| printf 'Summary\n' | |
| printf '%s\n' '------------------------------------------------------------' | |
| printf 'Paths : %d\n' "${#BASES[@]}" | |
| printf 'Files : %d\n' "${#EXPLICIT_FILES[@]}" | |
| printf 'Scanned : %d\n' "$scanned" | |
| printf 'Candidates : %d\n' "$candidates" | |
| if ((APPLY)); then | |
| printf 'Renamed : %d\n' "$renamed" | |
| else | |
| printf 'Renamed : 0 (dry-run)\n' | |
| fi | |
| printf 'Skipped : %d\n' "$skipped" | |
| printf 'Duplicates : %d\n' "$duplicates" | |
| printf 'Errors : %d\n' "$errors" | |
| ((errors == 0)) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment