#!/usr/bin/env bash
:<<'__comments__'
    Name: nb
   Usage: See show_usage()
Synopsis: Print or display records from a text file.
   Notes: This utility was inspired by
         <https://www.linux.com/news/cli-magic-command-line-contact-management>
  Author: G. D. LaBossiere, Xview Solutions Inc. <code@XviewSolutions.com>
 Version: 1.0.0 <http://semver.org>
 Created: 2019-05-07 (version 1.0.0)
Modified: 2019-05-16
 License: GNU GPL v3 <http://www.gnu.org/licenses/gpl-3.0.txt>
__comments__

#------------------------------------------------------------------------------
#  constants
#------------------------------------------------------------------------------
CONFIG="${HOME}/.notabene"
EDITOR="${EDITOR}"
FILE="${HOME}/notabene.txt"
PAGER="${PAGER}"
RS="^^^"
#------------------------------------------------------------------------------
#  error codes
#------------------------------------------------------------------------------
ERROR_10="Requires bash 4.2+"
ERROR_15="Missing required program(s)"
ERROR_16="Missing required function(s)"
ERROR_18="Bad or missing required parameter(s)"
ERROR_20="Insufficient user privilege"
ERROR_34="Bad or missing filename"
ERROR_35="Bad file content, format, syntax or type"
ERROR_37="Cannot write to file"
#------------------------------------------------------------------------------
#  variables
#------------------------------------------------------------------------------
appMode=""
argList=()
errFuncName=""
exitMessage=""
findStrings=""
recordFile=""
recordMarker=""
ttyRows=""
ttyCols=""
#------------------------------------------------------------------------------
#  functions
#------------------------------------------------------------------------------
:<<'__comments__'
     Name: all_done
 Synopsis: Delete $recordFile; if attached to a tty and $? is non-zero, display
           a message; exit with value $?; the first statement in main() must be
           'trap all_done EXIT'
Arguments: ---
 Requires: ---
  Returns: exit $?
    Calls: ---
__comments__
all_done() {
  local errNum="$?"
  local errTxt="ERROR_${errNum}"
  local errMsg="Error ${errNum}: No description available"
  [[ ${!errTxt} ]] && errMsg="Error ${errNum}: ${!errTxt}"
  [[ ${errFuncName} ]] && errMsg="${errFuncName}: ${errMsg}"
  errMsg="${0##*/}: ${errMsg}"
  [[ -t 1 && $errNum -ne 0 ]] && {
    [[ $exitMessage ]] && printf "%b\n" "${exitMessage}"
    printf "%s\n" "${errMsg}"
  }
  [[ -f $recordFile ]] && rm "$recordFile" 2>/dev/null
}
#------------------------------------------------------------------------------
:<<'__comments__'
     Name: check_bash
 Synopsis: Check for supported bash versions.
Arguments: ---
 Requires: bash 4.2+
  Returns: 0 on success; $errFuncName and exit 10 on error.
    Calls: ---
__comments__
check_bash() {
  errFuncName="$FUNCNAME"
  [[ ${BASH_VERSINFO[0]} -eq 4 && ${BASH_VERSINFO[1]} -ge 2 ]] || \
    [[ ${BASH_VERSINFO[0]} -gt 4 ]] || exit 10
  return 0
}
#------------------------------------------------------------------------------
:<<'__comments__'
     Name: check_params
 Synopsis: Verify all required parameters.
Arguments: ---
 Requires: file
  Returns: 'exec $EDITOR $FILE' or 0 on success; $errFuncName and exit 15, 18,
           20, 34 or 37 on error.
    Calls: ---
