#!/usr/bin/env bash # # Author: Georg Voell - georg.voell@standby.cloud # Version: @(#)print-table 3.3.1 16.03.2026 (c)2026 Standby.cloud # Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ # # This script can be used free of charge. Use it as is or customize as needed. It is not guaranteed to be # error free and you will not be reimbursed for any damage it may cause. # #@ Reads a tab separated values file (tsv) and pretty prints it. #@ First row (header) should contain the keys - all other rows are the values for the keys. #@ #@Usage: print-table [options] [keys] #@ Options: #@ -h, --help : Displays helptext. #@ -v, --version : Displays the version of the script. #@ -q, --quiet : Just write error message to LOGFILE. #@ -o, --output : Output format: can be "table" (default), "line", "etsv", "tsv", "csv", "json", "keys" or "plain". #@ -i, --import : Read from a file ( = filename) - if not specified, read from stdin. #@ -g, --green : If is the same as column, display it green. #@ -y, --yellow : If is the same as column, display it yellow. #@ -r, --red : If is the same as column, display it red. #@ -d, --darkgray : If is the same as column, display it gray. #@ -b, --bold : If is the same as column, display it bold. #@ Keys: Optional - Select the keys you want to display. Use a tab (default) or comma separated string for keys. #@ Colored output only used with output set to "table" or "line". #@ JSON output is always enveloped with "content" and amount of items (contentItems). Disable this via 'export ENVELOPE_TABLE=false' #@ #@Examples: #@ #@ printf "cmd\tvers\tos\njq\t1.6\tlinux\nrclone\t1.51.0\tlinux\n" | print-table #@ #@ Displays: #@ #@ cmd vers os #@ ------ ------ ----- #@ jq 1.6 linux #@ rclone 1.51.0 linux # # Exit codes: # 01: Unknown or wrong parameter. # 02: No file and empty stream # 03: File is empty or specified keys not found. # 99: User interrupt. # # Update history: # # V 1.0.0 10.10.2017 New version # V 3.0.0 26.04.2020 Option vertical (now line) and Multi OS compatible # V 3.1.0 05.06.2023 New copyright # V 3.2.0 11.08.2024 New minor version: Options --import and keys # V 3.2.3 30.11.2024 Use new ConverKeys function # V 3.3.0 19.01.2026 Revised with support of Claude Code # V 3.3.1 16.03.2026 Optimized: replaced backticks with $(); [ ] with [[ ]]; # echo|grep with [[ == pattern ]]; replaced let with (( )); # UUOC removed (wc < file); replaced wc -m per item with ${#item}; # echo|grep for /.../ sentinel with [[ == /*/ ]]; # replaced [ -a ] with &&; case-based format dispatch # # Find executable bash library and source it lib=$(command -v lib.bash 2>/dev/null) if [[ -n "$lib" ]]; then source "$lib" else progdir=$(dirname "$0") if [[ -r "${progdir}/lib.bash" ]]; then source "${progdir}/lib.bash" else echo "Unexpected error: Unable to locate bash library 'lib.bash'." exit 1 fi fi # Case-insensitive match shopt -s nocasematch # Do extra cleanup function ExtraCleanup() { filecheck -rm "${scratchfile}.pre" } # Check if first parameter (word) is found in following array function InArray { local word="$1"; shift for entry in "$@"; do [[ "$word" == "$entry" ]] && return 0 done return 1 } # Append a key to a keystring function AppendKey { local keystring="${1}" local key="${2}" if [[ -n "$key" ]]; then if [[ -n "$keystring" ]]; then printf "%s\t%s\n" "$keystring" "$key" else printf '%s\n' "$key" fi else printf '%s\n' "$keystring" fi } function PrepareSearchArrays { local IFS=$'\t' read -r -a red_words <<< "$redstr" read -r -a green_words <<< "$greenstr" read -r -a yellow_words <<< "$yellowstr" read -r -a gray_words <<< "$graystr" read -r -a bold_words <<< "$boldstr" } function PrepareHeaderArray { local keystring="${1}" local num=0 local IFS=$'\t' read -r -a headerArr <<< "${num} ${keystring}" if [[ -n "$keystring" ]]; then num=${#headerArr[@]} (( num-- )) headerArr[0]=$num fi } # Preset formatstr="" awkstr="" redstr="" greenstr="" yellowstr="" graystr="" boldstr="" filename="" param="" qopt="" red_words=() green_words=() yellow_words=() gray_words=() bold_words=() # Check parameters: Loop until all parameters are used up while [[ $# -gt 0 ]]; do pname="${1}" case "$pname" in -v | --version) shift showversion=true ;; -h | --help) shift showhelp=true ;; -q | --quiet) shift qopt="$pname" ;; -i | --import) shift if [[ -n "$1" ]]; then if [[ -z "$filename" ]]; then filename="$1" if [[ ! -r "$filename" ]]; then errstr="Can't read from filename '$filename'." fi else errstr="Option '$pname' used more then once." fi shift else errstr="Please specify a filename after parameter '$pname'." fi ;; -o | --output) shift if [[ -n "$1" ]]; then if [[ -z "$formatstr" ]]; then formatstr=$(ToLower "$1") case "$formatstr" in table|line|etsv|tsv|csv|json|keys|plain) ;; *) errstr="Unknown format '$formatstr' after parameter '$pname'. Please choose from 'table', 'line', 'json', 'etsv', 'tsv', 'csv', 'keys' or 'plain'." ;; esac else errstr="Option '$pname' used more then once." fi shift else errstr="Please specify a format ('table', 'line', 'json', 'etsv', 'tsv', 'csv', 'keys' or 'plain') after parameter '$pname'." fi ;; -r | --red) shift if [[ -n "$1" ]]; then if [[ -z "$redstr" ]]; then redstr=$(ConvertKeys "$1") else errstr="Option '$pname' used more then once."; fi shift else errstr="Please specify a string after parameter '$pname'." fi ;; -g | --green) shift if [[ -n "$1" ]]; then if [[ -z "$greenstr" ]]; then greenstr=$(ConvertKeys "$1") else errstr="Option '$pname' used more then once."; fi shift else errstr="Please specify a string after parameter '$pname'." fi ;; -y | --yellow) shift if [[ -n "$1" ]]; then if [[ -z "$yellowstr" ]]; then yellowstr=$(ConvertKeys "$1") else errstr="Option '$pname' used more then once."; fi shift else errstr="Please specify a string after parameter '$pname'." fi ;; -d | --darkgray) shift if [[ -n "$1" ]]; then if [[ -z "$graystr" ]]; then graystr=$(ConvertKeys "$1") else errstr="Option '$pname' used more then once."; fi shift else errstr="Please specify a string after parameter '$pname'." fi ;; -b | --bold) shift if [[ -n "$1" ]]; then if [[ -z "$boldstr" ]]; then boldstr=$(ConvertKeys "$1") else errstr="Option '$pname' used more then once."; fi shift else errstr="Please specify a string after parameter '$pname'." fi ;; *) shift if [[ "$pname" == -* ]]; then errstr="Unknown option '$pname'." else if [[ -z "$param" ]]; then param=$(ConvertKeys "$pname") else errstr="Keys were already specified '$param'. Unknown additional parameter: '$pname'." fi fi ;; esac done # Display help or error message DisplayHelp # Set output to table if not specified if [[ -z "$formatstr" ]]; then formatstr="table" fi # Check if filename is empty and create workfile if [[ -n "$filename" ]]; then grep . "$filename" > "$scratchfile" else if [[ ! -t 0 ]]; then grep . > "$scratchfile" 2>&1 else exitcode=2 errormsg $qopt $exitcode "($progstr) Please specify a file with tab separated values (tsv)." exit $exitcode fi fi # Check if we have a header line="" result=$(filecheck -sl "$scratchfile") if [[ -n "$result" ]]; then line=$(head -n 1 "$scratchfile") if [[ -n "$line" ]]; then fc="${line:0:1}" if [[ "$fc" == "{" || "$fc" == "[" || "$fc" == '"' ]]; then line="" else columns=$(NumKeys "$line") if (( columns < 1 )); then line="" else PrepareHeaderArray "$line" fi fi fi fi if [[ -n "$line" ]]; then # Parameter given -> Select only what was specified if [[ -n "$param" ]]; then # Check for parents in param and replace with children i=1 param2="" num=$(NumKeys "$param") while (( i <= num )); do item=$(GetKey "$param" "$i") result=$(CheckKey "$line" "$item" "parent") if [[ -n "$result" ]]; then while IFS=$'\n' read -r item; do if [[ -n "$item" ]]; then if [[ "$formatstr" == "plain" && $num -eq 1 ]]; then item=$(basename "$item") fi param2=$(AppendKey "$param2" "$item") fi done < <(printf '%s\n' "$result") else param2=$(AppendKey "$param2" "$item") fi (( i++ )) done if [[ "$param" != "$param2" ]]; then param="$param2" num=$(NumKeys "$param") fi i=1 while (( i <= num )); do j=1 found=false item=$(GetKey "$param" "$i") while (( j <= columns )) && [[ "$found" == false ]]; do if [[ "$item" == "${headerArr[$j]}" ]]; then found=true if [[ -z "$awkstr" ]]; then awkstr="\$$j" awkstr2="\$$j" else awkstr="${awkstr}\"\t\"\$$j" awkstr2="${awkstr2}\"\t\"\$$j" fi fi (( j++ )) done if [[ "$found" == false ]]; then if [[ -z "$awkstr" ]]; then awkstr="\"$item\"" awkstr2="\"//\"" else awkstr="${awkstr}\"\t$item\"" awkstr2="${awkstr2}\"\t//\"" fi fi (( i++ )) done if [[ -n "$awkstr" ]]; then mv -f "$scratchfile" "${scratchfile}.pre" head -n 1 "${scratchfile}.pre" | awk -F$'\t' "{print ${awkstr}}" > "$scratchfile" tailfromline2 "${scratchfile}.pre" | awk -F$'\t' "{print ${awkstr2}}" >> "$scratchfile" filecheck -rm "${scratchfile}.pre" line=$(head -n 1 "$scratchfile") columns=$(NumKeys "$line") if (( columns < 1 )); then line="" else PrepareHeaderArray "$line" fi else exitcode=3 errormsg $qopt $exitcode "($progstr) None of the specified keys were found. Exiting." Cleanup exit $exitcode fi fi else exitcode=3 errormsg $qopt $exitcode "($progstr) Invalid TSV file. Exiting." Cleanup exit $exitcode fi # If output is plain, just display all items in first line and exit case "$formatstr" in plain) if (( columns > 1 )); then parent="" oldparent="" i=1 while (( i <= columns )); do item=$(GetKey "$line" "$i") # Check for children using bash pattern match havechildren=false if [[ "$item" == *"/"* ]]; then parent="${item%%/*}" child="${item#*/}" [[ -n "$parent" && -n "$child" ]] && havechildren=true fi if [[ "$havechildren" == true ]]; then if [[ "$oldparent" != "$parent" ]]; then printf '%s\n' "$parent" oldparent="$parent" fi else printf '%s\n' "$item" fi (( i++ )) done Cleanup exit $exitcode else # Check if we selected a parent result=$(printf '%s' "$line" | sed 's|\t|\n|g' | grep "^${item}/") if [[ -n "$result" ]]; then printf '%s\n' "$result" | cut -d"/" -f2- Cleanup exit $exitcode fi fi ;; etsv) cat "$scratchfile" Cleanup exit $exitcode ;; tsv | csv) if [[ "$formatstr" == "tsv" ]]; then printf '%s\n' "$line" else printf '"%s"\n' "$line" | sed 's|\t|","|g' fi while IFS=$'\n' read -r line; do i=1 while (( i <= columns )); do item=$(GetKey "$line" "$i") # Use bash pattern match for /.../ sentinel (no echo|grep subshell) if [[ "$item" == /*/ ]]; then item="${item:1:${#item}-2}" # strip leading and trailing / else if [[ "$formatstr" == "csv" ]]; then item="\"${item}\"" fi fi if (( i == columns )); then printf "%s\n" "$item" else if [[ "$formatstr" == "csv" ]]; then printf "%s," "$item" else printf "%s\t" "$item" fi fi (( i++ )) done done < <(tailfromline2 "$scratchfile") Cleanup exit $exitcode ;; esac headerArrLen=0 headerArrMax=0 # Avoid UUOC: redirect file directly into wc linesMax=$(wc -l < "$scratchfile") (( linesMax-- )) # subtract header line # Determine string length for all header items i=1 while (( i <= columns )); do # Use bash ${#} instead of wc -m (no subshell per header cell) len=${#headerArr[$i]} headerArrLen[$i]=$len (( len > headerArrMax )) && headerArrMax=$len (( i++ )) done # Setting spacer for JSON output if [[ "$formatstr" == "json" ]]; then oldparent="" havechildren=false if [[ "$ENVELOPE_TABLE" == false ]]; then (( linesMax > 1 )) && spacer=" " || spacer=" " else (( linesMax > 1 )) && spacer=" " || spacer=" " fi fi # Prepare color-word arrays if [[ "$formatstr" == "table" || "$formatstr" == "line" ]]; then PrepareSearchArrays fi # We have to determine the length of each item in all lines (table mode only) if [[ "$formatstr" == "table" ]]; then while IFS=$'\n' read -r line; do i=1 while (( i <= columns )); do item=$(GetKey "$line" "$i") # Strip /.../ sentinel with bash built-in if [[ "$item" == /*/ ]]; then item="${item:1:${#item}-2}" fi # Use ${#item} instead of echo|wc -m (no subshell per cell) len=${#item} (( len > headerArrLen[i] )) && headerArrLen[$i]=$len (( i++ )) done done < <(tailfromline2 "$scratchfile") # Print header line i=1 while (( i <= columns )); do (( i > 1 )) && printf " " printf "%s" "${headerArr[$i]}" k=${#headerArr[$i]} if (( k < headerArrLen[i] )); then (( k = headerArrLen[i] - k )) printf "%${k}s" " " fi (( i++ )) done printf "\n" # Print dashed line i=1 while (( i <= columns )); do (( i > 1 )) && printf " " # Use printf -v for dash string (no tr subshell) printf -v dashstr '%*s' "${headerArrLen[$i]}" '' printf '%s' "${dashstr// /-}" (( i++ )) done printf "\n" else # JSON opening if [[ "$formatstr" == "json" && $linesMax -gt 0 ]]; then if (( linesMax > 1 )); then if [[ "$ENVELOPE_TABLE" == false ]]; then printf '[\n {\n' else printf '{\n "content": [\n {\n'; fi else if [[ "$ENVELOPE_TABLE" == false ]]; then printf '{\n' else printf '{\n "content": {\n'; fi fi fi fi # Set defaults l=0 nextskip=false # Print all remaining lines while IFS=$'\n' read -r line; do i=1 while (( i <= columns )); do item=$(GetKey "$line" "$i") skip=false special=false if [[ -n "$item" ]]; then if [[ "$item" == "//" ]]; then skip=true item="" elif [[ "$item" == /*/ ]]; then special=true item="${item:1:${#item}-2}" fi fi case "$formatstr" in table) (( i > 1 )) && printf " " ;; line) if (( i > 1 )); then if [[ "$nextskip" == true ]]; then nextskip=false else printf "\n" fi fi if [[ "$skip" == false ]]; then k=${#headerArr[$i]} if (( k < headerArrMax )); then (( k = headerArrMax - k )) printf "%${k}s" " " fi printf "%s" "${headerArr[$i]}: " else nextskip=true fi ;; json) if (( linesMax > 0 )); then # Detect parent/child in header havechildren=false parent="" child="" if [[ "${headerArr[$i]}" == *"/"* ]]; then parent="${headerArr[$i]%%/*}" child="${headerArr[$i]#*/}" [[ -n "$parent" && -n "$child" ]] && havechildren=true fi if (( i > 1 )); then if [[ "$nextskip" == false ]]; then if [[ "$havechildren" == true && -n "$oldparent" ]]; then needcomma=false k=$i while (( k <= columns )); do theader="${headerArr[$((k-1))]}" if [[ "$theader" == *"/"* ]]; then par="${theader%%/*}" chi="${theader#*/}" if [[ -n "$par" && -n "$chi" ]]; then if [[ "$par" != "$oldparent" ]]; then needcomma=false; k=$columns else titem=$(GetKey "$line" "$k") if [[ "$titem" == "//" ]]; then needcomma=false; k=$columns else needcomma=true; k=$columns fi fi fi else needcomma=true; k=$columns fi (( k++ )) done [[ "$needcomma" == true ]] && printf ',' printf '\n' else [[ -z "$oldparent" ]] && printf ',' printf '\n' fi fi fi if [[ "$skip" == false ]]; then printf "%s" "$spacer" nextskip=false else nextskip=true fi if [[ "$havechildren" == true ]]; then if [[ -z "$oldparent" ]]; then printf '"%s": {\n%s' "$parent" "$spacer" elif [[ "$oldparent" != "$parent" ]]; then [[ "$nextskip" == true ]] && printf '%s' "$spacer" printf '},\n%s"%s": {\n' "$spacer" "$parent" [[ "$skip" == false ]] && printf '%s' "$spacer" fi oldparent="$parent" else if [[ -n "$oldparent" ]]; then printf '},\n%s' "$spacer" oldparent="" fi fi if [[ "$skip" == false ]]; then if [[ "$havechildren" == true ]]; then printf ' "%s": ' "$child" else printf '"%s": ' "${headerArr[$i]}" fi fi fi ;; keys) (( i > 1 )) && printf "\n" printf "%s\t" "${headerArr[$i]}" ;; esac # Print the item value if [[ "$formatstr" == "json" || "$formatstr" == "keys" || "$formatstr" == "plain" ]]; then if [[ "$formatstr" == "json" ]]; then if [[ "$skip" == false ]]; then if [[ "$special" == true ]]; then printf "%s" "$item" else printf '"%s"' "$item" fi fi else printf "%s" "$item" fi else if [[ ( "$formatstr" == "table" || "$formatstr" == "line" ) && "$skip" == false ]]; then if InArray "$item" "${red_words[@]}"; then printf "${red}%s${normal}" "$item" elif InArray "$item" "${yellow_words[@]}"; then printf "${yellow}%s${normal}" "$item" elif InArray "$item" "${green_words[@]}"; then printf "${green}%s${normal}" "$item" elif InArray "$item" "${gray_words[@]}"; then printf "${gray}%s${normal}" "$item" elif InArray "$item" "${bold_words[@]}"; then printf "${bold}%s${normal}" "$item" else printf "%s" "$item" fi fi fi # Pad for table alignment using ${#item} (no wc -m subshell) if [[ "$formatstr" == "table" ]]; then k=${#item} if (( k < headerArrLen[i] )); then (( k = headerArrLen[i] - k )) printf "%${k}s" " " fi fi (( i++ )) done # End-of-line if [[ "$formatstr" == "json" || "$formatstr" == "line" ]]; then [[ "$nextskip" == false ]] && printf '\n' else printf "\n" fi (( l++ )) if [[ "$formatstr" != "table" && $l -lt $linesMax ]]; then if [[ "$formatstr" == "json" ]]; then if [[ -n "$oldparent" ]]; then printf '%s}\n' "$spacer" oldparent="" fi if [[ "$ENVELOPE_TABLE" == false ]]; then printf " },\n {\n" else printf " },\n {\n" fi elif [[ "$formatstr" != "plain" && "$formatstr" != "keys" ]]; then printf "\n" fi fi done < <(tailfromline2 "$scratchfile") if [[ "$formatstr" == "json" ]]; then if (( linesMax > 0 )); then if [[ -n "$oldparent" ]]; then printf '%s}\n' "$spacer" oldparent="" fi if (( linesMax > 1 )); then if [[ "$ENVELOPE_TABLE" == false ]]; then printf ' }\n]\n' else printf ' }\n ],\n "contentItems": %d\n}\n' "$linesMax" fi else if [[ "$ENVELOPE_TABLE" == false ]]; then printf '}\n' else printf ' },\n "contentItems": %d\n}\n' "$linesMax" fi fi fi fi # Cleanup and exit Cleanup exit $exitcode