#!/usr/bin/perl
# Day Planner notifier
# Sends a notification to the user
# Copyright (C) Eskild Hustvedt 2006
# $Id: dayplanner-notifier 1224 2007-03-25 11:01:14Z zero_dogg $
#
# 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 2
# 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, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.

use strict;			# Force strict coding
use warnings;			# Tell perl to display warnings
use Gtk2;			# Use Gtk2
use Getopt::Long;		# Commandline options
use IO::Socket;			# Network layer to communicate with the daemon
use Locale::gettext;		# Translation layer
use POSIX;			# setlocale()
use Cwd;
use File::Basename;
use FindBin;

my $Gettext;			# Global Gettext object
my $Version = "0.5.1";
my $RCSRev = '$Id: dayplanner-notifier 1224 2007-03-25 11:01:14Z zero_dogg $';
my $SoftwareDir = dirname(Cwd::realpath($0));

my $SocketName;			# The socket to connect to
my $Socket;			# The IO::Socket object
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 $MessageID;			# The daemon message ID
my $NoFork;

# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
# I18n functions (Day Planner Locale::gettext wrapper)
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

# Purpose: Wrapper around the gettext functions that does the right thing(tm)
# Usage: DP_gettext(STANDARD_GETTEXT_SYNTAX);
sub DP_gettext {
	if($DP_I18N_Mode == 1) {	# 1 is Legacy
		return(gettext(@_));
	} elsif ($DP_I18N_Mode == 2 ) {	# 2 is new Locale::gettext OO-interface
		return($Gettext->get(@_));
	} else {			# Neither of those means it's not available, just return
					# a scalar of the supplied data.
		my $String;
		foreach(@_) {
			$String .= $_;
		}
		return($String);
	}
}

# Purpose: Initialize the i18n subsystem
# Usage: DP_InitI18n();
sub DP_InitI18n {
	setlocale(LC_ALL, "" );
	if(eval("use Locale::gettext;1")) {
		my $BindTo;
		my $Legacy;
		if($Locale::gettext::VERSION > "1.04") {
			$Legacy = 0;
		} else {
			$Legacy = 1;
		}
		if(defined($ENV{DP_FORCE_LEGACY_I18N}) and $ENV{DP_FORCE_LEGACY_I18N} eq "1") {
			$Legacy = 1;
		}
		# Find out if we have a locale directory in our main dir
		if (-d "$FindBin::RealBin/locale/") {
			if(defined($ENV{LC_ALL}) or defined($ENV{LANG})) {
				my $I18N;
				if(defined($ENV{LC_ALL}) and length($ENV{LC_ALL})) {
					$I18N = $ENV{LC_ALL};
				} else {
					$I18N = $ENV{LANG};
				}
				if($I18N =~ /:/) {
					$I18N =~ s/^(.+):.*$/$1/;
				}
				if (-e "$FindBin::RealBin/locale/$I18N/LC_MESSAGES/dayplanner.mo") {
					$BindTo = "$FindBin::RealBin/locale";
				}
			}
		}
		# Set up the i18n fetching type
		if($Legacy) {
			DPIntWarn("Using legacy Locale::gettext. This will work, but is not officially supported and you may have some issues with certain accented characters");
			$DP_I18N_Mode = 1;
			bindtextdomain("dayplanner", "$FindBin::RealBin/locale");
			textdomain("dayplanner");
			# This is a hack, but works in many cases
			foreach my $EnvVar(qw(LANG LC_CTYPE LC_ALL LC_ADDRESS LC_NAME)) {
				bind_textdomain_codeset("dayplanner","ISO-8859-15");
			}
		} else {
			$DP_I18N_Mode = 2;
			$Gettext = Locale::gettext->domain("dayplanner");	# Set the gettext domain
			if($BindTo) {
				$Gettext->dir($BindTo);
			}
		}
	} else {
		$DP_I18N_Mode = 0;
		# No Locale::Gettext available
		DPIntWarn("Locale::gettext is not available. This will work, but localization will *not* be available");
	}
}

# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
# DAEMON COMMUNICATION
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

