#!/usr/bin/env perl
#===============================================================================
#
#         FILE: make_meeting
#
#        USAGE: ./make_meeting
#
#  DESCRIPTION: Makes a recurrent iCalendar meeting to be loaded into
#               a calendar. This is apparently necessary when the 'RRULE'
#               recurrence description is not adequate.
#
#      OPTIONS: None
# REQUIREMENTS: Needs modules Getopt::Long, Data::ICal, Date::ICal,
#               Date::Parse and Date::Calc
#         BUGS: ---
#        NOTES: Based on a script distributed with the HPR episode "iCalendar
#               Hacking"
#       AUTHOR: Dave Morriss (djm), Dave.Morriss@gmail.com
#      LICENCE: Copyright (c) year 2012-2025 Dave Morriss
#      VERSION: 0.2.4
#      CREATED: 2012-10-13 15:34:01
#     REVISION: 2025-10-23 16:51:48
#
#===============================================================================

use 5.010;
use strict;
use warnings;

use Getopt::Long;

use Data::ICal;
use Data::ICal::Entry::Event;
use Data::ICal::Entry::Todo;

use Date::Parse;
use Date::Calc qw{:all};
use Date::ICal;

#use Data::Dumper;

#
# Version number (manually incremented)
#
our $VERSION = '0.2.4';

#
# Script name
#
( my $PROG = $0 ) =~ s|.*/||mx;
( my $DIR  = $0 ) =~ s|/?[^/]*$||mx;
$DIR = '.' unless $DIR;

#
# Enable Unicode mode
#
binmode STDOUT, ":encoding(UTF-8)";
binmode STDERR, ":encoding(UTF-8)";

#-------------------------------------------------------------------------------
# Declarations
#-------------------------------------------------------------------------------
my ( @startdate, @rdate, @events );

#
# Attributes for the calendar message
#
my $server = 'chatter.skyehaven.net';
my $port   = 64738;

#-------------------------------------------------------------------------------
# Options and arguments
#-------------------------------------------------------------------------------
my $DEF_COUNT   = 12;
#my $DEF_SUMMARY = 'Send out CNews email';

#
# Process options
#
my %options;
Options( \%options );

#
# Default help
#
usage() if ( $options{'help'} );

#
# Collect options
#
my $count    = ( defined( $options{count} ) ? $options{count} : $DEF_COUNT );
my $reminder = ( defined( $options{reminder} ) ? $options{reminder} : 0 );
my $force    = ( defined( $options{force} )    ? $options{force}    : 0 );
my $outfile  = $options{output};

#my $reminder_summary = ( defined( $options{summary} ) ? $options{summary} :
#    $DEF_SUMMARY );

#
# Two reminders: 8 days ahead reminder to check with Ken, 5 days ahead
# reminder to send out the email.
#
my %reminders = (
    email => [ -5, 'Send out CNews email' ],
    check => [ -8, 'Check CNews date with Ken' ],
);

#
# Use the date provided or the default
#
if ( defined( $options{from} ) ) {
    #
    # Parse the date, convert to start of month and (optionally) validate it
    #
    @startdate = convert_date( $options{from}, $force );
}
else {
    #
    # Use the current date
    #
    @startdate = Today();
}

#
# Open the output file (or STDOUT)
#
my $outfh;
if ($outfile) {
    open( $outfh, ">:encoding(UTF-8)", $outfile )
        or die "Unable to open $outfile for writing: $!\n";
}
else {
    open( $outfh, ">&", \*STDOUT )
        or die "Unable to initialise for writing: $!\n";
}

#
# Date and time values
#
# TODO: These should be in a configuration file, and should ideally be capable
# of having a time zone defined (default UTC, as now).
#
my $monday = 1;    # Day of week number 1-7, Monday-Sunday
my $offset = -3;   # Offset from the target date (-3 is Friday)

my @starttime = ( 15, 00, 00 );    # UTC
my @endtime   = ( 17, 00, 00 );

my @todostart = (  9, 00, 00 );    # UTC
my @todoend   = ( 17, 00, 00 );

#
# Format of an ISO UTC datetime
#
my $fmt = "%02d%02d%02dT%02d%02d%02dZ";

#
# Constants for the event
#
my $calname     = 'HPR Community News';
my $timezone    = 'UTC';
my $location    = "$server port: $port";
my $summary     = 'HPR Community News Recording Dates';
my $description = <<ENDDESC;
Mumble settings
-------------------
Server Name:    Anything you like
Server Address: $server
Port:           $port
Name:           Your name or alias is fine

Information about Mumble can be found here:
http://hackerpublicradio.org/recording.php
ENDDESC

#
# Compute the next recording date from the starting date.
# Now @startdate will be today's date or the start of the explicitly selected
# month provided via -from=DATE. We want day of the week to be Monday, the
# first in the month, then to go back $offset days from that to get to the
# recording day! Simple.
#
@startdate = make_date( \@startdate, $monday, 1, $offset );
@rdate = @startdate;

