#!/bin/ksh
# now - show upcoming events
# Author: Perette Barella
# Copyright 2010 - 2019 Devious Fish.  All rights reserved.
VERSION='$Id: now 51 2019-03-03 19:13:01Z perette $'

arg0=$(basename $0)
NAMEOPTS=""
USAGE="ace:Ef:iIpPS:tTuU:w:"
[[ $(getopts '[-][12:abc]' flag --abc; print -- 0$flag) == "012" ]] &&
        NAMEOPTS="-a $arg0" &&
        USAGE=$'
[-1?'$VERSION$']
[+NAME?\f?\f - display upcoming events]
[+DESCRIPTION?\b\f?\f\b lists events from a queue-format calendar,
reformatting to ease reading.  By default, events are reformatted to
the terminal width, with one screenful of events shown.]
[a:all?List all events instead of only a screenful.  This is the default
if output is redirected or piped.]
[c:complete?Show only completed events.]
[e:events?List number of events specified.]#[count]
[E:edit?Edit files, validating dates.]
[f:file?Specify an alternate file.  Repeat option to specify
multiple files.]:[file]
[i:not-complete?Show incomplete events and those without completion status.]
[I:incomplete?Show only events marked incomplete.]
[p:past?Include events prior to today.]
[P:past-only?Show only events prior to today.]
[S:since?Show only events on or after a specified date.]:[date]
[t:today?Show only today.]
[T:tally?Tally event durations.  Does not include all-day events.  Implies \b-a\b.]
[u:unformatted?Do not reformat events.  Implies \b-a\b.]
[U:until?Show only events on or before a specified date.]:[date]
[w:width?Format for the specified width.]#[columns]
[EXIT STATUS?0 on success, non-0 on error.]
[+FILES?\vDocuments/Cloud/queue.txt\v is the default file.]
[+SEE ALSO?\bcloud\b(1), \bqueue2ics\b(1), \bqueue\b(5)]

matching-criteria ...

[-author?Perette Barella <perette@deviousfish.com>]
'

# Search for text
# Author: Perette Barella
# Copyright 2018 Devious Fish.  All rights reserved.
# $Id: search_for_text 40 2018-10-01 19:56:46Z perette $


# Check if 0 or more patterns match a line of text.
# Comparison is case insensitive.
# $1 - The text to match against.
# $2.. - The patterns.  May be shell patterns.
# Return: 0 if all patterns match, non-0 otherwise.
function text_search_line_matches_all {
	typeset -l pattern line="$1"
	shift
	for pattern
	do
		eval "[[ \$line != *\${pattern}* ]]" && return 1
	done
	return 0
}


# Search input stream for matching text.  Matches are sent to stdout.
# Comparison is case insensitive
# $1 - Method to search by: 'line' or 'paragraph'
# $2 - Paragraph separator.  If "", blank/all whitespace lines.
# $3.. - The patterns.
function search_for_text {
	typeset -r mode="$1" divider="$2"
	shift 2
	if [[ "$mode" != line && "$mode" != paragraph ]]
	then
		print -- "cloud_extract: Assertion: $mode: Must be 'line' or 'paragraph'." 1>&2
		exit 1
	fi

	integer -r paragraph_max=1024
	integer lines=0
	typeset match=false completed=false out
	typeset -a paragraph
	while ! $completed
	do
		IFS="" read -r line || completed=true
		if [[ "$mode" = "paragraph" ]]
		then
			if $completed ||
			   [[ $divider == "" && $line == *([ \t]) ]] ||
			   eval "[[ \"\$divider\" != \"\" && \"\$line\" == \$divider ]]"
			then
				if (( lines > 0 )) &&
				   text_search_line_matches_all "${paragraph[*]}" "$@"
				then
					for out in "${paragraph[@]}"
					do
						print -r -- "$out"
					done
					match=true
				fi
				# Reset the paragraph buffer
				unset paragraph
				typeset -a paragraph
				lines=0
			fi
			if (( lines < paragraph_max ))
			then
				paragraph[lines++]="$line"
			fi
		elif ! $completed && text_search_line_matches_all "$line" "$@"
		then
			match=true
			print -r -- "$line"
		fi
	done
	$match && return 0
	return 255
}



