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 "$@"