#!/usr/bin/perl
# Day Planner notifier
# Sends a notification to the user
# Copyright (C) Eskild Hustvedt 2006, 2007, 2008, 2009, 2012
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

use strict;			# Force strict coding
use warnings;			# Tell perl to display warnings
use Gtk2;			# Use Gtk2
use Getopt::Long;		# Commandline options
use POSIX;			# setlocale()
use FindBin;
# These constants prettify things
use constant { true => 1, false => 0 };
# This here is done so that we can use local versions of our libs
use lib "$FindBin::RealBin/modules/DP-iCalendar/lib/";
use lib "$FindBin::RealBin/modules/dayplanner/";
# External deps as fetched by the makefile
use lib "$FindBin::RealBin/modules/external/";
# Day Planner-specific libs
use DP::GeneralHelpers qw(PrefixZero DPIntWarn);
use DP::GeneralHelpers::I18N;
use DP::iCalendar qw(iCal_ParseDateTime iCal_ConvertToUnixTime);
use DP::CoreModules;

my $RealZero = $0;
$0 = 'dayplanner-notifier';
my $Gettext;			# Global Gettext object
my $Version = '0.11';
# The version name. Used for GIT detection
my $VersionName = '2012';

my $CalendarPath;		# The source iCalendar file
my $DP_I18N_Mode;
my $Message;			# The message
my $Fulltext;			# The fulltext (details)
my $Time;			# The time
my $Date;			# The date
my $IsWarning;			# If it is a warning or the actual event time
my $i18n;			# The DP::GeneralHelpers::I18N object

my $MessageID;			# The daemon message ID
my $NoFork;
my $LaunchTime = time();

# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
# HELPER FUNCTIONS
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

# Purpose: Die with useful information if an Assertion fails
# Usage: assert(TRUE/FALSE EXPR, REASON);
sub Assert
{
	my $expr = shift;
	return true if $expr;
	my ($package, $filename, $line, $subroutine, $hasargs, $wantarray, $evaltext, $is_require, $hints, $bitmask) = caller(1);
	my ($s2_package, $s2_filename, $s2_line, $s2_subroutine, $s2_hasargs, $s2_wantarray, $s2_evaltext, $s2_is_require, $s2_hints, $s2_bitmask) = caller(0);
	my $msg;
	if(defined($_[0]))
	{
		$msg = "Assertion failure at $s2_filename:$s2_line in $subroutine originating from call at $filename:$line: $_[0]\n";
	}
	else
	{
		$msg = "Assertion failure at $s2_filename:$s2_line in $subroutine originating from call at $filename:$line\n";
	}
	# If this is the GIT version or DP_FATAL_ASSERT is set, then the error is fatal.
	if ($VersionName eq 'GIT' or (defined($ENV{DP_FATAL_ASSERT}) and $ENV{DP_FATAL_ASSERT} eq 1))
	{
		die($msg);
	}
	# If not, then we output it and happily continue processing
	else
	{
		DaemonLog(0,$msg);
	}
	return true;
}

# Purpose: Create the details widget
# Usage: my $ExpanderWidget = CreateDetailsWidget();
sub CreateDetailsWidget {
	my $FT_Expander = Gtk2::Expander->new($i18n->get('Show details'));
	$FT_Expander->show();
	$FT_Expander->signal_connect('activate' => sub {
			# Yes, it is weird to use not here, but in the callback it appears
			# to return "" if it is expanded and 1 if it isn't.
			# Possibly a race condition within gtk2. This appears to work anyway.
			if(not $FT_Expander->get_expanded) {
				$FT_Expander->set_label($i18n->get('Hide details'));
			} else {
				$FT_Expander->set_label($i18n->get('Show details'));
			}
		});
	return($FT_Expander);
}

# Purpose: Print nicely formatted help output
# Usage: PrintHelp("-shortoption","--longoption","Description");
sub PrintHelp {
	printf "%-4s %-16s %s\n", "$_[0]", "$_[1]", "$_[2]";
}

# Purpose: Test if X is available
# Usage: if(X_Available()) { Available } else { Not available }
sub X_Available {
	# Check if we've got a gtk2 version above 2.10.0, if we do then we
	# use the builtin Gtk2->init_check function. If not, we fall back to using
	# the uglier, slower and less portable system() test
	#
	# We can't eval() or similar the ->init function because it dies at library (C)
	# level, which is below perl and can't be caught and handled properly.
	if(Gtk2->CHECK_VERSION(2,10,0)) {
		# Use the Gtk2->init_check function
		if(Gtk2->init_check()) {
			return(true);
		} else {
			return(false);
		}
	} else {
		# Fall back to using the ugly system()-based test.
		if(system('perl', '-e', 'use Gtk2; open(STDERR, \'>/dev/null\'); Gtk2->init;') <= 0) {
			return(false);
		} else {
			return(true);
		}
	}
}