#
# Create the calendar object
#
my $calendar = Data::ICal->new();

#
# Some calendar properties
#
$calendar->add_properties(
    'X-WR-CALNAME'  => $calname,
    'X-WR-TIMEZONE' => $timezone,
);

#
# Create the event object
#
my $vevent = Data::ICal::Entry::Event->new();

#
# Add some event properties
#
$vevent->add_properties(
    summary     => $summary,
    location    => $location,
    description => $description,
    dtstart     => sprintf( $fmt, @startdate, @starttime ),
    dtend       => sprintf( $fmt, @startdate, @endtime ),
);

#
# Add recurring dates. (Note that this generates RDATE entries rather than
# 1 entry with multiple dates; this is because this module doesn't seem to
# have the ability to generate the concatenated entry. The two modes of
# expressing the repeated dates seem to be equivalent.)
#
for my $i ( 1 .. $count ) {
    #
    # Recording date computation from the start of the month
    #
    @rdate = make_date( \@rdate, $monday, 1, $offset );

    #
    # Save the current recording date to make an array of arrayrefs
    #
    push( @events, [@rdate] );

    #
    # Add this date to the multi-date event
    #
    $vevent->add_property( rdate =>
            [ sprintf( $fmt, @rdate, @starttime ), { value => 'DATE-TIME' } ],
    );

    #
    # Increment the meeting date for the next one. If we're early in the month
    # by one day otherwise to the beginning of the next month. This is
    # necessary because otherwise make_date will skip months.
    #
    if ( $rdate[2] < 7 ) {
        @rdate = Add_Delta_Days( @rdate, 1 );
    }
    else {
        @rdate = ( ( Add_Delta_YM( @rdate, 0, 1 ) )[ 0 .. 1 ], 1 );
    }
}

#
# Add the event into the calendar
#
$calendar->add_entry($vevent);

#
# Are we to add reminders?
#
if ($reminder) {
    #
    # Loop through the cache of recording dates
    #
    for my $i ( 0 .. $count - 1 ) {
        #
        # Loop through the reminders hash
        #
        for my $key (keys(%reminders)) {
            #
            # A new Todo entry each iteration
            #
            my $vtodo = Data::ICal::Entry::Todo->new();

            #
            # Get a recording date from the cache and subtract 5 days from it to
            # get the preceding Monday
            #
            @rdate = @{ $events[$i] };
            @rdate = Add_Delta_Days( @rdate, $reminders{$key}->[0] );

            #
            # Add the date as the date part of the Todo
            #
            $vtodo->add_properties(
                summary => $reminders{$key}->[1],
                status  => 'INCOMPLETE',
                dtstart => Date::ICal->new(
                    ical => sprintf( $fmt, @rdate, @todostart )
                )->ical,
                due => Date::ICal->new(
                    ical => sprintf( $fmt, @rdate, @todoend )
                )->ical,
            );

            #
            # Add to the calendar
            #
            $calendar->add_entry($vtodo);

        }
    }

}

#
# Print the result
#
print $outfh $calendar->as_string;

exit;

#===  FUNCTION  ================================================================
#         NAME: convert_date
#      PURPOSE: Convert a textual date (ideally YYYY-MM-DD) to a Date::Calc
#               date for the start of the given month.
#   PARAMETERS: $textdate       date in text form
#               $force          Boolean defining whether to skip validating
#                               the date
#      RETURNS: The start of the month in the textual date in Date::Calc
#               format
#  DESCRIPTION: Parses the date string and makes a Date::Calc date from the
#               result where the day part is forced to be 1. Optionally
#               checks that the date isn't in the past, though $force
#               = 1 ignores this check.
#       THROWS: No exceptions
#     COMMENTS: Requires Date::Calc and Date::Parse
#               Note the validation 'die' has a non-generic message
#     SEE ALSO: N/A
#===============================================================================
sub convert_date {
    my ( $textdate, $force ) = @_;

    my ( @today, @parsed, @startdate );

    #
    # Reference date
    #
    @today = Today();

    #
    # Parse and perform rudimentary validation on the $textdate date. Function
    # 'strptime' returns "($ss,$mm,$hh,$day,$month,$year,$zone,$century)".
    #
    # The Date::Calc date $startdate[0] gets the returned year or the current
    # year if no year was parsed, $startdate[1] gets the parsed month or the
    # current month if no month was parsed, and $startdate[2] gets a day of 1.
    #
    @parsed    = strptime($textdate);
    die "Unable to parse date '$textdate'\n" unless @parsed;

    @startdate = (
        ( defined( $parsed[5] ) ? $parsed[5] + 1900 : $today[0] ),    # year
        ( defined( $parsed[4] ) ? $parsed[4] + 1 : $today[1] ), 1
    );

    #
    # Unless we've overridden the check there should be a positive or zero
    # difference in days between the target date and today's date to prevent
    # going backwards in time.
    #
    unless ($force) {
        unless ( Delta_Days( @today[ 0, 1 ], 1, @startdate ) ge 0 ) {
            warn "Invalid date $textdate (in the past)\n";
            die "Use -force to create a back-dated calendar\n";
        }
    }

    return @startdate;

}