# Determine where a string can be broken by locating whitespace.
function find_space {
	typeset -r phrase="$1"
	integer location=$2 limit=$3

	if (( ${#phrase} <= location ))
	then
		location=${#phrase}
	else
		while (( location > limit )) && [[ ${phrase:location:1} != " " ]]
		do
			let location--
		done
		(( location == limit )) && location=$2
	fi
	print $location
	return 0
}

function format_event {
	sed -e $'s& https\\{0,1\\}://[^ ]*&&' \
	    -e $'s/ \\[[Xx _]\\]//' \
	    -e $'s/^\\([0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]\\) \\([^-0-9]\\)/\\1             \\2/' \
	    -e $'s/ - \\([0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]\\)/..\\1/' \
	    -e $'s/\\( [0-9][0-9]:[0-9][0-9]\\) - /\\1-/' \
	    -e $'s/\\( [0-9][0-9]:[0-9][0-9]\\) /\\1       /' \
	    -e $'s/ - /\\\n                       /' \
	    -e $'s/ @ /\\\n                       /' \
	    -e $'s/$/\\\n/'
}

# Format a string for output.  This is basically a custom version
# of fold(1), which provides correct indentation.
function fold_event {
	typeset line piece
	integer width="$1"
	while IFS="" read -r line
	do
		while
			integer breakat=$(find_space "$line" $width 28)
			print -- "${line:0:breakat}"
			line="${line:breakat}"
			while [[ ! -z "$line" && ${line:0:1} == " " ]]
			do
				line="${line:1}"
			done
			[[ ! -z "$line" ]]
		do
				line="                       $line"
		done
	done
	return 0
}

# Tally event times as they are output, and report totals at end.
function tally_times {
	integer minutes=0 count=0 allday=0 duration width="$1"
	integer errors=0 noduration=0 nondate=0
	typeset line
	while read -r line
	do
		print -- "$line" | format_event | fold_event $width
		typeset date="${line:0:10}"
		typeset start="${line:11:5}"
		typeset through="${line:16:3}"
		typeset end="${line:19:5}"
		if [[ "$date" != [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9] ]]
		then
			let "nondate+=1"
		elif [[ "$start" != [0-9][0-9]:[0-9][0-9] ]]
		then
			let "allday += 1"
		elif [[ "$through" != " - " ]]
		then
			let "noduration+=1"
		elif [[ $end != [0-9][0-9]:[0-9][0-9] ]]
		then
			print -- "           Warning:    Invalid end time."
			let "errors+=1"
		else
			let count+=1
			integer sh=${start:0:2} sm=${start:3:2}
			integer eh=${end:0:2} em=${end:3:2}
			let "duration = (eh-sh)*60 + (em-sm)"
			(( $eh < sh )) && let "duration += 24*60"
			let "minutes += duration"
		fi
	done
	printf $'Total: %dh%02dm in %d durationed events and %d all-day events.\n' $((minutes / 60)) $((minutes % 60)) $count $allday
	(( nondate > 0 || noduration > 0 || errors > 0)) &&
		printf $'Ignored %d non-date entries, %d missing durations, %d invalid end times\n.' $nondate $noduration $errors
	return 0
}

function validate_date {
	if [[ "$1" != [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9] ]]
	then
		print -- "$1: Invalid date" 1>&2
		exit 1
	fi
	return 0
}

function extract_past_events {
	typeset line today="$(printf '%(%Y-%m-%d)T')"
	while read -r line
	do
		[[ ${line:0:10} != [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9] ]] &&
			continue
		[[ ${line:0:10} < $today ]] && print -- "$line"
	done
	return 0
}

function edit_queue_file {
	typeset filename ok=false past_event_file="/var/tmp/$arg0.$$.tmp"
	filename=$(cloud expand --no-encrypted --single "${@:-queue.txt}") || return $?

	trap "rm -f '$past_event_file'" EXIT
	extract_past_events < "$filename" > "$past_event_file" || return 1
	typeset -l choice=edit
	while [[ ${choice:0:1} != a ]]
	do
		typeset new_past_events
		${VISUAL:-${EDITOR:-vi}} "$filename"
		new_past_events=$(extract_past_events < "$filename" | fgrep -v -f "$past_event_file")
		[[ -z "$new_past_events" ]] && break
		print "New retroactive events have been added:" 1>&2
		print -- "$new_past_events" 1>&2
		while print -n "(a)ccept or (e)dit? " 1>&2
		      read choice
		      [[ ${choice:0:1} != [ae] ]]
		do
			:
		done
	done
	cloud publish
}

integer length=24 width=80
if [[ -t 1 ]]
then
	length=${LINES:-$(tput lines)}
	let length-=3
	(( length < 5 )) && length=5
	width=${COLUMNS:-$(tput cols)}
	(( width < 40 )) && width=80
	lengthlimit="head -n $length"
	eventcount="head -n $length"
else
	lengthlimit=cat
	eventcount=cat
fi

typeset all=false

today="$(printf '%(%Y-%m-%d)T')"
typeset include_today=true
startdate="$today"
enddate="9999-99-99"
completion_filter=cat
typeset format_output=true tally=false edit=unknown
typeset -a files
while getopts $NAMEOPTS "$USAGE" option
do
	if [[ $edit == true && $option != [Ef] ]]
	then
		print "$arg0: -$option incompatible with -E." 1>&2
		exit 1
	fi
	case "$option" in
	    a)
		all=true
		lengthlimit="cat"
		eventcount="cat"
		;;
	    c)
		completion_filter="grep $' \\\\[[Xx]\\\\]'"
		;;
	    e)
		lengthlimit="cat"
		eventcount="head -n $OPTARG"
		;;
	    E)
		if [[ $edit == false ]]
		then
			print "$arg0: -E incompatible with other options." 1>&2
			exit 1
		fi
		edit=true
		continue
		;;
	    f)
		files[${#files[@]}]="$OPTARG"
		continue
		;;
	    i)
		completion_filter="grep -v $' \\\\[[Xx]\\\\]'"
		;;
	    I)
		completion_filter="grep $' \\\\[[ _]\\\\]'"
		;;
	    p)
		startdate='0000-00-00'
		;;
	    P)
		startdate='0000-00-00'
		enddate="$today"
		include_today=false
		;;
	    S)
		startdate="$OPTARG"
		validate_date "$startdate"
		[[ "$startdate" > "$today" ]] && include_today=false
		;;
	    t)
		include_today=true
		startdate="$today"
		enddate="$today"
		;;
	    T)
		tally=true
		lengthlimit=cat
		eventcount=cat
		;;
	    u)
		format_output=false
		lengthlimit=cat
		eventcount=cat
		;;
	    U)
		enddate="$OPTARG"
		validate_date "$enddate"
		[[ "$enddate" < "$today" ]] && include_today=false
		;;
	    w)
		let width="$OPTARG"
		;;
	esac
	edit=false
