Created
September 21, 2022 06:57
-
-
Save extratone/d94a3ecdce0db69acf17fd6e10bffbc7 to your computer and use it in GitHub Desktop.
Brett Terpstra's qq.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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 "$@" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment