#!/usr/bin/env bash # # Author: Hsieh Chin Fan (typebrook) # License: MIT # https://gist.github.com/typebrook/b0d2e7e67aa50298fdf8111ae7466b56 # # gist # Description: Host your gists as local cloned git repo # Usage: gist [command] [] # # [star | s] list your gists with format below, star for your starred gists: # [index_of_gist] [url] [file_num] [comment_num] [short description] # update, u [star | s] update the local list of your gists, star for your starred gists # show the path of local gist repo and do custom actions # new, n [-d | --desc ] ... create a new gist with files # new, n [-d | --desc ] [-f | --file ] create a new gist from STDIN # detail, d show the detail of a gist # edit, e edit a gist description # delete, D ... delete a gist # clean, C clean removed gists in local # config, c [token | user | folder | auto-sync | EDITOR | action [value] ] do configuration # user, U get gists from a given Github user # help, h show this help message # # Example: # gist (Show your gists) # gist 3 (show the repo path of your 3rd gist, and do custom actions) # # Since now a gist is a local cloned repo # It is your business to do git commit and git push # TODO grep mode for description, file content # TODO push github.com (may need new token) # TODO description for current directory # TODO unit test # TODO test on mac and remote machine # TODO completion # Shell configuration set -o pipefail [ "$TRACE" ] && set -x trap 'rm -f "$http_data" "tmp_file"' EXIT GITHUB_API=https://api.github.com CONFIG=~/.config/gist.conf; mkdir -p ~/.config configuredClient="" # handle configuration cases _configure() { local target="" [[ -z "$@" ]] && (${EDITOR:-vi} $CONFIG) && return 0 if [[ $1 == 'token' ]]; then [[ ${#2} -eq 40 ]] && target=$1=$2 \ || echo -e Invalid token format, it is not 40 chars '\n' > /dev/tty elif [[ $1 == 'auto_sync' ]]; then [[ $2 == 'false' ]] && target=$1=$2 \ || target=$1=true elif [[ $1 == 'folder' ]]; then [[ -n "$2" ]] && target=$1=$2 \ || target=$1=~/gist elif [[ $1 == 'user' ]]; then target=$1=$2 fi umask 0077 && touch $CONFIG [[ "$target" =~ [^=]$ ]] && sed -i "/^$1=/ d" $CONFIG && echo $target >> $CONFIG cat $CONFIG } # prompt for username _ask_username() { while [[ ! $user =~ ^[[:alnum:]]+$ ]]; do [[ -n $user ]] && echo "Not a valid username" read -p "Github username: " user < /dev/tty done _configure user $user } # prompt for toekn # TODO token check, ref: https://developer.github.com/v3/apps/oauth_applications/#check-a-token _ask_token() { echo -n "Create a new token from web browser? [Y/n] " read answer < /dev/tty if [[ ! $answer =~ ^(N|n|No|NO|no)$ ]]; then python -mwebbrowser https://github.com/settings/tokens/new\?scopes\=gist fi while [[ ! $token =~ ^[[:alnum:]]{40}$ ]]; do [[ -n $token ]] && echo "Not a valid token" trap 'echo; return 1' INT read -p "Paste your token here (Ctrl-C to skip): " token < /dev/tty done _configure token $token } _validate_config(){ source $CONFIG 2> /dev/null || true if [[ ! -e $CONFIG || -z $user ]]; then echo 'Hi fellow! To access your gists, I need your Github username' echo "Also a personal token with scope which allows "gist"!'" echo _ask_username _ask_token elif [[ -z $token && $1 =~ ^(n|new|e|edit|D|delete)$ ]]; then if ! (_ask_token); then echo 'To create/edit/delete a gist, a token is needed' return 1 fi elif [[ -z $token && $1 =~ ^(u|update)$ && $2 =~ ^(s|star) ]]; then if ! (_ask_token); then echo 'To get user starred gists, a token is needed' return 1 fi fi } # load configuration _apply_config() { _validate_config "$@" || return 1 AUTH_HEADER="Authorization: token $token" [[ -z "$action" ]] && action="${EDITOR:-vi} ." [[ -z "$folder" ]] && folder=~/gist && mkdir -p $folder INDEX=$folder/index } _apply_config "$@" || exit 1 # This function determines which http get tool the system has installed and returns an error if there isnt one getConfiguredClient() { if command -v curl &>/dev/null; then configuredClient="curl" elif command -v wget &>/dev/null; then configuredClient="wget" elif command -v http &>/dev/null; then configuredClient="httpie" elif command -v fetch &>/dev/null; then configuredClient="fetch" else echo "Error: This tool requires either curl, wget, httpie or fetch to be installed." >&2 return 1 fi } # Allows to call the users configured client without if statements everywhere http_method() { local METHOD=$1; shift case "$configuredClient" in curl) [[ -n $token ]] && local extra="--header" local header="Authorization: token $token" [[ $METHOD =~ (POST|PATCH) ]] && extra2="--data" curl -X $METHOD -A curl -s $extra "$header" $extra2 @$http_data "$@" ;; wget) [[ -n $token ]] && local extra="--header" local header="Authorization: token $token" [[ $METHOD =~ (POST|PATCH) ]] && extra2='--body-file' wget --method=$METHOD -qO- $extra "$header" $extra2 $http_data "$@" ;; httpie) [[ -n $token ]] && header="Authorization:token $token" [[ $METHOD =~ (POST|PATCH) ]] && extra2="@$http_data" http -b $METHOD "$@" "$header" $extra2 ;; # TODO add other methods fetch) fetch -q "$@" ;; esac } # Show the list of gist, but not updated time # TODO a way to show files # TODO show git status outdated _show_list() { if [[ ! -e $INDEX ]]; then echo 'No local file found for last update, please run command:' echo ' gist update' return 0 fi local filter='/^s/ d; /^$/ d' [[ $1 == "s" ]] && filter='/^[^s]/ d; /^$/ d' while read index link blob_code file_num extra author description; do [[ $1 == "s" ]] && local author=$author local repo=$folder/$(echo $link | sed 's#.*/##') local occupy=0 # if repo is not yet cloned, show green message "Not cloned yet" [[ ! -d $repo ]] && extra="\e[32m[Not cloned yet]\e[0m" && occupy=16 # if there are some changes in git index or working directory, show blue message "working" [[ -n $(cd $repo && git status --short) ]] 2>/dev/null && extra="\e[36m[working]\e[0m" && occupy=9 # if there is a commit not yet push, show red message "ahead" [[ -n $(cd $repo && git cherry) ]] 2>/dev/null && extra="\e[31m[ahead]\e[0m" && occupy=7 echo -e $index $link $author $file_num $extra $(echo $description | cut -c -$(( 60 -$occupy -1 )) ) done < $INDEX \ | sed "$filter" echo -e '\nrun "gist help" for more details' > /dev/null } # parse JSON from STDIN with string of commands AccessJsonElement() { PYTHONIOENCODING=utf-8 \ python -c "from __future__ import print_function; import sys, json; $1" return "$?" } # equal to: jq '.[] | "\(.html_url) \([.files[] | .raw_url]) \(.files | keys | length) \(.comments) \(.description)"' _handle_gists() { echo ' raw = json.load(sys.stdin) for gist in raw: print(gist["html_url"], end=" ") print([file["raw_url"] for file in gist["files"].values()], end=" ") print(gist["public"], end=" ") print(len(gist["files"]), end=" ") print(gist["comments"], end=" ") print(gist["owner"]["login"], end=" ") print(gist["description"]) ' } # TODO check if a user create a very first gist # parse response from gists require _parse_response() { AccessJsonElement "$(_handle_gists)" \ | tac | sed 's/, /,/g' | nl -s' ' \ | while read index link file_url_array public file_num comment_num author description; do local blob_code=$(echo $file_url_array | tr ',' '\n' | sed -E 's#.*raw/(.*)/.*#\1#' | sort | cut -c -7 | paste -sd '-') [[ $public == 'False' ]] && local mark=p [[ -n $1 ]] && local index=$1 echo $mark$index $link $blob_code $file_num $comment_num $author $description | tr -d '"' done } # TODO add author, files and date of a gist # get latest list of gists from Github API _update() { echo "fetching $user's gists from $GITHUB_API..." echo local route="users/$user/gists" local mark="" local filter='/^[^s]/ d; /^$/ d' if [[ "$1" =~ ^(star|s)$ ]];then route="gists/starred" mark="s" filter='/^[s]/ d; /^$/ d' fi result=$(http_method GET $GITHUB_API/$route | _parse_response) [[ -z $result ]] && echo Failed to update gists && return 1 sed -i "$filter" $INDEX && echo "$result" >> $INDEX _show_list $mark if [[ $auto_sync != "false" ]]; then (_sync_repos $1 > /dev/null 2>&1 &); fi } _query_user() { local route="users/$1/gists" result=$(http_method GET $GITHUB_API/$route | _parse_response) [[ -z $result ]] && echo Failed to update gists && return 1 echo "$result" \ | while read index link blob_code file_num extra description; do echo $link $file_num $extra $(echo $description | cut -c -70 ) done } # update local git repos _sync_repos() { # clone repos which are not in the local comm -13 <(find $folder -maxdepth 1 -type d | sed '1d; s#.*/##' | sort) \ <(cat $INDEX | cut -d' ' -f2 | sed 's#.*/##' | sort) \ | xargs -I{} --max-procs 8 git clone git@github.com:{}.git $folder/{} # pull if remote repo has different blob objects cat $INDEX | cut -d' ' -f2,3 \ | while read url blob_code_remote; do local repo=$folder/$(echo $url | sed 's#.*/##') local blob_code_local=$(cd $repo && git ls-tree master | cut -d' ' -f3 | cut -c-7 | sort | paste -sd '-') cd $repo \ && [[ $blob_code_local != $blob_code_remote ]] \ && [[ $(git rev-parse origin/master) == $(git rev-parse master) ]] \ && git pull done echo Everything is fine! } # get gist id from index files _gist_id() { GIST_ID=$( (grep -hs '' $INDEX || true) | sed -n "/^$1 / p" | cut -d' ' -f2 | sed -E 's#.*/##') if [[ -z "$GIST_ID" ]]; then echo -e "Not a valid index: \e[31m$1\e[0m" echo Use the index in the first column instead: echo _show_list return 1 fi } _goto_gist() { _gist_id $1 || return 1 if [[ ! -d $folder/$GIST_ID ]]; then echo 'Cloning gist as repo...' git clone git@github.com:$GIST_ID.git $folder/$GIST_ID if [[ $? -eq 0 ]]; then echo 'Repo is cloned' > /dev/tty else echo 'Failed to clone the gist' > /dev/tty return 1 fi fi (cd $folder/$GIST_ID && eval "$action") echo $folder/$GIST_ID } _delete_gist() { for i in "$@"; do _gist_id "$i" http_method DELETE $GITHUB_API/gists/$GIST_ID \ && echo "$i" deleted \ && sed -i -E "/^$i / d" $INDEX done } # remove repos which are not in user gists anymore _clean_repos() { comm -23 <(find $folder -maxdepth 1 -type d | sed '1d; s#.*/##' | sort) \ <(cat $INDEX 2> /dev/null | cut -d' ' -f2 | sed 's#.*/##' | sort) \ | while read dir; do mv $folder/$dir /tmp && echo move $folder/$dir to /tmp done } # parse JSON from gist detail _handle_gist() { echo ' raw = json.load(sys.stdin) print("site:", raw["html_url"]) print("description:", raw["description"]) print("public:", raw["public"]) print("API:", raw["url"]) print("created_at:", raw["created_at"]) print("updated_at:", raw["updated_at"]) print("files:") for file in raw["files"].keys(): print(" ", file) ' } # equal to jq '.[] | {user: .user.login, created_at: .created_at, updated_at: .updated_at, body: .body}' _handle_comment() { echo ' raw = json.load(sys.stdin); for comment in raw: print() print("|", "user:", comment["user"]["login"]) print("|", "created_at:", comment["created_at"]) print("|", "updated_at:", comment["updated_at"]) print("|", comment["body"]) ' } # TODO format with simple text _show_detail() { _gist_id $1 http_method GET $GITHUB_API/gists/$GIST_ID \ | AccessJsonElement "$(_handle_gist)" http_method GET $GITHUB_API/gists/$GIST_ID/comments \ | AccessJsonElement "$(_handle_comment)" } # set filename/description/permission for a new gist _set_gist() { public=True while [[ -n "$@" ]]; do case $1 in -d | --desc) description="$2" shift; shift;; -f | --file) filename="$2" shift; shift;; -p) public=False shift;; *) files="$1 $files" shift;; esac done ls $files > /dev/null || return 1 } # Let user type the content of gist before setting filename _new_file() { [[ -t 0 ]] && echo "Type a gist. to cancel, when done" > /dev/tty tmp_file=$(mktemp) cat > $tmp_file echo -e '\n' > /dev/tty [[ -z "$1" ]] && read -p 'Type file name: ' filename < /dev/tty mv $tmp_file /tmp/$filename echo /tmp/$filename } _gist_body(){ echo " import os.path files_json = {} files = sys.stdin.readline().split() description = sys.stdin.readline().replace('\n','') for file in files: with open(file, 'r') as f: files_json[os.path.basename(file)] = {'content': f.read()} print(json.dumps({'public': $public, 'files': files_json, 'description': description})) " } # create a new gist with files _create_gist() { _set_gist "$@" || return 1 [[ -z "$files" ]] && files=$(_new_file $filename) [[ -z "$description" ]] && read -p 'Type description: ' description < /dev/tty echo 'Creating a new gist...' http_data=$(mktemp) echo -e "$files\n$description" \ | AccessJsonElement "$(_gist_body)" > $http_data \ && http_method POST $GITHUB_API/gists \ | sed '1 s/^/[/; $ s/$/]/' \ | _parse_response $(( $(sed '/^s/ d' $INDEX | wc -l) +1 )) >> $INDEX if [[ $? -eq 0 ]]; then echo 'Gist is created' _show_list | tail -1 else echo 'Failed to create gist' fi } # update description of a gist _edit_gist() { _gist_id $1 echo -n 'Type new description: ' read DESC < /dev/tty http_data=$(mktemp) echo "{ \"description\": \"$DESC\" }" > $http_data http_method PATCH $http_data $GITHUB_API/gists/$GIST_ID > /dev/null \ && _update } usage() { sed -E -n ' /^$/ q; 7,$ s/^#//p' $0 } getConfiguredClient case "$1" in "") _show_list ;; star | s) _show_list s ;; update | u) _update "$2" ;; new | n) shift _create_gist "$@" ;; edit | e) _edit_gist "$2" ;; sync | S) _sync_repos ;; detail | d) shift _show_detail "$@" ;; delete | D) shift _delete_gist "$@" ;; clean | C) _clean_repos ;; config | c) shift _configure "$@" ;; user | U) shift _query_user "$@" ;; help | h) usage ;; *) _goto_gist "$1" ;; esac