bashtop/dataviewer.sh

412 lines
15 KiB
Bash
Executable File

#!/usr/bin/env bash
# Ensure Bash 4.4 or later
if [[ "${BASH_VERSINFO[0]}" -lt 4 ]] || [[ "${BASH_VERSINFO[0]}" == 4 && "${BASH_VERSINFO[1]}" -lt 4 ]]; then
echo "ERROR: Bash 4.4 or later is required (you are using Bash ${BASH_VERSINFO[0]}.${BASH_VERSINFO[1]})."
exit 1
fi
# Set script to exit on error
set -e
# --- Drawing Functions ---
# Usage: draw_box <line> <col> <height> <width> [title]
draw_box() {
local line="$1" col="$2" height="$3" width="$4" title="$5"
local i
# Draw top border
echo -en "\033[${line};${col}H┌"
for ((i=0; i<width-2; i++)); do echo -n "─"; done
echo -n "┐"
# Draw side borders
for ((i=1; i<height-1; i++)); do
echo -en "\033[$((line+i));${col}H│"
echo -en "\033[$((line+i));$((col+width-1))H│"
done
# Draw bottom border
echo -en "\033[$((line+height-1));${col}H└"
for ((i=0; i<width-2; i++)); do echo -n "─"; done
echo -n "┘"
# Draw title
if [[ -n "$title" ]]; then
echo -en "\033[${line};$((col + 2))H┤ ${title}"
fi
}
# Usage: display_data <start_line> <start_col> <max_width> <max_height>
display_data() {
local start_line="$1" start_col="$2" max_width="$3" max_height="$4"
local i row_idx col_idx
# Clear previous content within the data area of the box
for ((i=0; i<max_height-2; i++)); do
echo -en "\033[$((start_line+1+i));$((start_col+1))H"
printf "%*s" "$((max_width-2))" ""
done
# Check if header_row itself contains an error message or info
if [[ "${header_row[0]}" == "Error:"* || "${header_row[0]}" == "Info:"* || "${header_row[0]}" == "No input file specified."* ]]; then
echo -en "\033[$((start_line + 1));$((start_col + 1))H${header_row[0]:0:$((max_width-2))}"
# If there's a second line to the error/info message in header_row
if [[ -n "${header_row[1]}" ]]; then
echo -en "\033[$((start_line + 2));$((start_col + 1))H${header_row[1]:0:$((max_width-2))}"
fi
return
fi
# Display Header
if ((${#header_row[@]} > 0)); then
echo -en "\033[$((start_line + 1));$((start_col + 1))H"
local header_line_to_print=""
for cell in "${header_row[@]}"; do
local col_width=$(( (max_width - 2) / ${#header_row[@]} ))
if ((col_width < 1)); then col_width=1; fi
local truncated_cell
if ((${#cell} > col_width -1 )); then
truncated_cell="${cell:0:$((col_width-2))}"
else
truncated_cell="$cell"
fi
header_line_to_print+=$(printf "\033[1m%-*s\033[0m" "$col_width" "$truncated_cell")
done
echo -n "$header_line_to_print"
row_idx=1
else
row_idx=0
fi
# Display Data Rows
local data_display_count=0
for row_nameref in "${parsed_data_rows[@]}"; do
if ((row_idx >= max_height - 2)); then break; fi
declare -n current_row="$row_nameref"
echo -en "\033[$((start_line + 1 + row_idx));$((start_col + 1))H"
local line_to_print=""
col_idx=0
if ((${#current_row[@]} > 0)); then
for cell in "${current_row[@]}"; do
# Use header_row for column count if available, otherwise current_row
local num_cols_for_width=${#header_row[@]}
if ((num_cols_for_width == 0)); then num_cols_for_width=${#current_row[@]}; fi
if ((num_cols_for_width == 0)); then num_cols_for_width=1; fi # Avoid division by zero
local col_width=$(( (max_width - 2) / num_cols_for_width ))
if ((col_width < 1)); then col_width=1; fi
local truncated_cell
if ((${#cell} > col_width -1 )); then
truncated_cell="${cell:0:$((col_width-2))}"
else
truncated_cell="$cell"
fi
line_to_print+=$(printf "%-*s" "$col_width" "$truncated_cell")
((col_idx++))
done
echo -n "$line_to_print"
fi
((row_idx++))
((data_display_count++))
done
if [[ ${#header_row[@]} -eq 0 && $data_display_count -eq 0 ]]; then
# This case should be largely covered by the initial check of header_row for errors
# but kept as a fallback.
local error_msg="No data to display."
if [[ -n "$INPUT_FILE_PATH" && ! -f "$INPUT_FILE_PATH" ]]; then # Check again, in case it was deleted
error_msg="Error: File not found ($INPUT_FILE_PATH)"
elif [[ -n "$INPUT_FILE_PATH" ]]; then
error_msg="Error: Could not parse or file empty ($INPUT_FILE_PATH)"
fi
echo -en "\033[$((start_line + 1));$((start_col + 1))H${error_msg:0:$((max_width-2))}"
elif [[ ${#header_row[@]} -gt 0 && $data_display_count -eq 0 ]]; then
local no_data_msg="Info: Header found, but no data rows."
echo -en "\033[$((start_line + 2));$((start_col + 1))H${no_data_msg:0:$((max_width-2))}"
fi
}
# --- Terminal Control Functions ---
hide_cursor() {
echo -en "\033[?25l"
}
show_cursor() {
echo -en "\033[?25h"
}
alt_screen() {
echo -en "\033[?1049h"
}
normal_screen() {
echo -en "\033[?1049l"
}
clear_screen() {
echo -en "\033[2J"
}
# --- Global Variables ---
SCRIPT_NAME="DataViewer"
VERSION="0.1.0"
CONFIG_REFRESH_INTERVAL_SECONDS=5 # Default refresh interval in seconds
INPUT_FILE_PATH=""
declare -a header_row # Will store the header fields
declare -a parsed_data_rows # Will store namerefs to actual data row arrays
# --- Data Parsing Functions ---
# Usage: parse_csv_tsv <file_path> <delimiter>
parse_csv_tsv() {
local file_path="$1"
local delimiter="$2"
local line_num=0
# Reset global data arrays
header_row=()
parsed_data_rows=()
if [[ ! -f "$file_path" ]]; then
# This message will be shown by display_data
return 1
fi
# Read the header line
IFS="$delimiter" read -r -a header_row < <(head -n 1 "$file_path")
local temp_row_idx=0
# Use process substitution and tail correctly
# Ensure to handle files with only a header or files where data lines might not end with a newline
while IFS="$delimiter" read -r -a line_fields || { [[ -n "${line_fields[*]}" ]] && (( ${#line_fields[@]} > 0 )); }; do
# Skip empty lines that might be read by `read` if not careful
[[ -z "${line_fields[*]}" ]] && continue
local row_var_name="data_row_${temp_row_idx}"
# Declare the array dynamically and assign fields to it
declare -g "$row_var_name=()"
for field in "${line_fields[@]}"; do
# Ensure fields containing only spaces are preserved if necessary, by quoting.
# Using printf to escape quotes within the field itself if necessary for the declare statement.
printf -v safe_field "%q" "$field"
declare -g -a "$row_var_name+=($safe_field)"
done
parsed_data_rows+=("$row_var_name")
((temp_row_idx++))
done < <(tail -n +2 "$file_path") # Start from the second line for data
if [[ $(wc -l < "$file_path") -eq 1 && ${#header_row[@]} -gt 0 && ${#parsed_data_rows[@]} -eq 0 ]]; then
: # Current behavior: single line is header.
fi
return 0
}
# Usage: parse_json <file_path>
parse_json() {
local file_path="$1"
# Reset global data arrays
header_row=()
parsed_data_rows=()
if [[ ! -f "$file_path" ]]; then
# This message will be displayed by display_data if header_row remains empty
header_row=("Error: File not found ($file_path)")
return 1
fi
if ! command -v jq &> /dev/null; then
header_row=("Error: jq is required for JSON.")
return 1
fi
# Check for completely empty file or just whitespace
if [[ ! -s "$file_path" ]] || [[ -z "$(jq . "$file_path" 2>/dev/null)" ]]; then
header_row=("Error: JSON file is empty or invalid.")
return 1
fi
# Check if it's an empty array specifically
if [[ "$(jq 'if type == "array" and length == 0 then "empty_array" else "not_empty" end' -r "$file_path")" == "empty_array" ]]; then
header_row=("Info: JSON file is an empty array [].")
return 0 # Successfully parsed an empty array
fi
# Attempt to parse JSON as an array of objects
# Extract headers from the keys of the first object
mapfile -t header_row < <(jq -r 'if type == "array" and length > 0 and .[0] != null and .[0] != "null" then .[0] | keys_unsorted[] else empty end' "$file_path")
if ((${#header_row[@]} == 0)); then
# Attempt to parse as a simple object (key-value pairs)
mapfile -t header_row < <(jq -r 'if type == "object" then keys_unsorted[] else empty end' "$file_path")
if ((${#header_row[@]} > 0)); then
local temp_row_idx=0
local row_var_name="data_row_${temp_row_idx}"
# Correctly initialize the array before appending
declare -g "$row_var_name=()"
for key in "${header_row[@]}"; do
local value
# Use printf for safer value assignment, then read into var
value_str=$(jq -r --arg k "$key" '.[$k]' "$file_path")
declare -g -a "$row_var_name+=($(printf %q "$value_str"))"
done
parsed_data_rows+=("$row_var_name")
else
header_row=("Error: Unsupported JSON structure or empty object.")
return 1
fi
else
# Process as an array of objects
local temp_row_idx=0
local num_objects
num_objects=$(jq '. | length' "$file_path")
for ((i=0; i<num_objects; i++)); do
local row_var_name="data_row_${temp_row_idx}"
declare -g "$row_var_name=()" # Initialize row array
for key in "${header_row[@]}"; do
local value
value_str=$(jq -r --arg k "$key" ".[$i] | .[\$k]" "$file_path")
declare -g -a "$row_var_name+=($(printf %q "$value_str"))"
done
parsed_data_rows+=("$row_var_name")
((temp_row_idx++))
done
fi
return 0
}
# --- Main Function Placeholder ---
main() {
alt_screen
clear_screen
hide_cursor
trap 'cleanup' EXIT SIGINT SIGTERM
local box_line=1 box_col=1
# These will be dynamically set based on terminal size anyway
# local box_height=15 box_width=60
# Determine file type and parse data
if [[ -n "$INPUT_FILE_PATH" ]]; then
local file_ext="${INPUT_FILE_PATH##*.}"
if [[ ! -f "$INPUT_FILE_PATH" ]]; then
# File not found at the start, set header_row to an error message
# This allows display_data to show it.
header_row=("Error: File not found ($INPUT_FILE_PATH)")
parsed_data_rows=()
elif [[ "$file_ext" == "csv" ]]; then
parse_csv_tsv "$INPUT_FILE_PATH" ","
elif [[ "$file_ext" == "tsv" ]]; then
parse_csv_tsv "$INPUT_FILE_PATH" $'\t' # Tab delimiter
elif [[ "$file_ext" == "json" ]]; then
parse_json "$INPUT_FILE_PATH"
else
header_row=("Error: Unknown file type ($file_ext)")
parsed_data_rows=()
fi
else
# No input file path given
header_row=("No input file specified.")
parsed_data_rows=()
fi
# Main loop
while true; do
local term_height term_width
if ! read -r term_height term_width < <(stty size); then
term_height=24
term_width=80
# echo "Failed to get terminal size, defaulting to ${term_height}x${term_width}" >&2 # Debug
fi
local current_box_height=$((term_height - 1)) # Leave 1 line for status/quit message
local current_box_width=$((term_width))
if ((current_box_height < 5)); then current_box_height=5; fi
if ((current_box_width < 20)); then current_box_width=20; fi
clear_screen
local current_title="${INPUT_FILE_PATH:-Data Viewer}"
if [[ "${header_row[0]}" == "Error:"* || "${header_row[0]}" == "No input file specified." ]]; then
current_title="${header_row[0]}" # Show error as title if parsing failed early
elif [[ "${header_row[0]}" == "Info:"* ]]; then # Also show Info messages in title
current_title="${header_row[0]}"
fi
draw_box "$box_line" "$box_col" "$current_box_height" "$current_box_width" "$current_title"
display_data "$box_line" "$box_col" "$current_box_width" "$current_box_height"
# Status/Quit message at the bottom of the terminal
echo -en "\033[$((term_height));1H" # Move to last line, first column
printf "%-${term_width}s" "Press 'q' to quit. (r)efresh. Interval: ${CONFIG_REFRESH_INTERVAL_SECONDS}s" # Clear line and print
read -rsn1 -t "${CONFIG_REFRESH_INTERVAL_SECONDS}" key
key=${key,,} # to lower case
if [[ "$key" == "q" ]]; then
break
elif [[ "$key" == "r" ]]; then
# Re-evaluate what to parse or show on refresh
if [[ -n "$INPUT_FILE_PATH" ]]; then
local file_ext="${INPUT_FILE_PATH##*.}"
if [[ ! -f "$INPUT_FILE_PATH" ]]; then
header_row=("Error: File not found ($INPUT_FILE_PATH)")
parsed_data_rows=()
elif [[ "$file_ext" == "csv" ]]; then
parse_csv_tsv "$INPUT_FILE_PATH" ","
elif [[ "$file_ext" == "tsv" ]]; then
parse_csv_tsv "$INPUT_FILE_PATH" $'\t'
elif [[ "$file_ext" == "json" ]]; then
parse_json "$INPUT_FILE_PATH"
else
header_row=("Error: Unknown file type ($file_ext)")
parsed_data_rows=()
fi
else
header_row=("No input file specified.")
parsed_data_rows=()
fi
fi
done
}
# --- Cleanup Function ---
cleanup() {
show_cursor
normal_screen
echo "Data Viewer Exiting."
}
# --- Script Entry Point ---
if [[ -n "$1" ]]; then
INPUT_FILE_PATH="$1"
# Sanitize INPUT_FILE_PATH? Expand ~? Resolve relative paths?
# For now, assume it's usable as is.
fi
if [[ -n "$2" ]]; then
if [[ "$2" =~ ^[0-9]+$ && "$2" -gt 0 ]]; then
CONFIG_REFRESH_INTERVAL_SECONDS="$2"
else
# Silently use default if invalid, or echo to stderr if not in alt screen mode yet.
# Since we go to alt screen immediately, user won't see this.
# echo "Warning: Invalid refresh interval '$2'. Using default ${CONFIG_REFRESH_INTERVAL_SECONDS}s." >&2
: # Use default
fi
fi
# Removed echo statements that would interfere with TUI.
# Any initial messages should be handled after alt_screen if really needed,
# or passed to main/display_data.
main