#!/usr/bin/env bash # # Author: Hsieh Chin Fan (typebrook) # License: MIT # https://gist.github.com/typebrook/b0d2e7e67aa50298fdf8111ae7466b56 # # # This script host your gists as local cloned git repo # It works under GNU curl, which is easy to get in most cases # # Use the following commands to manage your gists: # # * update the local list of your gists, star for your starred gists # gist (update | u) [star | s] # # * list your gists with format: [number] [url] [file_num] [comment_num] [short description] # gist [star | s] # # * show the path of local gist repo and files # gist # # * create a new gist with files # gist (new | n) [-d | --desc ""] ... # # * create a new gist with STDIN # gist (new | n) [-d | --desc ""] [-f | --file ] < # # * show the detail of a gist # gist (detail | d) # # * edit a gist description # gist (edit | e) # # * delete a gist # gist (delete | D) ... # # * clean removed gists in local # gist (clean | C) # # * update a gist # Since now a gist is a local cloned repo # It is your business to do git commit and git push # # * configuration # gist (config | c) [token ] [user ] [folder ] [auto-sync false] # # * show this help message # gist (help | h) # TODO parallel branch works with wget and other stuff # TODO grep mode for description, file content # TODO push github.com (may need new token) # TODO description for current directory # TODO error handling, unit test # TODO test on mac and remote machine # TODO completion # Shell configuration [ "$TRACE" ] && set -x 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 } # load configuration _apply_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 source $CONFIG } _apply_config "$@" || exit 1 AUTH_HEADER="Authorization: token $token" [[ -z "$folder" ]] && folder=~/gist && mkdir -p $folder INDEX=$folder/index ## 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 httpGet() { case "$configuredClient" in curl) curl -A curl -s "$@" ;; wget) wget -qO- "$@" ;; httpie) http -b GET "$@" ;; 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="" if [[ $1 == "s" ]]; then filter='/^[^s]/ d' else filter='/^s/ d' fi cat $INDEX \ | while read index link blob_code file_num extra description; do 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 $file_num $extra $(echo $description | cut -c -$(( 60 -$occupy -1 )) ) done \ | sed "$filter" } # parse JSON from STDIN with string of commands AccessJsonElement() { PYTHONIOENCODING=utf-8 \ python -c "from __future__ import print_function; import sys, json; raw = json.load(sys.stdin); $1" 2> /dev/null return "$?" } # equal to: jq '.[] | "\(.html_url) \([.files[] | .raw_url]) \(.files | keys | length) \(.comments) \(.description)"' _handle_gists() { echo ' 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["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 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 $description | tr -d '"' done } # 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' if [[ "$1" =~ ^(star|s)$ ]];then route="gists/starred" mark="s" filter='/^[s]/ d' fi local response=$(curl -s -H "$AUTH_HEADER" $GITHUB_API/$route) false && echo Failed to update gists && return 1 sed -i "$filter" $INDEX echo $response | _parse_response >> $INDEX _show_list $mark if [[ $auto_sync != "false" ]]; then (_sync_repos $1 > /dev/null 2>&1 &); fi } # 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 if [[ ! -d $folder/$GIST_ID ]]; then echo 'Cloning gist as repo...' git clone git@github.com:$GIST_ID.git $folder/$GIST_ID if [[ "$?" -ne 0 ]]; then echo 'Repo is cloned' > /dev/tty else echo 'Failed to clone the gist' > /dev/tty fi fi echo $folder/$GIST_ID cd $folder/$GIST_ID && ls #tig --all 2> /dev/null || true } _delete_gist() { for i in "$@"; do _gist_id "$i" curl -X DELETE -s -H "$AUTH_HEADER" $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 ' 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 ' 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 httpGet $GITHUB_API/gists/$GIST_ID \ | AccessJsonElement "$(_handle_gist)" httpGet $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 local 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 } # 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 local index=$(( $(sed '/^s/ d' $INDEX | wc -l) +1 )) echo 'Creating a new gist' for file in $files; do echo "\"$(basename $file)\": {\"content\": \"$(sed '$ !s/$/\\n/' $file)\"}," done | tr -d '\n' | sed 's/^/{/; s/,$/}/' \ | echo "{ \"public\": $public, \"files\": $(cat -), \"description\": \"$description\"}" \ | curl -s -H "$AUTH_HEADER" --data @- $GITHUB_API/gists \ | sed '1 s/^/[/; $ s/$/]/' \ | _parse_response $index >> $INDEX if [[ "$?" -ne 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 echo "{ \"description\": \"$DESC\" }" \ | curl -X PATCH -H "$AUTH_HEADER" --data @- $GITHUB_API/gists/$GIST_ID > /dev/null \ && _update } usage() { sed -E -n ' /^$/ q; 8,$ 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 "$@" ;; help | h) usage ;; *) _goto_gist "$1" ;; esac