#!/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] # # * clone gist repos which are not in local # * pull master branch if a local repo is behind its remote # gist (sync | S) # # * Go to local gist repo # . 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 unify indent # TODO grep mode for description, file content # TODO description for current directory # TODO error handling, unit test # TODO completion # Validate configuration set -eo pipefail [ "$TRACE" ] && set -x GITHUB_API=https://api.github.com CONFIG=~/.config/gistrc mkdir -p ~/.config && umask 0077 configuredClient="" _config_cases() { if [[ $1 == 'token' ]]; then [[ ${#2} -eq 40 ]] && echo $1=$2 \ || echo -e Invalid token format, it is not 40 chars '\n' > /dev/tty elif [[ $1 == 'auto_sync' ]]; then [[ $2 == 'false' ]] && echo $1=$2 \ || echo $1=true elif [[ $1 == 'folder' ]]; then [[ -n "$2" ]] && echo $1=$2 \ || echo $1=~/gist elif [[ $1 == 'user' ]]; then echo $1=$2 fi } _configure() { [[ -z "$@" ]] && (${EDITOR:-vi} $CONFIG) && return 0 target=$(_config_cases "$@") touch $CONFIG [[ "$target" =~ [^=]$ ]] && sed -i "/^$1=/ d" $CONFIG && echo $target >> $CONFIG cat $CONFIG } _ask_username() { while [[ ! $user =~ ^[[:alnum:]]+$ ]]; do [[ ! -z $user ]] && echo "Not a valid username" read -p "Github username: " user < /dev/tty done _configure user $user } _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 [[ ! -z $token ]] && echo "Not a valid token" read -p "Paste your token here (Ctrl-C to skip): " token < /dev/tty done _configure token $token } _apply_config() { source $CONFIG if [[ ! -e $CONFIG ]] || [[ -z $user ]]; then echo -n 'Hi fellow! To access your gists, I need your Github username, ' echo -n "also a personal token with scope which allows "gist"!'" _ask_username _ask_token elif [[ -z $token ]] && [[ $1 =~ ^(n|new|e|edit|D|delete)$ ]]; then _ask_token fi source $CONFIG } _apply_config "$@" auth_header="Authorization: token $token" [[ -z "$folder" ]] && folder=~/gist && mkdir -p $folder INDEX=$folder/index STARRED=$folder/starred ## 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 "$1" ]]; then echo 'No local file found for last update, please run command:' echo " gist update $([[ $1 == $STARRED ]] && echo 'star')" return 0 fi cat $1 \ | while read line_num link file_url_array 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 $line_num $link $file_num $extra $(echo $description | cut -c -$(( 60 -$occupy -1 )) ) done } # get the list of gists _update() { echo "fetching $user's gists from $GITHUB_API..." echo local list_file=$INDEX local route="users/$user/gists" local mark="" [[ "$1" =~ ^(star|s)$ ]] && list_file=$STARRED && route="gists/starred" && mark="s" httpGet $GITHUB_API/$route \ | _parse_response | nl -s' ' | sed -E "s/^ */$mark/" > $list_file \ && _show_list $list_file \ || echo Fail to update gists if [[ $auto_sync != "false" ]]; then (_sync_repos $1 > /dev/null 2>&1 &); fi } ## 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" 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(len(gist["files"]), end=" ") print(gist["comments"], end=" ") print(gist["description"]) ' } # TODO check if a user create a very first gist _parse_response() { AccessJsonElement "$(_handle_gists)" \ | tac | sed 's/, /,/g'\ | while read link file_url_array 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 '-') echo $link $blob_code $file_num $comment_num $description | tr -d '"' done } _sync_repos() { local list_file=$INDEX [[ "$1" =~ ^(star|s)$ ]] && list_file=$STARRED && route="gists/starred" # clone repos which are not in the local comm -13 <(find $folder -maxdepth 1 -type d | sed '1d; s#.*/##' | sort) \ <(cat $list_file | 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 $STARRED || 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 "$INDEX" return 1 fi } # TODO a better way without source _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 \ && echo 'Repo is cloned' \ || echo 'Failed to clone the gist' \ && return 1 fi echo This gist is at $folder/$GIST_ID echo -e "You can run the following command to jump to this directory: \n" echo -e " \e[32m. gist $1\e[0m\n" echo -n 'files: ' && 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 $STARRED 2> /dev/null | cut -d' ' -f2 | sed 's#.*/##' | sort) \ | while read dir; do mv $folder/$dir /tmp && echo move $folder/$dir to /tmp done } _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_gist() { while [[ -n "$@" ]]; do case $1 in -d | --desc) description="$2" shift; shift;; -f | --file) filename="$2" shift; shift;; *) files="$1 $files" shift;; esac done ls $files > /dev/null || return 1 } _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 } # create a new gist with files # TODO support secret gist # FIXME catch status code from curl if it fails _create_gist() { _set_gist "$@" || return 1 [[ -z "$files" ]] && files=$(_new_file $filename) [[ -z "$description" ]] && read -p 'Type description: ' description < /dev/tty for file in $files; do echo "\"$(basename $file)\": {\"content\": \"$(sed '$ !s/$/\\n/' $file)\"}," done | tr -d '\n' | sed 's/^/{/; s/,$/}/' \ | echo "{ \"public\": true, \"files\": $(cat -), \"description\": \"$description\"}" \ | curl -s -H "$auth_header" --data @- $GITHUB_API/gists \ | sed '1 s/^/[/; $ s/$/]/' \ | _parse_response \ | sed -E "s/^/$(( $(wc -l $INDEX | cut -d' ' -f1) + 1 )) /" >> $INDEX \ && echo -e '\nGist created' \ || echo 'Fail to create gist' _show_list $INDEX | tail -1 } # 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 } _help_message() { sed -E -n ' /^$/ q; 8,$ s/^#//p' $0 } getConfiguredClient case "$1" in "") _show_list $INDEX ;; star | s) _show_list $STARRED ;; 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) _help_message ;; *) _goto_gist "$1" ;; esac