~otheb/kb

68e0e4fbdfbdc4f223c48d2dde95eeca6b85d7f4 — Olie Ayre 8 months ago 118d5bc
Wrote CLI

And removed previous partial attempt in bash that ran painfully slowly.
3 files changed, 283 insertions(+), 142 deletions(-)

M cli/dub.json
D cli/kb.sh
M cli/main.d
M cli/dub.json => cli/dub.json +2 -1
@@ 3,6 3,7 @@
		"Oliver Ayre"
	],
	"dependencies": {
		"arsd-official:http": "~>5.0.0",
		"lighttp": "~>0.5.3"
	},
	"description": "kb CLI client",


@@ 15,4 16,4 @@
		"main.d"
	],
	"targetType": "executable"
}
}
\ No newline at end of file

D cli/kb.sh => cli/kb.sh +0 -106
@@ 1,106 0,0 @@
#!/bin/bash

url="127.0.0.1:80"
pager="${PAGER:-less -r}"
editor="${EDITOR:-vim}"

# quick arg count check
[[ "$#" -lt 2 ]] && goto usage

# set host and port
url="http://$1"

# functions
usage() {
	echo 'Usage: '$0' <host> <command> [command-args...]
	host : hostname/IP/port of API server e.g. '\'myapiserver:8000\''
	commands :
	ls [-r] : list entries by ID (or by timestamp if `-r` is provided)
	view <id> : view entry #ID in $PAGER (defaults to `less -r`)
	new : create a new entry in $EDITOR (defaults to `vim`)
	edit <id> : edit entry #ID in $EDITOR
	rm <id> : delete entry #ID
	search <terms...> : search based on provided terms and display results
	in $PAGER'
	exit 1
}

formatdate() {
	# $1 is unix timestamp
	date "+%-d %b %Y @ %I:%M%p" -d @$1
}

formatpost() {
	# $1 is parsed json data
	local data="$1"
	echo -en "\x1b[34m#$(echo "$data" | sed '1q;d')\x1b[0m"
	[[ "$(echo "$data" | sed '2q;d')" == "true" ]] && \
		echo -en " [DEPRECATED]"
	echo -en " $(echo "$data" | sed '3q;d' | sed 's/^"//;s/"$//' )"
	echo " - $(formatdate "$(echo "$data" | sed '4q;d')")"
	echo -e "\tTags: $(echo "$data" | sed '1,4d' | xargs)"
}

dols() {
	curl --fail "$url/" 2> /dev/null | jq '.ids[]' | sort | while read id; do
		formatpost "$(curl "$url/$id" 2> /dev/null | \
			jq '.id , .old , .title , .time , .tags[]')"
	done | $pager
}

doview() {
	local data="$(curl --fail "$url/$1" 2> /dev/null)"
	[[ "$data" == "" ]] && return
	formatpost "$(echo "$data" | jq '.id , .old , .title , .time , .tags[]')"
	local content="$(echo "$data" | jq '.content')"
	echo "${content:1:-1}"
	echo -e "\nHistory:"
	echo "$data" | jq '.history[]'
}

donew() {
	echo "new"
}

doedit() {
	echo "edit $1"
}

dorm() {
	echo "rm $1"
}

dosearch() {
	echo "search $@"
}

# do command
case "$2" in
	ls)
		if [[ "$#" -eq 3 && "$3" == "-r" ]]; then dols true
		elif [[ "$#" -eq 2 ]]; then dols false
		else usage
		fi ;;
	view)
		if [[ "$#" -eq 3 && "$(echo -n $3 | tr -d 0-9)" == "" ]]; then doview $3
		else usage
		fi ;;
	new)
		if [[ "$#" -eq 2 ]]; then donew
		else usage
		fi ;;
	edit)
		if [[ "$#" -eq 3 && "$(echo -n $3 | tr -d 0-9)" == "" ]]; then doedit $3
		else usage
		fi ;;
	rm)
		if [[ "$#" -eq 3 && "$(echo -n $3 | tr -d 0-9)" == "" ]]; then dorm $3
		else usage
		fi ;;
	search)
		if [[ "$#" -gt 2 ]]; then dosearch "${@:3}"
		else usage
		fi ;;
	*) usage
esac
exit 0

M cli/main.d => cli/main.d +281 -35
@@ 1,11 1,18 @@
module main ;

import std.stdio  ;
import std.conv   ;
import std.array  ;
import std.string ;
import std.algorithm ;
import std.stdio     ;
import std.conv      ;
import std.array     ;
import std.string    ;
import std.socket    ;
import std.typecons  ;
import std.json      ;
import std.process   ;
import std.path      ;
import std.file      ;

import lighttp ;
import arsd.http2 ;