#===  FUNCTION  ================================================================
#         NAME: make_date
#      PURPOSE: Make the event date for recurrence
#   PARAMETERS: $refdate        An arrayref to the reference date array
#                               (usually today's date)
#               $dow            Day of week for the event date (1-7, 1=Monday)
#               $n              The nth day of the week ($dow) in the given
#                               month required for the event date ($dow=1,
#                               $n=1 means first Monday)
#               $offset         Number of days to offset the computed date
#      RETURNS: The resulting date as a list for Date::Calc
#  DESCRIPTION: We want to compute a simple date with an offset, such as
#               "the Sunday before the first Monday of the month". We do
#               this by computing a pre-offset date (first Monday of month)
#               then apply the offset (Sunday before).
#       THROWS: No exceptions
#     COMMENTS: TODO Needs more testing to be considered truly universal
#     SEE ALSO:
#===============================================================================
sub make_date {
    my ( $refdate, $dow, $n, $offset ) = @_;

    #
    # Compute the required date: the "$n"th day of week "$dow" in the year and
    # month in @$refdate. This could be a date in the past.
    #
    my @date = Nth_Weekday_of_Month_Year( @$refdate[ 0, 1 ], $dow, $n );

    #
    # If the computed date plus the offset is before the base date advance
    # a month
    #
    if ( Day_of_Year(@date) + $offset < Day_of_Year(@$refdate) ) {
        #
        # Add a month and recompute
        #
        @date = Add_Delta_YM( @date, 0, 1 );
        @date = Nth_Weekday_of_Month_Year( @date[ 0, 1 ], $dow, $n );
    }

    #
    # Apply the day offset
    #
    @date = Add_Delta_Days( @date, $offset ) if $offset;

    #
    # Return a list
    #
    return (@date);
}

#===  FUNCTION  ================================================================
#         NAME: ISO8601_Date
#      PURPOSE: Format a Date::Calc date in ISO8601 format
#   PARAMETERS: @date   - a date in the Date::Calc format
#      RETURNS: Text string containing a YYYY-MM-DD date
#  DESCRIPTION: Just a convenience to allow a simple call like
#               $str = ISO8601_Date(@date)
#       THROWS: No exceptions
#     COMMENTS: None
#     SEE ALSO: N/A
#===============================================================================
sub ISO8601_Date {
    my (@date) = (@_)[ 0, 1, 2 ];

    if ( check_date(@date) ) {
        return sprintf( "%04d-%02d-%02d", @date );
    }
    else {
        return "*Invalid Date*";
    }
}

#===  FUNCTION  ================================================================
#         NAME: usage
#      PURPOSE: Display a usage message and exit
#   PARAMETERS: None
#      RETURNS: To command line level with exit value 1
#  DESCRIPTION: Builds the usage message using global values
#       THROWS: no exceptions
#     COMMENTS: none
#     SEE ALSO: n/a
#===============================================================================
sub usage {
    print STDERR <<EOD;
Usage: $PROG [options]

$PROG v$VERSION

Makes a recurrent iCalendar meeting to be loaded into a calendar. Optionally
adds reminders in the form of TODO items in relation to each meeting.

    -help               Display this information
    -from=DATE          Start date for the calendar
    -count=N            Number of entries; default 12
    -[no]force          Allow a -from=DATE date before today; default not
    -[no]reminder       Add a reminder TODO item; default no
    -output=FILE        Name of file to write to; optional, writes to STDOUT
                        if omitted

EOD
#    -summary=TEXT       Alternative text for the reminder (default 'Send out
#                        CNews email')
    exit(1);
}

#===  FUNCTION  ================================================================
#         NAME: Options
#      PURPOSE: Processes command-line options
#   PARAMETERS: $optref     Hash reference to hold the options
#      RETURNS: Undef
#  DESCRIPTION:
#       THROWS: no exceptions
#     COMMENTS: none
#     SEE ALSO: n/a
#===============================================================================
sub Options {
    my ($optref) = @_;

    my @options
        = ( "help", "from=s", "count=i", "force!", "reminder!", "output=s" );
#        "summary|rs=s" );

    if ( !GetOptions( $optref, @options ) ) {
        usage();
    }

    return;
}

# vim: syntax=perl:ts=8:sw=4:et:ai:tw=78:fo=tcrqn21:fdm=marker