# Purpose: Get information from the calendar
# Usage: GetMessagesFromCalendar(Calendar, UID);
sub GetMessagesFromCalendar
{
	my $calendar = shift;
	my $UID = shift;
	Assert($calendar);
	Assert($UID);
	my $iCalendar = DP::iCalendar->new($calendar);
	my $UIDInf = $iCalendar->get_info($UID);
	$Message = $UIDInf->{'SUMMARY'};
	$Fulltext = $UIDInf->{'DESCRIPTION'};
	$IsWarning = true;	# Default to warning
	$Date = 'tomorrow'; # Default to tomorrow
	my ($eventYear, $eventMonth, $eventDay, $eventTime) = iCal_ParseDateTime($UIDInf->{DTSTART});
	$Time = $eventTime;
	# Find out if the UID is now or not
	#  We try both launch time and launch time plus/minus 60 seconds. This so that we
	#  get reliable information even during high system loads and in the offchance that
	#  something is not right.
	foreach my $possibleTime (@{[$LaunchTime, $LaunchTime - 60, $LaunchTime + 60]})
	{
		my ($sec,$min,$hour,$mday,$month,$year,$wday,$yday,$isdst) = GetDate($possibleTime);
		if($iCalendar->UID_exists_at($UID,$year,$month,$mday,PrefixZero($hour).':'.PrefixZero($min)))
		{
			$IsWarning = false;
		}
		elsif(iCal_ConvertToUnixTime($UIDInf->{DTSTART}) <= time())
		{
			$IsWarning = false;
		}
		if($iCalendar->UID_exists_at($UID,$year,$month,$mday))
		{
			$Date = 'today';
		}
	}
}

# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
# MAIN NOTIFICATION
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

# Purpose: Find out how to notify the user and do it
# Usage: NotifyUser();
sub NotifyUser {
	$i18n = DP::GeneralHelpers::I18N->new('dayplanner');
	$Time = $i18n->AMPM_From24($Time);	# Convert to AM/PM if needed
	if(defined($ENV{DISPLAY}) and length($ENV{DISPLAY}) and X_Available()) {
		# We have DISPLAY, use GTK
		GtkNotifier();
	} else {
		NonGUINotifier();
	}
}

