qq

Updated 09212022-015828



#!/bin/bash
QQ_VERSION=1.2.8
# Usage:
# Ask a question using natural language
# $ qq [search terms]
#
# Search using @tags
# $ qq awk replace @unix
#
# Search including macOS tags
# $ qq '#awk #unix' replace
#
# Add a new question answer pair
# $ qq -a "This is the question" "This is the answer"
#
# Add a new question using clipboard as answer
# $ qq -p "This is the question"
#
# If no arguments are provided with -a or -p, input will be
# requested interactively. If used with -e, answer will be
# opened in editor.
#
# To edit an existing answer or use the editor to answer a
# new question, add the `-e` flag. If used alone, the first
# matching answer will be opened in your editor.
#
# CONFIGURATION
#
# Configuration is done via environment variables:
#
#   QQ_NOTES_DIR - Path to Markdown files
#   QQ_NOTES_EXT - Extension of answer files (default md)
#   QQ_NOTES_PRE - Prefix of question files (default ??)
#   QQ_EDITOR    - Text editor to use (default $EDITOR)
#   QQ_USE_FZF   - true or false to force use of fzf for menus
#   QQ_USE_GUM   - true or false to force use of gum for inputs
#
#  Example:
#  export QQ_NOTES_DIR="/Users/ttscoff/Dropbox/Notes"
#  export QQ_NOTES_EXT="md"
#
: ${QQ_NOTES_DIR:="$HOME/Dropbox/Notes/nvALT2.2"}
: ${QQ_NOTES_EXT:="md"}
: ${QQ_NOTES_PRE:="??"}
: ${QQ_EDITOR:=$EDITOR}
_USE_FZF=false
which fzf &>/dev/null
[[ $? == 0 ]] && _USE_FZF=true
: ${QQ_USE_FZF:=$_USE_FZF}

_USE_GUM=false
which gum &>/dev/null
[[ $? == 0 ]] && _USE_GUM=true
: ${QQ_USE_GUM:=$_USE_GUM}

_QQ_DEBUG=false

