#!/usr/bin/perl -w our $ID = q$Id: reminder,v 1.16 2012/03/04 05:18:36 eagle Exp $; # # reminder -- E-mail reminders of possibly periodic events. # # Copyright 2005, 2007, 2009, 2010, 2012 Russ Allbery # # This program is free software; you can redistribute it and/or modify it # under the same terms as Perl itself. ############################################################################## # Site configuration ############################################################################## # The data directory in which all the pending reminders are stored. our $DATA = "$ENV{HOME}/data/reminders"; ############################################################################## # Modules and declarations ############################################################################## require 5.006; use strict; use Date::Manip qw(ParseDate UnixDate DateCalc Date_Cmp); use Getopt::Long qw(GetOptions); use POSIX qw(strftime); ############################################################################## # Parsing reminders ############################################################################## # Returns all of the current reminders as a sorted list. sub reminders { opendir (D, $DATA) or die "$0: cannot open $DATA: $!\n"; my @numbers = grep { /^\d+$/ } readdir D; closedir D; return sort { $a <=> $b } @numbers; } # Returns the next available reminder number. sub next_reminder { my %numbers = map { $_ => 1 } reminders; my $i = 1; while ($numbers{$i}) { $i++; } return $i; } # Read a reminder, returning its information as a reference to a hash. The # body text of the reminder will be in the key __BODY__. sub read_reminder { my ($number) = @_; my %data; open (REM, "$DATA/$number") or die "$0: cannot open $DATA/$number: $!\n"; local $_; while () { last if /^\s*$/; if (/^(\S+): \s*(.*?)\s*$/) { my ($key, $value) = ($1, $2); next unless ($value =~ /\S/); $data{lc $key} = $value; } else { warn "$0:$number: cannot parse $_"; } } local $/; $data{__BODY__} = ; close REM; return \%data; } # Given a reference to the reminder data hash and a tag, returns true if that # reminder has that tag. sub has_tag { my ($data, $tag) = @_; return unless $data->{tags}; my %tags = map { $_ => 1 } split (' ', $data->{tags}); return $tags{$tag}; } ############################################################################## # Creating reminders ############################################################################## # Edit the reminder given by number, handling non-zero exit status from the # editor. If the error flag is true, prompt before editing even the first # time. Returns true on success, false on failure. sub edit_reminder { my ($number, $error) = @_; my $editor = $ENV{EDITOR} || 'vi'; do { if ($error) { my $resp; do { print "Abort, edit again, or quit (a/e/q)? "; $resp = lc ; chomp $resp; } while ($resp !~ /^[aeq]$/); if ($resp eq 'q') { exit } elsif ($resp eq 'a') { return 0 } } my $status = system ("$editor $DATA/$number"); $error = ($status != 0); } while ($error); return 1; } # Given the reminder number and the data hash, write the reminder back out to # disk (used to save any changes). sub save_reminder { my ($number, $data) = @_; open (NEW, '>', "$DATA/$number.new") or die "$0: cannot create $DATA/$number.new: $!\n"; for my $key (sort keys %$data) { next if $key eq '__BODY__'; print NEW ucfirst ($key), ": ", $$data{$key}, "\n"; } if ($$data{__BODY__}) { print NEW "\n"; print NEW $$data{__BODY__}; } close NEW; rename ("$DATA/$number.new", "$DATA/$number") or die "$0: cannot rename $DATA/$number.new: $!\n"; } # Validate the reminder, checking to be sure everything is syntactically okay. # Takes the data hash from read_reminder and prints a diagnostic if anything # is wrong (as well as returning false). sub valid_reminder { my ($data) = @_; unless ($$data{title}) { print "Reminder needs a title\n"; return; } unless ($$data{start}) { print "Reminder needs a starting date\n"; return; } my $date = ParseDate ($$data{start}); unless ($date) { print "Date in reminder cannot be parsed\n"; return; } if ($$data{repeat}) { my $error; $date = DateCalc ($$data{start}, "+ $$data{repeat}", \$error); unless ($date) { if ($error == 2) { $error = "invalid interval $$data{repeat}"; } elsif ($error == 3) { $error = 'date out of range'; } else { $error = 'unknown error'; } print "Repeat interval cannot be parsed: $error\n"; return; } } return 1; } # Creates a reminder and then spawns the user's configured editor to edit it. # After the user finishes, checks the date information in the reminder to be # sure that it produces the right results. sub create_reminder { my $number = next_reminder; open (REM, '>', "$DATA/$number") or die "$0: cannot create $DATA/$number: $!\n"; print REM "Title: \n"; print REM "Start: \n"; print REM "Repeat: \n"; print REM "Tags: \n"; print REM "\n"; close REM; # The start of the editing process. Label this so that we can jump back # to it if we need to based on user request. START: unless (edit_reminder ($number)) { unlink "$DATA/$number"; exit; } # Parse the data and then tell the user what we saw. my $data = read_reminder ($number); until (valid_reminder ($data)) { unless (edit_reminder ($number, 1)) { unlink "$DATA/$number"; exit; } $data = read_reminder ($number); } my $date = ParseDate ($$data{start}); print "\nTitle: $$data{title}\n"; print "First: ", UnixDate ($date, '%Y-%m-%d %T'), "\n"; if ($$data{repeat}) { my $error; my $next = DateCalc ($$data{start}, "+ $$data{repeat}"); print " Next: ", UnixDate ($next, '%Y-%m-%d %T'), "\n"; } print "\nIs this correct (Y/n)? "; my $resp = ; goto START if ($resp =~ /^n/i); # Now, add the username to the reminder for sending mail, canonicalize the # date, and write the reminder back out again. $$data{user} = getpwuid ($<); $$data{start} = UnixDate ($date, '%Y-%m-%d %T'); save_reminder ($number, $data); } ############################################################################## # Display reminders ############################################################################## # Print out a line for a single reminder. Takes the number and the data hash # of the reminder. sub list_reminder { my ($number, $data) = @_; my $title = substr ($$data{title}, 0, 50); my $repeat = $$data{repeat}; if ($repeat) { $repeat =~ s/^\s*\+//; } my $start; if ($$data{last}) { $start = DateCalc ($$data{last}, "+ $repeat"); } else { $start = ParseDate ($$data{start}); } $start = UnixDate ($start, '%Y-%m-%d'); my $next = ''; if ($repeat) { $next = UnixDate (DateCalc ($start, "+ $repeat"), '%Y-%m-%d'); } printf ("%4d %-50s %10s %10s\n", $number, $title, $start, $next); } # Print out a short list of all current reminders, giving their number, at # least a portion of their title, their start date, and their next occurance. # Optionally takes a tag to restrict the list to reminders with that tag. sub list_reminders { my ($tag) = @_; my @numbers = reminders; my @reminders; for my $number (@numbers) { my $data = read_reminder ($number); next unless ($$data{title} && $$data{start}); if (defined $tag) { next unless has_tag ($data, $tag); } my $start; if ($$data{last}) { my $repeat = $$data{repeat}; $repeat =~ s/^\s*\+//; $start = DateCalc ($$data{last}, "+ $repeat"); } else { $start = ParseDate ($$data{start}); } push (@reminders, [ $number, $start, $data ]); } @reminders = sort { $a->[1] cmp $b->[1] } @reminders; for my $reminder (@reminders) { list_reminder ($reminder->[0], $reminder->[2]); } } # Print out a list of all of the tags used by any reminder. sub list_tags { my @numbers = reminders; my %tags; for my $number (@numbers) { my $data = read_reminder ($number); next unless ($$data{title} && $$data{start} && $$data{tags}); for my $tag (split (' ', $$data{tags})) { $tags{$tag}++; } } for my $tag (sort keys %tags) { printf "%s (%s %s)\n", $tag, $tags{$tag}, ($tags{$tag} > 1 ? 'times' : 'time'); } } # Determine whether a given reminder, passed in as a data hash, is active. # The optional second argument is a time to use to determine if a reminder is # active. If not given, "now" is used. sub is_active { my ($data, $time) = @_; $time = 'now' unless defined $time; return unless (Date_Cmp ($$data{start}, $time) <= 0); if ($$data{last}) { return unless $$data{repeat}; my $repeat = $$data{repeat}; $repeat =~ s/^\s*\+//; my $next = DateCalc ($$data{last}, "+ $repeat"); return unless (Date_Cmp ($next, $time) <= 0); } return 1; } # Print out a short list of all active reminders, where active means that the # date is past the passed-in active date, and there is either no last date or # the last date plus the repeat is earlier than the passed-in active date. If # no date is passed in, the current date is used. sub active_reminders { my ($time) = @_; $time = 'now' unless defined $time; my @numbers = reminders; my @reminders; for my $number (@numbers) { my $data = read_reminder ($number); next unless ($$data{title} && $$data{start}); next unless is_active ($data, $time); my $start; if ($$data{last}) { my $repeat = $$data{repeat}; $repeat =~ s/^\s*\+//; $start = DateCalc ($$data{last}, "+ $repeat"); } else { $start = ParseDate ($$data{start}); } push (@reminders, [ $number, $start, $data ]); } @reminders = sort { $a->[1] cmp $b->[1] } @reminders; for my $reminder (@reminders) { list_reminder ($reminder->[0], $reminder->[2]); } } # Display a given reminder by number. Use a pager automatically if it's too # big to fit on one screen. sub show_reminder { my ($number) = @_; my $data = read_reminder ($number); my $output; for my $header (qw/Title Start Last Repeat/) { next unless $$data{lc $header}; $output .= sprintf ("%6s: %s\n", $header, $$data{lc $header}); } if ($$data{__BODY__}) { $output .= "\n$$data{__BODY__}"; } my $use_pager; if (-t STDOUT) { my $count = ($output =~ tr/\n/\n/); my $lines = $ENV{LINES} || ($ENV{TERMCAP} && ($ENV{TERMCAP} =~ /li\#(\d+):/)[0]) || (`stty -a` =~ /rows (\d+)/)[0] || 24; if ($count > $lines - 2) { my $pager = $ENV{PAGER} || 'more'; $SIG{PIPE} = 'IGNORE'; open (PAGER, "| $pager") or die "$0: cannot fork pager $pager: $!\n"; select PAGER; $use_pager = 1; } } $output = "\n$output\n" unless $use_pager; print $output; close PAGER if $use_pager; } # Center a given string on the screen. Assumes a default line width of 74 # characters, which can be overridden by the optional second argument. # Returns a string. sub center { my ($string, $width) = @_; $width = 74 unless $width; my $newline; $newline++ while chomp $string; my $spaces = ($width - length $string) / 2; $spaces = 0 if ($spaces < 0); $string = ' ' x $spaces . $string; if ($newline) { $string .= $/ x ($newline / length $/); } return $string; } # Mail all of the currently active reminders to the relevant users. sub mail_reminders { my @numbers = reminders; my %users; my ($sendmail) = grep { -x $_ } qw(/usr/sbin/sendmail /usr/lib/sendmail); $sendmail ||= '/usr/lib/sendmail'; my $date = strftime ('%Y-%m-%d', localtime); for my $number (@numbers) { my $data = read_reminder ($number); next unless ($$data{title} && $$data{start}); next unless is_active ($data); $$data{number} = $number; $users{$$data{user}} ||= []; push (@{ $users{$$data{user}} }, $data); } for my $user (keys %users) { my $version = (split (' ', $ID))[2]; open (MAIL, '|-', $sendmail, '-t', '-oi', '-oem') or die "$0: cannot fork $sendmail: $!\n"; my $sender = getpwuid $<; print MAIL "From: Reminder Service <$sender>\n"; print MAIL "Subject: Reminders for $date\n"; print MAIL "To: $user\n"; print MAIL "User-Agent: reminder/$version\n"; print MAIL "\n"; my @reminders = @{ $users{$user} }; for my $reminder (@reminders) { my $date; if ($$reminder{last}) { my $repeat = $$reminder{repeat}; $repeat =~ s/^\s*\+\s*//; $date = "$$reminder{last} + $repeat"; } else { $date = $$reminder{start}; } print MAIL center ("$$reminder{title} (#$$reminder{number})\n"); print MAIL center ("$date\n"); if ($$reminder{__BODY__}) { my $body = $$reminder{__BODY__}; print MAIL "\n$body"; print MAIL "\n" unless $body =~ /\n\s*$/; print MAIL "\n" unless $body =~ /\n\s*\n$/; } else { print MAIL "\n\n"; } } close MAIL; warn "$0: sending mail failed\n" unless ($? == 0); } } ############################################################################## # Editing reminders ############################################################################## # Delete a reminder. Takes the reminder number. sub delete_reminder { my ($number) = @_; unlink ("$DATA/$number") or die "$0: cannot delete $DATA/$number: $!\n"; } # Mark a reminder as having been completed. Takes the reminder number and # adds an appropriate Last header field. sub did_reminder { my ($number) = @_; my $data = read_reminder ($number); if ($$data{repeat}) { my $next = $$data{last} || $$data{start}; my $last; while (Date_Cmp ($next, 'now') <= 0) { $last = $next; my $repeat = $$data{repeat}; $repeat =~ s/^\s*\+//; $next = DateCalc ($next, "+ $repeat"); } $$data{last} = UnixDate ($last, '%Y-%m-%d %T'); save_reminder ($number, $data); } else { delete_reminder ($number); } } ############################################################################## # Main routine ############################################################################## $| = 1; my $fullpath = $0; $0 =~ s%.*/%%; # Parse command-line options. my ($help, $tag, $version); Getopt::Long::config ('bundling'); GetOptions ('h|help' => \$help, 't|tag=s' => \$tag, 'v|version' => \$version) or exit 1; if ($help) { print "Feeding myself to perldoc, please wait....\n"; exec ('perldoc', '-t', $fullpath); } elsif ($version) { my $version = join (' ', (split (' ', $ID))[1..3]); $version =~ s/,v\b//; $version =~ s/(\S+)$/($1)/; $version =~ tr%/%-%; print $version, "\n"; exit; } die "Usage: $0 []\n" if (@ARGV == 0 || @ARGV > 2); my $command = shift; # Take appropriate action based on the command. if ($command eq 'active') { die "Usage: $0 active\n" if @ARGV; active_reminders; } elsif ($command eq 'create' || $command eq 'new') { die "Usage: $0 create\n" if @ARGV; create_reminder; } elsif ($command eq 'canonicalize') { die "Usage: $0 canonicalize \n" unless @ARGV == 1; my $number = $ARGV[0]; my $data = read_reminder ($number); $$data{start} = UnixDate (ParseDate ($$data{start}), '%Y-%m-%d %T'); save_reminder ($number, $data); } elsif ($command eq 'delete') { die "Usage: $0 delete \n" unless @ARGV == 1; delete_reminder ($ARGV[0]); } elsif ($command eq 'did') { die "Usage: $0 did \n" unless @ARGV == 1; did_reminder ($ARGV[0]); } elsif ($command eq 'edit') { die "Usage: $0 edit \n" unless @ARGV == 1; my $number = $ARGV[0]; edit_reminder ($number) or exit; my $data = read_reminder ($number); until (valid_reminder ($data)) { edit_reminder ($number, 1) or exit; $data = read_reminder ($number); } save_reminder ($number, $data); } elsif ($command eq 'list') { die "Usage: $0 list\n" if @ARGV; list_reminders ($tag); } elsif ($command eq 'mail') { die "Usage: $0 mail\n" if @ARGV; mail_reminders; } elsif ($command eq 'next') { die "Usage: $0 next\n" if @ARGV; my $time = DateCalc ('now', '+ 1 day'); active_reminders ($time); } elsif ($command eq 'show') { die "Usage: $0 show \n" unless @ARGV == 1; show_reminder ($ARGV[0]); } elsif ($command eq 'tags') { list_tags; } else { die "$0: unknown command $command\n"; } exit 0; ############################################################################## # Documentation ############################################################################## =head1 NAME reminder - E-mail reminders of possibly periodic events =head1 SYNOPSIS reminder [B<-hv>] reminder ( create | new ) reminder ( active | mail | next | tags ) reminder [B<-t> I] list reminder show I reminder ( canonicalize | delete | did | edit ) I =head1 REQUIREMENTS Perl 5.6 or later and the Date::Manip module are required. The latter can be obtained from CPAN. =head1 DESCRIPTION B is a variation on the old Unix calendar(1) program, allowing a user to maintain a list of reminders for specific dates and displaying currently applicable reminders in a few different ways. It allows periodic reminders, continues to remind about something daily until the user indicates that reminder is "done," and supports an extremely flexible date specification syntax. The action to take is specified by the first word on the command line, which must be one of the following: =over 4 =item C Lists all active reminders (reminders whose date has passed and which have not been acknowledged with C). Displays the reminder number, the title, the date of the current reminder, and the date when it will next recur (if any). Reminders are sorted by their date. =item C I Perform the same operations on reminder I as would be done after the reminder was created (mainly rewriting the Start time into ISO format). This is normally not needed, and is primiarly useful only after editing the reminder files by hand. =item C =item C Create a new reminder. The new reminder will start with an empty body and empty Title, Start, and Repeat fields that the user can fill in. See L for the complete specification of the format of a reminder. After the user finishes editing the reminder, B will attempt to parse the dates. If that fails, the user will be prompted as to whether they want to edit the reminder again. Otherwise, they will be shown the dates as B understood them and be asked if that's correct. If there are any errors, the user is asked if they want to abort (delete the new reminder as if they never tried to create it), edit it again, or quit (leave the reminder there, even if it is invalid). =item C I Delete the reminder I completely, even if it is recurring. =item C I Mark the reminder I as acknowledged. If the reminder is not repeating, this will do the same thing as C. If the reminder is repeating, the last acknowledged date will be updated in the Last field, thus "resetting" the repeating reminder so that it won't recur until the Repeat interval has again passed. =item C I Edit the reminder I. After editing it, the same checks will be applied as after C. =item C Lists all reminders. Displays the reminder number, the title, the next time the reminder will trigger, and the date after that when it will recur (if any). Reminders that don't have a title or date (such as reminders that someone is in the process of creating) will be skipped. Reminders are sorted by their date. If a tag is specified with B<-t>, the listed reminders are restricted to those with that tag. =item C Mails all active reminders to the user specified in the (normally hidden) User field of the reminder. Normally one would run B with this option each morning from cron. This mail message comes from the user the reminder program is running as, and will have the User-Agent field set to C>, where I is the version of B (for easy e-mail sorting). =item C Like C but lists all active reminders and all reminders that will become active in the next day. Displays the reminder number, the title, the date of the current reminder, and the date when it will next recur (if any). Reminders are sorted by their date. =item C I Display the reminder I. Only the Title, Start, Last, and Repeat header fields, if present, will be shown. =item C List all of the tags used by any reminder. =back =head1 OPTIONS =over 4 =item B<-h>, B<--help> Print out this documentation (which is done simply by feeding the script to C. =item B<-t> I, B<--tag>=I Restricts application of the command to reminders with a particular tag. Currently, this only works in conjunction with the C command. =item B<-v>, B<--version> Print out the version of B and exit. =back =head1 FORMAT A reminder is stored on disk in a format very similar to an e-mail message, with a series of headers starting with a keyword, a colon, a space, and a value, followed by a blank line and then the body of the reminder. Unlike with an e-mail message, header lines may not be continued and are always a single line. The body is completely free-form and may be omitted. The following header fields are allowed: =over 4 =item Title The title of the reminder, displayed in the C and C views and used as the heading for the reminder in the message sent by C. This field is required. =item Start The starting date of the reminder. If this is a one-time reminder (there is no Repeat field), this will be the date that the reminder becomes active, and the reminder will be deleted when acknowledged. If this is a repeating reminder, this is the first date that it will become active, and the reminder will then become active again every Repeat interval after Start. This date can be in just about any format that one wishes. For a complete list of formats, see L. =item Repeat How often the reminder repeats. This must specify an interval like C<4 days> or C<1 year> or C<7 months>. For a complete list of valid formats, see L. If this field is present, the reminder will become active again every multiple of Repeat after Start. When a reminder becomes active again does not depend on when it was last acknowledged. =item Last Added by the C command and not generally specified by the user, this gives the date of the last time this reminder became active and was acknowledged. It is some multiple of Repeat after Start, not the date that the C command was issued. It is used as the basis for determining when the reminder will become active again. =item Tags A space-separated list of tags applicable to that reminder. This can be used to restrict the reminders shown by the C command. =item User Added automatically by C, this specifies the user to which the reminder applies and is used to determine where to send mail with the C command. It is not really used at the moment beyond that, but is present for future work on multi-user reminder setups. =back =head1 ENVIRONMENT =over 4 =item EDITOR The editor invoked by the C and C functions. If EDITOR is not set in the environemnt, B defaults to C. =back =head1 FILES =over 4 =item $HOME/data/reminders Where the reminders are stored. Each reminder will be a separate file in this directory whose file name is the number of that reminder. The reminder files can be edited by hand if desired, although it's better to use the C function so that the reminder will be sanity-checked and canonicalized after being edited. =back =head1 AUTHOR Russ Allbery =head1 COPYRIGHT AND LICENSE Copyright 2005, 2007, 2009, 2010, 2012 Russ Allbery . This program is free software; you may redistribute it and/or modify it under the same terms as Perl itself. =head1 SEE ALSO L L will have the current version of this program. =cut