# Purpose: Connect to the daemon
# Usage: DaemonConnect();
sub DaemonConnect {
	$Socket = IO::Socket::UNIX->new(Peer => $SocketName,
					Type => SOCK_STREAM,
					Timeout => 10 )
					or DPIntWarn("Unable to connect to $SocketName: $@!") and return(0);
	if(Daemon_DataSegment("HI servant") eq "HI") {
		return(1);
	} else {
		DPIntWarn("Unable to connect to $SocketName: The daemon refused my connection\n");
		close($Socket);
		return("NOHI");
	}

}

# Purpose: Send some data to the daemon
# Usage: Daemon_SendData(DATA);
sub Daemon_SendData {
	print $Socket "$$ $_[0]","\n" or DPIntWarn("Problem sending data to the daemon: $!") and return(0);
}

# Purpose: Get some data from the daemon
# Usage: $Data = Daemon_GetData();
sub Daemon_GetData {
	my $Data = <$Socket>;
	unless(defined($Data)) {
		return("ERR UNABLE_TO_GET_DATA");
	}
	if ($Data =~ /^BEGIN MULTILINE/) {
		return(UnwrapMultiLinePacket());
	} else {
		chomp($Data);
		return($Data);
	}
}

# Purpose: Send something to the daemon and then return the daemons reply
# Usage: $Reply = Daemon_DataSegment(DATA);
sub Daemon_DataSegment {
	Daemon_SendData($_[0]);
	return(Daemon_GetData());
}

# Purpose: Disconnect from the daemon
# Usage: DaemonDisconnect();
sub DaemonDisconnect {
	Daemon_DataSegment("BYE");
	close($Socket);
}

# Purpose: Unwrap multiline packets correctly
# Usage: my $Multiline = UnwrapMultiLinePacket();
sub UnwrapMultiLinePacket {
	my $MultiLineNotEnded = 1;
	my $NewMessage;
	my $UnhandledLimit;
	my $DidSomething;
	my $DidNotDoAnything;
	while ($MultiLineNotEnded) {
		$DidSomething = 0;
		foreach(split(/\n/,<$Socket>)) {
			$DidSomething = 1;
			if (/^END MULTILINE/) {
				$MultiLineNotEnded = 0;
				last;
			}elsif (s/^\s*ML //) {
				chomp;
				if(defined($NewMessage)) {
					$NewMessage = $NewMessage . "$_\n";
				} else {
					$NewMessage = "$_\n";
				}
			} elsif (/^NNL$/) {
				# This means the preceeding line doesn't end with \n
				unless(defined($NewMessage)) {
					DPIntWarn("Recieved NNL while unwrapping a multiline packet without any preceeding line. This is a bug in either the daemon or notifier. Ignoring request and proceeding");
					next;
				}	
				$NewMessage =~ s/\n$//;
			} else {
				chomp($_);
				DPIntWarn("Unhandled daemon command: $_\n");
				$UnhandledLimit++;
			}
			if(defined($UnhandledLimit) and $UnhandledLimit >= 20) {
				DPIntWarn("Limit of 20 unhandled daemon commands within UnwrapMultiLinePacket exceeded. Assuming END MULTILINE");
				$MultiLineNotEnded = 0;
				last;
			}
		}
		if($DidSomething == 0) {
			$DidNotDoAnything++;
		} else {
			$DidNotDoAnything = 0;
		}
		if(defined($DidNotDoAnything) and $DidNotDoAnything >= 20) {
			DPIntWarn("Limit of 20 idle turns through the UnwrapMultiLinePacket loop exceeded. Assuming END MULTILINE");
			$MultiLineNotEnded = 0;
			last;
		}
	}
	unless(defined($NewMessage)) {
		DPIntWarn("\$NewMessage was undefined while unwrapping a multiline packet. This is probably a bug. Returning \"\" scalar string value to caller");
		return("");
	}
	unless($NewMessage =~ /\n.*\n/) {
		chomp($NewMessage);
	}
	return($NewMessage);
}