# Purpose: Notify the user using a graphical (gtk2) dialog
# Usage: GtkNotifier();
sub GtkNotifier {
	Gtk2->init;
	while (1) {
		my $MainText;
		if($Date eq 'today') {
			$MainText = $i18n->get_advanced("Today at %(time):\n%(event_description)", { 'time' => $Time, 'event_description' => $Message});
		} elsif ($Date eq 'tomorrow') {
			$MainText = $i18n->get_advanced("Tomorrow at %(time):\n%(event_description)", { 'time' => $Time, 'event_description' => $Message});
		} else {
			$MainText = $i18n->get_advanced("At %(time) on %(date):\n%(event_description)", { 'time' => $Time, date => $Date, event_description => $Message});
		}
		# If it is a warning then we use the "info" type.
		# If it isn't a warning, then we use the "warning" type to attempt to
		#  display the urgency of the notification better.
		my $DialogType = $IsWarning ? 'info' : 'warning';
		my $NotifyDialog = Gtk2::MessageDialog->new (undef,
							'destroy-with-parent',
							$DialogType,
							'none',
							$MainText);
		my $WindowIcon = DetectImage('dayplanner-48x48.png','dayplanner-32x32.png','dayplanner-24x24.png', 'dayplanner-16x16.png', 'dayplanner.png','dayplanner_HC48.png','dayplanner_HC24.png', 'dayplanner_HC16.png', );
		if ($WindowIcon) {
			$NotifyDialog->set_default_icon_from_file($WindowIcon);
		}
		# This is important, so set the urgency hint and keep it above all other windows
		# and on all desktops
		$NotifyDialog->set_keep_above(1);
		$NotifyDialog->stick;
		$NotifyDialog->set_title($i18n->get('Day Planner event'));
		$NotifyDialog->set_skip_pager_hint(0);
		$NotifyDialog->set_skip_taskbar_hint(0);
		$NotifyDialog->set_default_size(300,-1);
		$NotifyDialog->set_type_hint('dialog');
		$NotifyDialog->set_deletable(0);
		if(Gtk2->CHECK_VERSION(2,10,0)) {
			$NotifyDialog->set_urgency_hint(1);
		}
		
		my $Tooltips = Gtk2::Tooltips->new();
		my $PostponeButton = Gtk2::Button->new_with_label($i18n->get('Remind me later'));
		$PostponeButton->can_default(1);
		my $PostponeImage = Gtk2::Image->new_from_stock('gtk-go-forward','button');
		$PostponeButton->set_image($PostponeImage);
		$NotifyDialog->add_action_widget($PostponeButton, 'reject');
		$PostponeButton->show();
		$Tooltips->enable();
		$Tooltips->set_tip($PostponeButton, $i18n->get('Postpone this notification for 10 minutes'));
	
		my $OkayButton = Gtk2::Button->new_from_stock('gtk-ok');
		$OkayButton->can_default(1);
		$NotifyDialog->add_action_widget($OkayButton, 'accept');
		$OkayButton->show();

		$NotifyDialog->set_default_response('reject');
		
		# If the $Fulltext is defined and set then allow the user to be shown it
		if(defined($Fulltext) and $Fulltext =~ /\S/) {
			# The expander
			my $FT_Expander = CreateDetailsWidget();;
			$NotifyDialog->vbox->add($FT_Expander);
			# The textview field
			my $FulltextView = Gtk2::TextView->new();
			$FulltextView->set_editable(0);
			$FulltextView->set_wrap_mode('word-char');
			$FulltextView->show();
			# Add the text to it
			my $FulltextBuffer = Gtk2::TextBuffer->new();
			$FulltextBuffer->set_text($Fulltext);
			$FulltextView->set_buffer($FulltextBuffer);
			# Create a scrollable window to use
			my $FulltextWindow = Gtk2::ScrolledWindow->new;
			$FulltextWindow->set_policy('automatic', 'automatic');
			$FulltextWindow->add($FulltextView);
			$FulltextWindow->show();
			# Add it to the expander
			$FT_Expander->add($FulltextWindow);
		}
		# Display, get the reply and destroy (this sounds a bit violent - I assure you no widgets will be hurt)
		my $GtkReply = $NotifyDialog->run;
		$NotifyDialog->destroy();
		# Flush the display before sleeping
		Gtk2->main_iteration while Gtk2->events_pending;
		if($GtkReply eq 'reject') {
			sleep(60*10);
		} else {
			return(1);
		}
	}
}

# Purpose: Notify the user using a nongraphical system
# Usage: NonGUINotifier();
sub NonGUINotifier {
	my ($sysname, $nodename, $release, $version, $machine) = POSIX::uname();
	unless($sysname eq 'Linux') {
		print "*** dayplanner-notifier ($Version): Not running under GNU/Linux but attempting to use the NonGUINotifier. This might not work as expected if a non-GNU compatible who is installed\n";
	}
	# Discussion follows
	#
	# To further up the ability of the daemon to use the GtkNotifier, perhaps the dayplanner
	# process could use a new HI format: HI client $ENV{DISPLAY} so that the daemon can set
	# its own DISPLAY variable more properly - that is, set it to the DISPLAY variable of
	# the last client started. Maybe rather add a command "ADD_DISPLAY" to the daemon.
	# Perhaps also a dayplanner-daemon --add-display command, which
	# adds the current DISPLAY or the display supplied to the display pool
	my $MainText;
	if($Date eq 'today') {
		$MainText = $i18n->get_advanced("Today at %(time):\n%(event_description)", { 'time' => $Time, 'event_description' => $Message});
	} elsif ($Date eq 'tomorrow') {
		$MainText = $i18n->get_advanced("Tomorrow at %(time):\n%(event_description)", { 'time' => $Time, 'event_description' => $Message});
	} else {
		$MainText = $i18n->get_advanced("At %(time) on %(date):\n%(event_description)", { 'time' => $Time, date => $Date, event_description => $Message});
	}
	unless(WriteTo(cuserid(), $MainText)) {
		print "---\n$MainText\n---\n";
	}
}

# Purpose: Write a message to all writable terminals owned by the user supplied
# Usage: my $Return = WriteTo(USER, MESSAGE);
# 	The return value is 0 if nothing was written anywhere, 1 if something was
# 	written.
sub WriteTo {
	my ($User,$Message) = @_;
	return(0) unless InPath('who');
	return(0) unless InPath('write');
	my $Return = 0;
	open(my $WHO, 'who -T|');
	my @WritableDevs;
	while(<$WHO>) {
		next unless s/^$User\s+//;
		chomp;
		my $Writable;

		if(s/^\+\s+//) {
			s/^(\S+).+/$1/;
			push(@WritableDevs, $_);
		}
	}
	close($WHO);
	foreach my $Target (@WritableDevs) {
		open(my $WRITE, "|write $User $Target");
		print $WRITE "$Message";
		close($WRITE);
		$Return = 1;
	}
}

# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
# INITIALIZATION
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
GetOptions (
	'help|h' => sub {
		print "Day Planner notifier version $Version\n";
		print "This program is for use by the dayplanner daemon.\n\n";
		print "Use a --time/--message/--date (and --fulltext) combo if you want to call it manually,\n";
		print "the daemon uses --uid and --calendar.\n\n";
		PrintHelp('-h','--help','Display this help screen');
		PrintHelp('-v', '--version', 'Display version information and exit');
		PrintHelp('-m', '--message', 'Set the message');
		PrintHelp('-f', '--fulltext', 'Set the "fulltext" entry (details)');
		PrintHelp('-t', '--time', 'Set the time (HH:MM)');
		PrintHelp('-d', '--date', 'Set the date (a date string, "today" or "tomorrow")');
		PrintHelp('-c', '--calendar', 'Set the iCalendar file');
		PrintHelp('', '--uid', 'Set the iCalendar UID to display');
		PrintHelp('-n', '--nofork', 'Don\'t go into the background');
		exit(0);
	},
	'message|m=s' => \$Message,
	'time|t=s' => \$Time,
	'fulltext|f=s' => \$Fulltext,
	'date|d=s' => \$Date,
	'uid=s' => \$MessageID,
	'n|nofork' => \$NoFork,
	'c|calendar=s' => \$CalendarPath,
	'v|version' => sub {
		print "Day Planner notifier version $Version\n";
		exit(0);
	},
	# For backwards compatibility
	's|i|id|socket' => sub
	{
		print "$_[0]: deprecated.\n";
		# Exit with a nonzero return value - the daemon will then continue to look for another notifier
		exit(1);
	},
) or die "See $0 --help for more information\n";

# Verify various things
unless(defined($MessageID) and length($MessageID)) {
	die "I need a --time\n" unless defined($Time) and length($Time);
	die "I need a --message\n" unless defined($Message) and length($Message);
	die "I need a --date\n" unless defined($Date) and length($Date);
}

if(defined($MessageID)) {
	die("Needs a --calendar\n") if not $CalendarPath;
	die("$CalendarPath: does not exist\n") if not -e $CalendarPath;
	die("$CalendarPath: is not readable\n") if not -r $CalendarPath;
}

unless($NoFork) {
	# Okay, we're here now, fork
	my $PID = fork;
	exit if $PID;
	die "I was unable to fork: $!\n" unless defined($PID);
}

if(defined($MessageID)) {
	GetMessagesFromCalendar($CalendarPath,$MessageID);
}

NotifyUser();
__END__
=head1 NAME

Day Planner notifier - notifier program for Day Planner.

=head1 SYNOPSIS

B<dayplanner-notifier> [I<OPTIONS>]

=head1 DESCRIPTION

This is the Day Planner notifier. It is called by I<dayplanner-daemon(1)> to
display notifications of events to users.

It can also be used standalone in a script by using a combination of the
I<--message>, I<--time> and I<--date> (and optionally I<--fulltext>) options.

=head1 OPTIONS

=over

=item B<-h, --help>

Display the help screen.

=item B<-v, --version>

Display version information.

=item B<-m, --message> I<MESSAGE>

Set the message when in standalone mode.

=item B<-f, --fulltext> I<MESSAGE>

Set the fulltext when in standalone mode. This is optional
and is hidden by default.

=item B<-t, --time> I<TIME>

Set the time when in standalone mode. Should be HH:MM.

=item B<-t, --date> I<DATE>

Set the date when in standalone mode. Either use a numeric date,
or the strings 'today' or 'tomorrow' (which enables translation).

=item B<--dayplannerdir> I<DIR>

Use the directory I<DIR> instead of the default Day Planner configuration
directory. See also I<--test> and I<--confdir> in L<dayplanner(1)>.

=item B<-k, --kill>

Shut down the currently running daemon.

=item B<-n, --nofork> 

Don't fork. Stay in the foreground.

=back

=head1 HELP/SUPPORT

See L<http://www.day-planner.org/index.php/help>

=head1 AUTHOR

Eskild Hustvedt I<<eskild
at zerodogg
dot
org>>

=head1 FILES

See the FILES section in L<dayplanner(1)>

=head1 SEE ALSO

L<dayplanner(1)> L<dayplanner-daemon(1)>

=head1 LICENSE AND COPYRIGHT

Copyright (C) Eskild Hustvedt 2006-2012

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program.  If not, see L<http://www.gnu.org/licenses/>.