done
shift $((OPTIND - 1))

if [[ $edit == true ]]
then
	edit_queue_file "${files[@]}"
	return $?
fi


if $tally && ! $format_output
then
	print "$arg0: -T and -u options are incompatible." 1>&2
	exit 3
fi

format_command="format_event | fold_event $width"
$format_output || format_command="cat"
$tally && format_command="tally_times $width"

set -o pipefail

# And now for one seriously crazy pipeline.
if (( ${#files[@]} > 0 ))
then
	cloud sync
	for file in "${files[@]}"
	do
		if [[ -f "$file" ]]
		then
			cat "$file" || return $?
		else
			cloud cat "$file" || return $?
		fi
	done
else
	cloud cat -f queue.txt || return $?
fi |
grep -v $'^[ \t]*$' |
eval "$completion_filter" |
search_for_text line - "$@" |
sort -k 1,1 -k 2.1,2.2g -k 2.4,2.5n |
(while IFS="" read -r line
do
	[[ "$startdate" > "${line:0:10}" ]] && continue
	[[ "$enddate" < "${line:0:10}" ]] && continue
	[[ "$include_today" == "false" && "${line:0:10}" == "$today" ]] && continue
	print -- "$line"
done; true) |
eval "$eventcount" |
eval "$format_command" |
eval "$lengthlimit"

exit_status=$?

# The 'head' command that cuts off at a page breaks the pipeline,
# which fails with SIGPIPE.  If we got that, ignore it.
if (( exit_status != 0 )) && ! $all
then
	COLUMNS=10
	sigpipe_info=$(kill -L 2>&1 | grep -w PIPE)
	if [[ -n "$sigpipe_info" ]]
	then
		integer sigpipe
		let sigpipe="${sigpipe_info%%+([^0-9])}" || return $exit_status
		(( exit_status == sigpipe )) && exit_status=0
	fi
fi

exit $?