int usage() {
	writeln(


@@ 13,7 20,7 @@ int usage() {
\thost: hostname/IP/port of API server e.g. 'myapiserver:8000'
\tcommans:\n
\tls [-r] : list entries by ID (or by timestamp if `-r` is provided).
\tview ID : view entry #ID in $PAGER (defaults to `less`).
\tview ID : view entry #ID in $PAGER (defaults to `less -r`).
\tnew     : create a new entry in $EDITOR (defaults to `vim`).
\tedit ID : edit entry #ID in $EDITOR.
\trm ID   : delete entry #ID.


@@ 22,13 29,33 @@ int usage() {
	return 1 ;
}

const string entryfooter = q"EOS
# Please enter your description for the knowledge base. Lines starting with '#'
# will be ignored, and an empty message or title will abort the entry creation.
#
# The first line (starting with 'Title:') is for the title of your entry, which
# you should append to the line. The 'Tags:' line is for tagging your entry with
# key words to make it easier to find. These should be separated with
# whitespace. The 'Deprecated:' line is for showing whether or not the entry is
# to be considered old, deprecated, or no longer relevant. A value of "yes" or
# "true" will mark it as such, and any other value will be treated as "no" in
# that the entry is relevant. The 'IsPatch:' line marks whether edits to an
# existing entry are to be treated as a new version ("no"/"false") or as a patch
# to the current version ("yes"/"true"/*) e.g. if you're just fixing typos. An
# example set of headers might be:
#
# Title: My entry title
# Tags: first second third
# Deprecated: no
# IsPatch: no
# My content here...
EOS" ;

string HOST   = "127.0.0.1" ;
ushort PORT   = 80          ;
string PAGER  = "less"      ;
string PAGER  = "less -r"   ;
string EDITOR = "vim"       ;

Client.ClientConnection client ;

int main( string[] args ) {
	if ( args.length < 3 ) { return usage() ; }



@@ 38,12 65,8 @@ int main( string[] args ) {
		if ( hp.length > 1 ) { PORT = hp[1].to!ushort ; }
	}

	// connect
	client = new Client().connect( HOST , PORT ) ;

	// main command
	try //
	switch ( args[2] ) {
	try switch ( args[2] ) {
	case "ls" :
		if ( args.length == 4 && args[3] == "-r" ) { ls( true ) ;
		} else if ( args.length == 3 ) { ls( false ) ;


@@ 51,7 74,7 @@ int main( string[] args ) {
		break ;
	case "view" :
		if ( args.length == 4 && args[3].tr( "0-9" , "" , "d" ).length == 0 ) {
			view( args[3].to!long ) ;
			view( args[3] ) ;
		} else { return usage() ; }
		break ;
	case "new" :


@@ 60,12 83,12 @@ int main( string[] args ) {
		break ;
	case "edit" :
		if ( args.length == 4 && args[3].tr( "0-9" , "" , "d" ).length == 0 ) {
			edit( args[3].to!long ) ;
			edit( args[3] ) ;
		} else { return usage() ; }
		break ;
	case "rm" :
		if ( args.length == 4 && args[3].tr( "0-9" , "" , "d" ).length == 0 ) {
			rm( args[3].to!long ) ;
			rm( args[3] ) ;
		} else { return usage() ; }
		break ;
	case "search" :


@@ 73,8 96,8 @@ int main( string[] args ) {
		} else { return usage() ; }
		break ;
	default : return usage() ;
	} catch ( Exception e ) {
		writeln( "Error: " , e.message ) ;
	} catch ( SocketOSException e ) {
		writeln( "No connection to API server at " , url ) ;
		return 1 ;
	}



@@ 82,36 105,259 @@ int main( string[] args ) {
}

string url() { return "http://" ~ HOST ~ ":" ~ PORT.to!string ; }
string tmp() { return buildPath( tempDir() , "kb-tempbuffer" ) ; }

auto index() { return get( url ~ "/" ).waitForCompletion() ; }
auto entry( long id ) {
	return get( url ~ "/" ~ id.to!string ).waitForCompletion() ;
}
auto entry( string id ) { return get( url ~ "/" ~ id ).waitForCompletion() ; }
auto post( string j , string id = "" ) {
	auto client = new HttpClient() ;
	return client.request( Uri( url ~ "/" ~ id ) , HttpVerb.POST ,
	                       cast(ubyte[])j , "application/json" )
	       .waitForCompletion() ;
}
auto patch( string j , string id ) {
	auto client = new HttpClient() ;
	return client.request( Uri( url ~ "/" ~ id ) , HttpVerb.PATCH ,
	                       cast(ubyte[])j , "application/json" )
	       .waitForCompletion() ;
}
auto delete_( string id ) {
	auto client = new HttpClient() ;
	return client.request( Uri( url ~ "/" ~ id ) , HttpVerb.DELETE )
	       .waitForCompletion() ;
}

void ls( bool recent ) {
	writeln( "listing entries from http://" , HOST , ":" , PORT , " using " ,
	         PAGER , " and sorting them based on " ,
	         recent ? "timestamp." : "id." ) ;
	client
		.success( r => writeln( r.body ) )
		.failure( () => writeln( "no connection to API server at " , url ) )
		.get( "/" ).w ;
	writeln( "made the request" ) ;
	auto res = index() ;
	if ( res.code != 200 ) {
		writeln( "Error " , res.code ) ;
		return ;
	}
	auto pipe = pipeProcess( PAGER.split() , Redirect.stdin ) ;
	pipe.stdin.write(
		res.contentText.parseJSON()["ids"].array.map!( e => entry( e.integer ) )
		.filter!( r => r.code == 200 ).map!( r => r.contentText.parseJSON() )
		.array.sort!( ( a , b ) =>
			recent ? a["time"].integer > b["time"].integer :
				a["id"].integer < b["id"].integer
		).map!( e => e.fmtshort() ).join( "\n" )
	) ;
	pipe.stdin.close() ;
	pipe.pid.wait() ;
}

string fmtdate( long ts ) {
	import std.datetime ;
	auto dt = cast(DateTime)SysTime.fromUnixTime( ts , LocalTime() ) ;
	return text ( // D Month YEAR @ HH:MMap
		dt.day , " " , // day
		[ "" , "January" , "February" , "March" , "April" , "May" , "June" ,
		  "July" , "August" , "September" , "October" , "November" , "December"
		][ dt.month ] , " " , // month
		dt.year , " @ " , // year
		dt.hour % 12 == 0 ? 12 : dt.hour % 12 , ":" , // hour
		std.conv.to!string( dt.minute ).rightJustify( 2 , '0' ) , // minute
		dt.hour >= 12 ? "pm" : "am" // am/pm
	) ;
}

string fmtshort( JSONValue j ) {
	return text(
		"#" , j["id"].integer , // #ID
		j["old"].boolean ? " [DEPRECATED] " : " " , // deprecated
		j["title"].str , "\n\t" , // title
		j["time"].integer.fmtdate() // date and time
	) ;
}

string fmtlong( JSONValue j ) {
	return text(
		j.fmtshort() , "\n" , // short form at start
		j["content"].str.wrap( 80 ) , // content
		j["history"].array.length == 0 ? "" :
		"\nHistory:\n" ~ // history header
		j["history"].array.map!( h => text(
			"\n\t" , h["time"].integer.fmtdate() , "\n" , // date and time
			h["content"].str.wrap( 80 ) // content
		) ).join( "" )
	) ;
}

void view( string id ) {
	auto res = get( url ~ "/" ~ id ).waitForCompletion() ;
	if ( res.code == 404 ) {
		writeln( "Entry " , id , " does not exist" ) ;
	} else if ( res.code != 200 ) {
		writeln( "Error " , res.code ) ;
	} else {
		auto pipe = pipeProcess( PAGER.split() , Redirect.stdin ) ;
		pipe.stdin.write( res.contentText.parseJSON().fmtlong() ) ;
		pipe.stdin.close() ;
		pipe.pid.wait() ;
	}
}

void submit( string id = "" ) {
	// if id != "" then we're editing so get the existing entry
	auto e = JSONValue() ;
	if ( id != "" ) {
		// get existing entry
		auto res = entry( id ) ;
		if ( res.code == 404 ) {
			writeln( "Entry " , id , " doesn't exist" ) ;
			return ;
		} else if ( res.code != 200 ) {
			writeln( "Error " , res.code ) ;
			return ;
		}
		e = res.contentText.parseJSON() ;
	}

	// create the temporary file
	std.file.write( tmp , e.type == JSONType.null_ ? mktemplate() :
	                mktemplate( e ) ) ;

	// edit in $EDITOR
	spawnProcess( EDITOR.split() ~ tmp ).wait() ;

	// parse the file then remove it
	auto j = tmp.readText().parsefile() ;
	remove( tmp ) ;

	// abort on empty content/title
	if ( j.type == JSONType.null_ ) {
		writeln( "Aborting on empty content/title" ) ;
		return ;
	}

	// submit
	if ( id == "" ) {
		// new
		auto res = post( j.toString() ) ;
		if ( res.code != 200 ) {
			writeln( "Error " , res.code ) ;
		} else {
			writeln( "Created new entry #" ,
			         res.contentText.parseJSON()["id"].integer , " '" ,
			         j["title"].str , "'" ) ;
		}
	} else {
		auto res = j["ispatch"].boolean ?
			patch( j.toString() , id ) :
			post ( j.toString() , id ) ;
		if ( res.code != 200 ) {
			writeln( "Error " , res.code ) ;
		} else {
			writeln( "Updated entry #" ,
			         res.contentText.parseJSON()["id"].integer , " '" ,
			         j["title"].str , "'" ) ;
		}
	}
}

void view( long id ) {
	writeln( "viewing entry " , id , " in " , PAGER ) ;
void new_() { submit() ; }

string mktemplate() {
	return "Title:\nTags:\nDeprecated: no\n\n" ~ entryfooter ;
}

void new_() {
	writeln( "creating a new entry with " , EDITOR ) ;
string mktemplate( JSONValue j ) {
	return text(
		"Title: " , j["title"].str , "\n" , // title
		"Tags: " ,
		j["tags"].array.map!( t => t.str ).join( " " ) , "\n" , // tags
		"Deprecated: " , j["old"].boolean ? "yes" : "no" , "\n" , // deprecated
		"IsPatch: \n" , // is a patch
		j["content"].str , "\n" , // content
		entryfooter // stock content at the bottom
	) ;
}

void edit( long id ) {
	writeln( "editing entry " , id , " in " , EDITOR ) ;
auto parsefile( string content ) {
	// strip comments
	string[] lines = content.splitLines.filter!( l => ! l.startsWith( "#" ) )
	                 .array ;
	string title ; {
		auto i = lines.countUntil!( l => l.startsWith( "Title:" ) ) ;
		// return null json if there's no title
		if ( i == -1 ) { return JSONValue() ;
		} else {
			title = lines[i][ "Title:".length .. $ ].strip() ;
			lines.remove( i ) ;
		}
		// return null json if the title is empty
		if ( title == "" ) { return JSONValue() ; }
	}
	string[] tags ; {
		auto i = lines.countUntil!( l => l.startsWith( "Tags:" ) ) ;
		if ( i != -1 ) {
			tags = lines[i][ "Tags:".length .. $ ].split()
			       .filter!( t => t != "" ).map!( t => t.toLower() ).array ;
			lines.remove( i ) ;
		}
	}
	bool ispatch ; {
		auto i = lines.countUntil!( l => l.startsWith( "IsPatch:" ) ) ;
		if ( i == -1 ) { ispatch = false ;
		} else {
			ispatch = [ "yes" , "true" ]
			          .canFind( lines[i][ "IsPatch:".length .. $ ].strip()
			                    .toLower() ) ;
			lines.remove( i ) ;
		}
	}
	bool old ; {
		auto i = lines.countUntil!( l => l.startsWith( "Deprecated:" ) ) ;
		if ( i == -1 ) { old = false ;
		} else {
			old = [ "yes" , "true" ]
				.canFind( lines[i][ "Deprecated:".length .. $ ].strip()
				          .toLower() ) ;
			lines.remove( i ) ;
		}
	}
	string body = lines.join( "\n" ).strip() ;
	return body == "" ? JSONValue() : JSONValue( [
		"title"   : JSONValue( title ) ,
		"tags"    : JSONValue( tags  ) ,
		"old"     : JSONValue( old   ) ,
		"content" : JSONValue( body  )
	] ) ;
}

void rm( long id ) {
	writeln( "deleting entry " , id ) ;
void edit( string id ) { submit( id ) ; }

void rm( string id ) {
	auto res = delete_( id ) ;
	if ( res.code == 404 ) { writeln( "Entry " , id , " doesn't exist" ) ;
	} else if ( res.code != 200 ) { writeln( "Error " , res.code ) ;
	} else { writeln( "Deleted entry " , id ) ; }
}

void search( string[] terms ) {
	writeln( "searching entries based on search terms " , terms ,
	         " and displaying results in " , PAGER ) ;
	// make the request
	auto client = new HttpClient() ;
	auto res = client.request( Uri( url ~ "/s" ) , HttpVerb.POST ,
		cast(ubyte[])JSONValue( [
			"search" : JSONValue( terms.join( " " ) )
		] ).toString() , "application/json" ).waitForCompletion() ;
	if ( res.code != 200 ) { writeln( "Error " , res.code ) ;
	} else {
		auto pipe = pipeProcess( PAGER.split() , Redirect.stdin ) ;
		pipe.stdin.writeln(
			res.contentText.parseJSON()["results"].array
			.map!( r => tuple( r["id"].integer , r["relevance"].integer ) )
			.array.sort!( ( a , b ) => a[1] > b[1] ).map!( r => entry( r[0] ) )
			.filter!( r => r.code == 200 )
			.map!( r => r.contentText.parseJSON() ).map!( e => e.fmtshort() )
			.join( "\n" )
		) ;
		pipe.stdin.close() ;
		pipe.pid.wait() ;
	}
}