__qq () {
  ### CONFIG
  # notes folder, for note creation and limiting searches
  local NOTESDIR=$QQ_NOTES_DIR
  # extension used for your notes
  local NOTESEXT=$QQ_NOTES_EXT
  # the prefix you use to separate "Question" notes
  local NOTESPRE=$QQ_NOTES_PRE
  # editor command to use for modifying answers
  local QQEDITOR=$QQ_EDITOR

  NOTESDIR="${NOTESDIR%/}/"

  # Exlude file names containing these phrases, separated by colons
  local EXCLUDENAMES="what was I doing"

  #### END CONFIG
  local INPUT QQQUERY HAS_OPENED_URL HAS_COPIED_TEXT NOTESPREESC QUESTION ANSWER appname url
  local EXCLUDEQQQUERY=$(__qq_query_exclude_all "$EXCLUDENAMES")

  local EDITING=false
  local ADDING=false
  local PASTING=false
  local HELPING=false
  local DEBUG=false

  OPTIND=1

  while getopts "acdeh?lpv" opt; do
    case $opt in
      a)
        ADDING=true
        ;;
      c)
        __qq_config
        HELPING=true
        ;;
      d)
        export _QQ_DEBUG=true
        ;;
      e)
        EDITING=true
        ;;
      h|\?)
        __qq_help
        HELPING=true
        ;;
      l)
        __qq_list_all
        return 0
        ;;
      p)
        ADDING=true
        PASTING=true
        ;;
      v)
        echo "QuickQuestion v$QQ_VERSION"
        return
        ;;
    esac
  done

  shift $((OPTIND-1))
  [ "${1:-}" = "--" ] && shift

  if $HELPING; then
    return
  fi

  HAS_COPIED_TEXT=false
  HAS_OPENED_URL=false

  if $ADDING; then
    if [ $# == 2 ]; then
      QUESTION=$1
      ANSWER=$2
    elif [ $# -le 1 ]; then
      if [ $# == 1 ]; then
        QUESTION=$1
        echo "Question: $QUESTION"
      else
        if $QQ_USE_GUM; then
          QUESTION=$(gum input --placeholder "Enter your question")
        else
          echo -n "Question: "
          read QUESTION
        fi
      fi

      if [[ -z "$QUESTION" ]]; then
        echo "No question asked"
        exit 1
      fi

      if $PASTING; then
        ANSWER=$(pbpaste)
      elif [[ "$EDITING" == "false" ]]; then
        if $QQ_USE_GUM; then
          ANSWER=$(gum write --placeholder "So, ${QUESTION}?" --width $(tput cols) --char-limit 0)
        else
          echo "Answer (Ctrl-D to end):"
          ANSWER=$(cat)
        fi
      fi

      if [[ -z "$ANSWER" ]]; then
        echo "No answer given"
        exit 1
      fi
    else
      echo "Invalid number of arguments for -a(dd). Requires question and answer (or no arguments to input them at runtime)."
      echo "example: ${0##*/} -a \"What is the meaning of life?\" \"42\""
      return 1
    fi
    local QQFILE="${NOTESDIR}$NOTESPRE $QUESTION.$NOTESEXT"

    if $EDITING; then
      echo -n "$ANSWER" >> "$QQFILE"
      $QQEDITOR "$QQFILE"
    else
      echo -n "$ANSWER" >> "$QQFILE" && echo "Question added and answered." || echo "Something went wrong"
    fi
  else
    if [[ $# == 0 ]]; then
      __qq_help
      return
    fi
    local QQORIGINALQUERY="$*"
    local QQINPUTQUERY=$(__qq_query_include_all "${*%\?}")
    __qq_debug "Attempting to find ALL options: ${QQINPUTQUERY}"
    QQQUERY="mdfind -onlyin '$NOTESDIR' -interpret '(kind:text OR kind:markdown) AND filename:$NOTESEXT AND filename:$NOTESPRE ${QQINPUTQUERY}${EXCLUDEQQQUERY}'"
    local RESULTS=$(eval $QQQUERY 2> /dev/null)

    if [[ "$RESULTS" == "" ]]; then
      QQINPUTQUERY=$(__qq_query_include_all OR "${*%\?}")
      __qq_debug "No luck, looser search: ${QQINPUTQUERY}"
      QQQUERY="mdfind -onlyin '$NOTESDIR' -interpret '(kind:text OR kind:markdown) AND filename:$NOTESEXT AND filename:$NOTESPRE ${QQINPUTQUERY}${EXCLUDEQQQUERY}'"
      RESULTS=$(eval $QQQUERY 2> /dev/null)
    fi

    if [[ "$RESULTS" == "" ]]; then

      if $QQ_USE_FZF; then
        QQQUERY='ls "${NOTESDIR}??"*.$NOTESEXT|fzf -i --tiebreak=length,begin -f "$(__qq_remove_stopwords "$*")"'
        __qq_debug "We have fzf, trying: ${QQQUERY}"
        RESULTS=$(eval $QQQUERY 2> /dev/null)
        RESULTS=$(echo "$RESULTS" | head -n 1)
      fi
    fi

    if [[ "$RESULTS" == "" ]]; then
      __qq_debug "Well, jeeze, I guess we'll try grepping for the question with the most matching words"
      declare -a WORDS=( $* )
      local MAX=${#WORDS}
      local RX=$(__qq_query_regex "$*")
      for i in $(seq $MAX 2); do
        RESULTS=$(ls "${NOTESDIR}??"*.$NOTESEXT|grep -iE "${RX}{$i}")
        if [[ "$RESULTS" != "" ]]; then
          __qq_debug "Ooh, found a match containing $i of the words: ${RX}{$i}"
          break
        fi
      done
    fi

    if [[ "$RESULTS" == "" ]]; then
      echo "$(__qc red)Sorry, I don't know the answer to that question.$(__qc reset)"
      return 2
    else
      TOTAL_RESULTS=$(echo -ne "$RESULTS" | wc -l)
      # Sort results by length, assuming shortest result is best match
      declare -a PRETTY_RESULTS=()

      while IFS= read -r result; do
        local NOTESPREESC=`echo "$NOTESPRE"|sed -E 's/([\?\!\$\`\"]) ?/\\\\\1/g'`

        local STRIPPED=$(basename "$result" ".$NOTESEXT" | sed -E "s/^$NOTESPREESC *//")
        PRETTY_RESULTS+=( "$STRIPPED" )
      done < <(printf '%s\n' "$RESULTS")

      RESULTS=$(printf '%s\n' "${PRETTY_RESULTS[@]}" | awk '{ print length, $0 }' | sort -n -s | cut -d" " -f2-)

      if $QQ_USE_FZF; then
        QUESTION=$(echo -e "$RESULTS"|fzf -i --prompt="Select a question > " -1 -q "$QQORIGINALQUERY")
        RESULTS=""
      else
        QUESTION=$(echo -e "$RESULTS" | head -n 1)
        RESULTS=$(echo -e "$RESULTS" | sed '1d' | head -n 5)
      fi


      if [[ "$QUESTION" =~ ^$ ]]; then
        echo "$(__qc red)Sorry, I don't know the answer to that question.$(__qc reset)"
        return 1;
      fi

      local ANSWER_FILE="${NOTESDIR}${NOTESPRE} ${QUESTION}.${NOTESEXT}"

      if $EDITING; then
        $QQEDITOR "$ANSWER_FILE"
        return
      fi

      # QUESTION=`basename "$ANSWER" ".$NOTESEXT"`
      echo -n "$(__qc yellow)Q: $(__qc white)"
      echo "$QUESTION"|sed -E 's/([^\?])$/\1?/'
      echo -n "$(__qc yellow)A: $(__qc white)"
      cat "$ANSWER_FILE"|sed -E 's/@\([^\)]+\) ?//g'|sed -E 's/@copy\((.+)\)/\1/'|sed -E 's/@open\(([^\)+]*)\)/Related URL: \1/'|sed -E 's/@[^\( ]+ ?//g' # |sed -E 's/^[   ]*|[  ]*$//g'
      if [[ `cat "$ANSWER_FILE"|grep -E '@copy\('` && $HAS_COPIED_TEXT == false ]]; then
        cat "$ANSWER_FILE"|grep '@copy('|sed -E 's/.*@copy\((.+)\).*/\1/'|tr -d '\n'|pbcopy
        echo -e "\n$(__qc green)Example in clipboard"
        HAS_COPIED_TEXT=true
      fi

      if [[ `cat "$ANSWER_FILE"|grep -E '@open\('` && $HAS_OPENED_URL == false ]]; then
        url=$(cat "$ANSWER_FILE"|grep '@open('|sed -E 's/.*@open\(([^\)]+)\).*/\1 /'|tr -d '\n')
        open -g $url
        echo -e "\n$(__qc green)Opened URL"
        HAS_OPENED_URL=true
      fi

      if [[ "$RESULTS" != "" ]]; then
        echo "$(__qc gray)----------------------"
        echo "$(__qc yellow)Other results included:"
        echo -e "$(__qc cyan)$RESULTS"
      fi

      __qq_debug "\n$(__qc green)$TOTAL_RESULTS $(__qc white)total results"

      __qc reset
    fi
  fi
  return 0
}

__qq_esc () {
  echo "$*"|sed 's/"/\\\"/g'|sed 's/#/tag:/g'
}

__qq_rx_esc () {
  ruby -e 'puts Regexp.escape(ARGV.join(" "))' $*
}

__qq_remove_stopwords () {
  local input=$1
  declare -a STOPWORDS=( what which is can how do my where when why that the was who this i a as if up out in )
  for word in ${STOPWORDS[@]}; do
    input=$(echo "$input"|sed -E "s/(^| )$word([\.\,\? ]|$)/\1/ig")
  done
  __qq_debug "Cleaned stop words: ${input}"
  echo -n "$input"
}

__qq_query_include_all () {
  local bool=" AND "
  if [[ $1 == "OR" ]]; then
    bool=" "
    shift
  fi
  if [[ "$*" != "" ]]; then
    local input=$(__qq_remove_stopwords "$*")

    declare -a query_array=( $input )
    local query=" AND ("
    for i in ${query_array[@]}; do
        query="${query}`__qq_esc $i`$bool"
    done
    echo -n "$query"|sed -e 's/ AND $//' -e 's/ OR $//' -e 's/ +/ /g' -e 's/ *$/)/'
  fi
}

__qq_query_regex () {
  if [[ "$*" != "" ]]; then
    declare -a query_array=( $* )
    local query="(.*("
    for i in ${query_array[@]}; do
      local stripped=$(echo "$i" | sed -E 's/[^A-Z0-9 ]/.?/gi')
      query="${query}${stripped}|"
    done
    echo -n "$query"|sed -e 's/|$//' -e 's/$/).*)/'
  fi
}

__qq_query_exclude_all () {
  local input="$1"
  local OLDIFS=$IFS
  IFS=":"
  set $input
  declare -a query_array=( "$@" )
  local query=' NOT ('
  for i in ${query_array[@]}; do
      query="${query}filename:\"`__qq_esc $i`\" OR "
  done
  echo -n "$query"|sed 's/ OR $/)/'
  IFS=$OLDIFS
}

__qq_list_all () {
  local QQQUERY="mdfind -onlyin '$NOTESDIR' -interpret '(kind:text OR kind:markdown) AND filename:$NOTESEXT AND filename:$NOTESPRE ${EXCLUDEQQQUERY}'"
  local NOTESPREESC=`echo "$NOTESPRE"|sed -E 's/([\?\!\$\`\"]) ?/\\\\\1/g'`
  RESULTS=$(eval $QQQUERY 2> /dev/null)
  echo "$(__qc green)Questions I have answers to...$(__qc white)"
  echo -e "$RESULTS" | while read LINE; do
    if [[ "$LINE" =~ ^$ ]]; then
      echo "$(__qc red)Sorry, no answers found.$(__qc reset)"
      return 1;
    fi
    QUESTION=`basename "$LINE" ".$NOTESEXT"`
    echo "$QUESTION"|sed -E "s/$NOTESPREESC ?//g"|sed -E 's/([^\?])$/\1?/'
  done
  __qc reset
}

__qq_help () {
  appname=`basename $0`
  echo "$(__qc white)QuickQuestion$(__qc reset) - build a knowledgebase with plain text files"
  echo "$(__qc green)Usage: $(__qc yellow)$appname $(__qc white)\"terms to search for\"$(__qc reset)"
  echo
  echo "$(__qc green)Options:$(__qc reset)"
  echo "   $(__qc white)-a$(__qc reset) [QUESTION $(__qc gray)[ANSWER]$(__qc reset)]  Add a question/answer"
  echo "   $(__qc white)  $(__qc reset)                      No arguments triggers interactive add"
  echo "   $(__qc white)-l$(__qc reset)                      List all known questions"
  echo "   $(__qc white)-p$(__qc reset) [QUESTION]           Add a question using the clipboard as answer"
  echo "   $(__qc white)-e$(__qc reset)                      Open editor with first result"
  echo "   $(__qc white)-h$(__qc reset)                      Show this help"
  echo "   $(__qc white)-c$(__qc reset)                      Display configuration"
  echo
  echo "Add question/answer: $(__qc yellow)$appname $(__qc white)-a$(__qc reset) \"Question in natural language\" \"Succinct answer\""
  echo "  Add interactively: $(__qc yellow)$appname $(__qc white)-a$(__qc reset)"
  echo " Add from clipboard: $(__qc yellow)$appname $(__qc white)-p$(__qc reset) [\"Optional Question\"]"
  echo "     Edit an answer: $(__qc yellow)$appname $(__qc white)-e$(__qc reset) \"terms to search for\" # first question found is edited"
  echo
}

__qq_config () {
  echo "$(__qc green)QuickQuestion Settings:$(__qc reset)"
  echo
  echo "$(__qc yellow)QQ_NOTES_DIR: $(__qc white)${QQ_NOTES_DIR}$(__qc reset)"
  echo "$(__qc yellow)QQ_NOTES_EXT: $(__qc white)${QQ_NOTES_EXT}$(__qc reset)"
  echo "$(__qc yellow)QQ_NOTES_PRE: $(__qc white)${QQ_NOTES_PRE}$(__qc reset)"
  echo "$(__qc yellow)   QQ_EDITOR: $(__qc white)${QQ_EDITOR}$(__qc reset)"
  echo "$(__qc yellow)  QQ_USE_FZF: $(__qc white)${QQ_USE_FZF}$(__qc reset)"
  echo "$(__qc yellow)  QQ_USE_GUM: $(__qc white)${QQ_USE_GUM}$(__qc reset)"
  echo
}

__qc () {
  local COLOR
  case $1 in
    gray)
      COLOR="\033[1;30m"
      ;;
    green)
      COLOR="\033[0;32m"
      ;;
    reset)
      COLOR="\033[0;39m"
      ;;
    cyan)
      COLOR="\033[0;36m"
      ;;
    white)
      COLOR="\033[1;37m"
      ;;
    yellow)
      COLOR="\033[0;33m"
      ;;
    red)
      COLOR="\033[0;31m"
      ;;
  esac
  echo -en $COLOR
}

__qq_debug () {
  if $_QQ_DEBUG; then
    echo -e "$@" >&2
  fi
}

__qq "$@"