__comments__
check_params() {
  errFuncName="$FUNCNAME"
  type -p file &>/dev/null || exit 15
  [[ $appMode =~ ^(edit)$|^(list)$|^(show)$ ]] || {
    exitMessage="Valid mode values are: 'edit' or 'list' or 'show'"
    exit 18
  }
  [[ $appMode =~ ^(edit)$ ]] && {
    [[ -f $FILE ]] || {
      exitMessage="No FILE to edit"
      exit 34
    }
    [[ -w $FILE ]] || {
      exitMessage="FILE is not writable"
      exit 37
    }
    [[ "$(file $FILE)" =~ ' text'$ ]] || {
      exitMessage="'$FILE' is not a plain text file"
      exit 35
    }
    EDITOR=$(type -p $EDITOR)
    [[ -x $EDITOR ]] || EDITOR=$(type -p vim)
    [[ -x $EDITOR ]] || EDITOR=$(type -p vi)
    [[ -x $EDITOR ]] || {
      exitMessage="No valid text EDITOR installed"
      exit 15
    }
#  do NOT quote the args to exec
    exec $EDITOR $FILE
  }
  [[ $appMode =~ ^(list)$|^(show)$ ]] && {
    [[ -f $FILE ]] || {
      exitMessage="No FILE to search"
      exit 34
    }
    [[ -r $FILE ]] || {
      exitMessage="No read permission for FILE"
      exit 20
    }
    [[ "$(file $FILE)" =~ ' text'$ ]] || {
      exitMessage="'$FILE' is not a plain text file"
      exit 35
    }
  }
  [[ $appMode =~ ^(list)$ ]] && {
    [[ $recordMarker ]] || recordMarker="$RS"
  }
  [[ $appMode =~ ^(show)$ ]] && {
    PAGER=$(type -p $PAGER)
    [[ -x $PAGER ]] || PAGER=$(type -p less)
    [[ -x $PAGER ]] || PAGER=$(type -p more)
    [[ -x $PAGER ]] || {
      exitMessage="No valid PAGER is installed"
      exit 15
    }
    printf -v recordMarker "%${ttyCols}s" ""
    recordMarker=${recordMarker// /-}
  }
  return 0
}
#------------------------------------------------------------------------------
:<<'__comments__'
     Name: check_required
 Synopsis: Verify all required tools/utilities are available on this host.
Arguments: ---
 Requires: sed, sort, tr
  Returns: 0 on success; $errFuncName, $exitMessage and exit 15 on error.
    Calls: ---
__comments__
check_required() {
  errFuncName="$FUNCNAME"
  type -p sed sort tr &>/dev/null || exit 15
  local each missing required
#  explanation of sed commands:
#+ sed -e "s/#.*$//" -e "/^$/d" ==> exclude commented or blank lines
#+ sed -ne "/ type /p" ==> select only lines containing ' type '
#+ ${0} ==> this file
#+ sed -e "s/.*type \-*p*P*\(.*\).\+>.*/\1/" ==> extract strings between
#+ 'type -p' and '?>' (i.e. save the arguments passed to 'type' but exclude
#+ everything from and after the first char before a '>' redirection symbol)
#+ sed -e "s/\$.\+ //g" ==> delete any space-terminated strings that start with
#+ a '$' dollar symbol (i.e. unexpanded vars)
#+ sed -e "s/[^0-9a-zA-Z_ -]//g" ==> delete chars not allowed in program names
  required=($(sed -e "s/#.*$//" -e "/^$/d" -ne "/ type /p" "${0}" | \
    sed -e "s/.*type \-*p*P*\(.*\).\+>.*/\1/" -e "s/\$.\+ //g" \
    -e "s/[^0-9a-zA-Z_ -]//g"))
#  explanation of commands:
#+ printf "%s\n" "${required[@]}" ==> output each array element
#+ sort -u ==> remove duplicate names
#+ tr '\n' ' ' ==> replace newlines with spaces (i.e. convert to a string)
#+ tr -s ' ' ==> squeeze spaces (i.e. leave a single space between names)
  required="$(printf "%s\n" "${required[@]}" | sort -u | tr '\n' ' ' | \
    tr -s ' ' )"
  for each in ${required}; do
    type -p "${each}" &>/dev/null || missing="${missing} ${each}"
  done
  exitMessage="Required program(s) not found: ${missing}"
  [[ $missing ]] &&  exit 15
  return 0
}
#------------------------------------------------------------------------------
:<<'__comments__'
     Name: find_records
 Synopsis: Search $FILE for records containing $findStrings; display or list
           matching records.
Arguments: ---
 Requires: head, mktemp, sed, tr
  Returns: 0 on success; $errFuncName and exit 15 or 35 on error.
    Calls: ---
__comments__
find_records() {
  [[ $appMode =~ ^(list)$|^(show)$ ]] || return 0
  errFuncName="$FUNCNAME"
  type -p head mktemp sed tr &>/dev/null || exit 15
  local content found idx next record search string strings this
#  get the line number of the first record in FILE
  next=$(sed -n "/^\ *${RS}\ *$/!d;=" $FILE | head -n1)
  [[ $next ]] || {
    exitMessage="No record markers found in FILE"
    exit 35
  }
#  parse FILE only if there is some text to search for
  [[ $findStrings =~ [0-9a-zA-Z] ]] || unset findStrings
#  if there is text to search for we must still remove non-text search strings
  [[ $findStrings ]] && {
    readarray -t search < <(printf '%b' "$findStrings")
    for ((idx=0;idx<${#search[@]};idx++)); do
      [[ ${search[$idx]} =~ ^([[:space:][:punct:]]){1,}$ ]] && \
        unset search[$idx]
      [[ ${search[$idx]} ]] && strings=(${strings[@]} ${search[$idx]})
    done
  }
#  if there is text to search for, loop thru FILE examining every record
  [[ $strings ]] && {
    while [[ $next ]]; do
      this="$next"
#  get the line number of the next record, unless this is the last one
      next=$(sed -n "1,${this}d;/^\ *${RS}\ *$/!d;=" $FILE | head -n1)
      [[ $next ]] && \
        record=$(sed -n "$((${this}+1)),$((${next}-1))p" $FILE) || \
        record=$(sed -n "$((${this}+1)),\$p" $FILE)
#  remove control chars, squeeze spaces, convert to lower case
      content=$(printf '%b' "$record" | tr '[:cntrl:]' ' ')
      content=$(printf '%b' "${content,,}" | tr -s ' ')
      found=0
#  look for each element of the array (including any empty elements) within the
#+ current record's content
      for ((idx=0;idx<${#strings[@]};idx++)); do
        string=${strings[${idx}]}
#  squeeze spaces and convert to lower case before comparing string to content
        string=$(printf '%b' "${string,,}" | tr -s ' ')
#  this will also return true on an empty array element
        [[ $content =~ $string ]] && ((++found))
      done
#  the current record matches only if it contains ALL the array elements
      [[ $found -eq ${#strings[@]} ]] && {
        [[ -w $recordFile ]] || recordFile=$(mktemp -t ${0##*/}.XXXXXX)
        printf '%s\n%s\n' "$recordMarker" "$record" >> $recordFile
      }
    done
    [[ -w $recordFile ]] && printf '%s\n' "$recordMarker" >> $recordFile
  }
  [[ $recordFile ]] || {
    [[ -t 1 ]] && printf '%s\n' "No search results"
    return 0
  }
  [[ $appMode =~ ^(list)$ ]] && printf '%b\n' "$(<$recordFile)"
  [[ $appMode =~ ^(show)$ ]] && {
    [[ -t 1 ]] && $PAGER "$recordFile" || printf '%b\n' "$(<$recordFile)"
  }
  return 0
}
#------------------------------------------------------------------------------
:<<'__comments__'
     Name: parse_command_line
 Synopsis: Parse the command line arguments.
Arguments: See show_usage()
 Requires: ---
  Returns: 0 in all cases.
    Calls: show_usage
__comments__
parse_command_line() {
  errFuncName="$FUNCNAME"
  local arg=0 args=("${argList[@]}")
  while [[ $arg -lt ${#args[@]} ]]; do
    case "${args[$arg]}" in
      -d|--delimiter)
        [[ ${args[(($arg+1))]} =~ ^[^-]{1,} ]] && \
          recordMarker="${args[(($arg+1))]}" && unset args[$(($arg+1))]
        ((++arg))
      ;;
      -e|--editor)
        [[ ${args[(($arg+1))]} =~ ^[^-]{1,} ]] && \
          EDITOR="${args[(($arg+1))]}" && unset args[$(($arg+1))]
        ((++arg))
      ;;
      -f|--file)
        [[ ${args[(($arg+1))]} =~ ^[^-]{1,} ]] && \
          FILE="${args[(($arg+1))]}" && unset args[$(($arg+1))]
        ((++arg))
      ;;
      -p|--pager)
        [[ ${args[(($arg+1))]} =~ ^[^-]{1,} ]] && \
          PAGER="${args[(($arg+1))]}" && unset args[$(($arg+1))]
        ((++arg))
      ;;
      -*)
        show_usage
      ;;
      *)
        [[ $appMode =~ ^(list)$|^(show)$ ]] && \
          findStrings="${findStrings}\n${args[$arg]}"
        [[ $appMode ]] || appMode="${args[$arg],,}"
        ((++arg))
      ;;
    esac
  done
  return 0
}
#------------------------------------------------------------------------------
:<<'__comments__'
     Name: set_config
 Synopsis: Override defaults if alternate constant values are set in $CONFIG;
           get tty size.
Arguments: ---
 Requires: cut, sed, stty
  Returns: 0 on success; $errFuncName and exit 15 on error,
    Calls: ---
__comments__
set_config() {
  [[ -r $CONFIG ]] && {
    errFuncName="$FUNCNAME"
    type -p cut sed stty &>/dev/null || exit 15
    local const term=() var
    for var in EDITOR FILE PAGER RS; do
#  extract 1st non-commented occurence of a var assignment
      const="$(sed -n "/^.*[^#]*${var}=/p; /${var}=/q;" "${CONFIG}")"
      [[ $const =~ '=' ]] && {
#  trim any trailing comment
        const="$(printf "%s" "${const}" | cut -d'#' -f1)"
#  unquote for export
        const=${const//\"/}
        const=${const//\'/}
        export ${const}
      }
    done
  }
  [[ -t 1 ]] && {
    term=($(stty size))
    ttyRows=${term[0]}
    ttyCols=${term[1]}
  }
  return 0
}
#------------------------------------------------------------------------------
:<<'__comments__'
     Name: show_usage
 Synopsis: Display script usage before exiting if attached to a tty.
Arguments: ---
 Requires: ---
  Returns: Exit 0 in all cases.
    Calls: ---
__comments__
show_usage() {
  [[ -t 1 ]] || exit 0
  printf "%b" "
-------------------------------------------------------------------------------

Usage: ${0##*/} edit [-f FILE] [-e EDITOR]
       ${0##*/} list ['text string' 'text string' ...] [-d 'chars'] [-f FILE]
       ${0##*/} show ['text string' 'text string' ...] [-f FILE] [-p PAGER]

       Print or display records in FILE that contain at least one instance of
       each 'text string' passed on the command line. Records in FILE must be
       separated by a line containing only RS. Look for required values first
       on the command line, second in CONFIG='~/.notabene', third in the
       environment, and finally try built-in defaults. Any of the following may
       be set in CONFIG:
       EDITOR=''
       FILE=''  # default value: ~/notabene.txt
       PAGER=''
       RS=''  # default value: ^^^

Modes: edit
       Open FILE in EDITOR.

       list
       Print the search results to stdout.

       show
       Display the search results in PAGER.

Flags: -d, --delimiter 'chars'
       Separate the records printed to stdout with a line containing one or
       more 'chars'. Default value for 'chars' is the same value as RS (the
       record separator in FILE).

       -e, --editor EDITOR
       Open FILE in EDITOR. Default is vim or vi.

       -f, --file FILE
       Search for records in FILE, which must contain only ASCII or UTF-8 text.
       Default is ~/notabene.txt

       -p, --pager PAGER
       Use PAGER to display the search results. Default is less or more. The
       records displayed will be separated by a dashed line.

       Unrecognized flags cause this message to be displayed.

-------------------------------------------------------------------------------

"
  exit 0
}
#------------------------------------------------------------------------------
#  main
#------------------------------------------------------------------------------
main() {
  argList=("$@")
  trap all_done EXIT
  declare -F all_done check_bash check_params check_required find_records \
    parse_command_line set_config show_usage &>/dev/null || exit 16
  check_bash
  check_required
  set_config
  parse_command_line
  check_params
  find_records
  exit 0
}
#------------------------------------------------------------------------------
#  run program
#------------------------------------------------------------------------------
main "$@"