# Purpose: Get the messages from the daemon
# Usage: GetMessages(Message ID);
sub GetMessages {
	unless(Daemon_DataSegment("NOTIFICATION VERIFY $_[0]") eq 'SET') {
		die("The MessageID $_[0] is invalid according to the daemon\n");
	}
	
	$Message = Daemon_DataSegment("NOTIFICATION GET $_[0] Summary");
	$Time = Daemon_DataSegment("NOTIFICATION GET $_[0] Time");
	$Date = Daemon_DataSegment("NOTIFICATION GET $_[0] Date");
	$Fulltext = Daemon_DataSegment("NOTIFICATION GET $_[0] Fulltext");
	$IsWarning = Daemon_DataSegment("NOTIFICATION GET $_[0] PreNot");
	if($IsWarning eq "$_[0] UNDEF") {
		$IsWarning = 0;
	}

	# Fulltext is a multiline packet and can thus return "" on failure.
	# We assume $_[0] UNDEF on ""/failure
	if($Fulltext eq "$_[0] UNDEF" or $Fulltext eq "") {
		$Fulltext = undef;
	}

	Daemon_DataSegment("NOTIFICATION CLEAN $_[0]");
}

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

# Purpose: Create the details widget
# Usage: my $ExpanderWidget = CreateDetailsWidget();
sub CreateDetailsWidget {
	my $FT_Expander = Gtk2::Expander->new(DP_gettext("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(DP_gettext('Hide details'));
			} else {
				$FT_Expander->set_label(DP_gettext('Show details'));
			}
		});
	return($FT_Expander);
}

# Purpose: Display a nicely formatted warning
# Usage: DPIntWarn("Warning");
sub DPIntWarn {
	warn "*** (Day Planner notifier $Version) Warning: $_[0]\n";
}

# 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(1);
		} else {
			return(0);
		}
	} else {
		# Fall back to using the ugly system()-based test.

		# This statement might look a bit weird, but remember that for commandline
		# programs 0 is true and anything else is false.
		if(system("perl -e 'use Gtk2; open(STDERR, \">/dev/null\"); Gtk2->init;'")) {
			return(0);
		} else {
			return(1);
		}
	}
}

# Purpose: Find out if a command is in PATH or not
# Usage: InPath(COMMAND);
sub InPath {
	foreach (split /:/, $ENV{PATH}) { if (-x "$_/@_" and ! -d "$_/@_" ) {   return 1; } } return 0;
}


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

# Purpose: Find out how to notify the user and do it
# Usage: NotifyUser();
sub NotifyUser {
	DP_InitI18n();
	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 = sprintf(DP_gettext("Today at %s:\n%s"),$Time,$Message);
		} elsif ($Date eq 'tomorrow') {
			$MainText = sprintf(DP_gettext("Tomorrow at %s:\n%s"),$Time,$Message);
		} else {
			$MainText = sprintf(DP_gettext("At %s on %s:\n%s"),$Time,$Date,$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);
		# 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(DP_gettext("Day Planner event"));
		$NotifyDialog->set_skip_pager_hint(0);
		$NotifyDialog->set_skip_taskbar_hint(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(DP_gettext('Postpone'));
		$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, DP_gettext("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 = sprintf(DP_gettext("Today at %s:\n%s"),$Time,$Message);
	} elsif ($Date eq 'tomorrow') {
		$MainText = sprintf(DP_gettext("Tomorrow at %s:\n%s"),$Time,$Message);
	} else {
		$MainText = sprintf(DP_gettext("At %s on %s:\n%s"),$Time,$Date,$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 --id.\n\n";
		PrintHelp("-h","--help","Display this help screen");
		PrintHelp("-v", "--version", "Display version information and exit");
		PrintHelp("-s","--socket","Select the daemon socket");
		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("-i", "--id", "Set the daemon message ID");
		PrintHelp("-n", "--nofork", "Don't go into the background");
		exit(0);
	},
	'socket|s=s' => \$SocketName,
	'message|m=s' => \$Message,
	'time|t=s' => \$Time,
	'fulltext|f=s' => \$Fulltext,
	'date|d=s' => \$Date,
	'id|i=s' => \$MessageID,
	'n|nofork' => \$NoFork,
	'v|version' => sub {
		print "Day Planner notifier version $Version\n";
		print "RCS revision: $RCSRev\n";
		exit(0);
	},
) 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 "I need a --socket to connect to\n" unless defined($SocketName) and length($SocketName);
	die "$SocketName does not exist\n" unless -e $SocketName;
	die "$SocketName is not a socket\n" unless -S $SocketName;
}

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)) {
	DaemonConnect();
	GetMessages($MessageID);
}

NotifyUser();
if(defined($MessageID)) {
	DaemonDisconnect();
}
