#!/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 [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 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 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 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 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&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