#!/usr/bin/perl use v5.36; use utf8; use open qw(:std :encoding(UTF-8)); use autodie; use Feature::Compat::Try; use Feature::Compat::Class 0.07; use FindBin qw($Bin); use POSIX qw(locale_h); use locale; use Encode qw(decode_utf8); # TODO: modernize CalDAV access instead use Net::Netrc; use List::Util qw(first); use IO::Interactive::Tiny; use Log::Any qw($log); use Log::Any::Adapter; use URI; use IO::Prompter; use Cal::DAV; use Data::ICal::DateTime; use DateTime; use Path::Tiny; use Text::Xslate; if ( IO::Interactive::Tiny::is_interactive() ) { Log::Any::Adapter->set( 'Screen', default_level => 'info' ); } # set defaults and parse command-line options my ( $BASE_URI, $CALENDAR_URI, $SKELDIR, $OUTPUT_FILE ); $BASE_URI = $ENV{CAL_DAV_URL_BASE}; $CALENDAR_URI = $ENV{CAL_DAV_URL_CALENDAR}; $SKELDIR = $ENV{SKELDIR} || "$Bin/../templates"; $BASE_URI ||= shift @ARGV if @ARGV; $CALENDAR_URI ||= shift @ARGV if @ARGV; $OUTPUT_FILE = shift @ARGV if @ARGV; class Calendar { field $log = Log::Any->get_logger; # borrow from Data::ICal::new() signature field $data : param = undef; field $filename : param = undef; ADJUST { if ($data) { if ( $data isa Data::ICal ) { } else { $data = Data::ICal->new( data => $data ) } } elsif ($filename) { $data = Data::ICal->new( filename => $filename ) } if ( $log->is_trace ) { use DDP; p $data; } } # mimick Data::ICal::DateTime::events() signature method events ( $set = undef, $period = undef ) { $log->infof( 'will pick events between %s and %s', $set->start, $set->end ) if $set; my @events = sort { DateTime->compare( $a->begin, $b->begin ) || DateTime->compare( $a->end, $b->end ) || $a->summary cmp $b->summary } map { Event->new( entry => $_ ) } $data->events( $set || (), $period || () ); return @events; } } class Event { use Encode qw(decode_utf8); # TODO: modernize CalDAV access instead field $log = Log::Any->get_logger; field $entry : param; field $begin : reader = $entry->start; field $date_begin : reader = $begin->strftime('%A %e. %B'); field $time_begin : reader = $begin->strftime('%k.%M'); field $end : reader = $entry->end; field $date_end : reader; field $time_end : reader; field $datespan : reader; field $timespan : reader; field $time_brief : reader; field $summary : reader = $entry->property('summary') ? decode_utf8 $entry->property('summary')->[0]->value : ''; field $description : reader = $entry->property('description') ? decode_utf8 $entry->property('description')->[0]->value : ''; field $location : reader = $entry->property('location') ? decode_utf8 $entry->property('location')->[0]->value : ''; field $price : reader; field @attendees; field @attachments; ADJUST { if ( defined $end ) { $date_end = $end->strftime('%A %e. %B'); $time_end = $end->strftime('%k.%M'); } $datespan = ( defined $end and $date_end ne $date_begin ) ? ucfirst("$date_begin - $date_end") : ucfirst("$date_begin"); $timespan = ( defined $end and not $entry->all_day ) ? ucfirst("$date_begin kl. $time_begin-$time_end") : undef; $time_brief = $entry->all_day ? $datespan : ucfirst("$date_begin kl. $time_begin"); $description =~ s/\n\n[Pp]ris:\s*((?!\n).+)\s*\z//m; $price = $1; if ( $entry->property('attendee') ) { for ( @{ $entry->property('attendee') } ) { push @attendees, decode_utf8 $_->parameters->{'CN'} || $_->value =~ s/^mailto://r; } } if ( $entry->property('attach') ) { for ( @{ $entry->property('attach') } ) { my $uri; try { $uri = URI->new( $_->value ) } catch ($e) { $log->errorf( 'failed to parse URI %s: %s', $uri, $e ); next; } $uri->authority and $uri->host or next; push @attachments, $uri; } } if ( $log->is_trace ) { use DDP; p $entry; p $begin; p $end; } } method attendees { !!@attendees ? [@attendees] : undef } method attachments { !!@attachments ? [@attachments] : undef } } # use system locale to format DateTime objects parsed from iCal data DateTime->DefaultLocale( setlocale(LC_TIME) ); # resolve calendar URIs my ( $base_uri, $calendar_uri, $calendar ); $base_uri = URI->new($BASE_URI) if ($BASE_URI); $base_uri or $log->fatal('required base URI not provided') && exit 2; $base_uri->scheme or $base_uri->scheme('file'); if ( $base_uri->scheme eq 'http' or $base_uri->scheme eq 'https' ) { $log->infof( 'will use base URI %s', $base_uri ); $calendar_uri = URI->new( $CALENDAR_URI || $base_uri ); $calendar_uri and $calendar_uri->authority or $log->fatal('bad calendar URI: must be an internet URI') && exit 2; $base_uri->eq($calendar_uri) and $calendar_uri = undef or $log->infof( 'will use calendar URI %s', $calendar_uri ); # resolve credentials $log->debug('resolve credentials...'); my ( $mach, $user, $pass ); ( $user, $pass ) = split ':', $base_uri->userinfo if $base_uri->userinfo; $user ||= $ENV{CAL_DAV_USER}; $pass ||= $ENV{CAL_DAV_PASS}; $mach = Net::Netrc->lookup( $base_uri->host, $user ) if !$user or !$pass; if ($mach) { $user ||= $mach->login; $pass ||= $mach->password; $log->infof( 'will use .netrc provided credentials for user %s', $user ); } elsif ( IO::Interactive::Tiny::is_interactive() ) { $log->warn( 'will ask for missing info - this will fail in headless mode'); $user ||= prompt 'Enter your username'; $pass ||= prompt 'Enter your password', -echo => '*'; } $log->debugf( 'resolved credentials for user %s', $user ); # fetch and parse CalDAV calendar data $log->debug('fetch and parse CalDAV calendar data...'); my $session = Cal::DAV->new( user => $user, pass => $pass, url => $base_uri, ); $session->get($calendar_uri) if $calendar_uri; $calendar = Calendar->new( data => $session->cal ); } elsif ( $base_uri->scheme eq 'file' ) { defined $base_uri->file or $log->fatal('bad base URI: cannot open file') && exit 2; $log->infof( 'will use base URI %s', $base_uri ); # parse local calendar data $log->debug('parse local calendar data...'); my $path = path( $base_uri->file ); if ( $path->is_file ) { $calendar = Calendar->new( filename => "$path" ); } else { my $data; $path->visit( sub { $data .= $_->slurp_raw if $_->is_file } ); $calendar = Calendar->new( data => $data ); } } if ( $log->is_trace ) { use DDP; p $calendar; } # TODO: if list is empty and no calendar uri was explicitly supplied, # warn on stdout with list of abailable collections using this sequence: # 1. PROPFIND on base-URL for {DAV:}current-user-principal # 2. PROPFIND for calendar-home-set property in caldav namespace # 3. PROPFIND with depth: 1 # as documented at # select subset of calendar events $log->debug('serialize calendar events...'); my $start; if ( $ENV{CAL_DAV_NOW} ) { try { require DateTimeX::Easy } catch ($e) { $log->fatalf( 'failed parsing CAL_DAV_NOW: %s', $e ) && exit 2 } $start = DateTimeX::Easy->new( $ENV{CAL_DAV_NOW} ); $log->fatalf( 'failed parsing CAL_DAV_NOW: unknown start time "%s"', $ENV{CAL_DAV_NOW} ) && exit 2 unless defined $start; } $start ||= DateTime->now; my $end = $start->clone->add( months => 6 ); my $span = DateTime::Span->from_datetimes( start => $start, end => $end ); my @events = $calendar->events($span); # serialize calendar view my $output_path; if ($OUTPUT_FILE) { $output_path = path($OUTPUT_FILE); $output_path->parent->mkpath; $output_path->remove; } my %vars; for (@events) { next unless $_->summary; push @{ $vars{events} }, $_; } my %tmpl; $tmpl{list} = path($SKELDIR)->child('list.md')->slurp_utf8; my $template = Text::Xslate->new( path => \%tmpl, syntax => 'TTerse', type => 'text', ); my $content = $template->render( 'list', \%vars ); if ($output_path) { $output_path->append_utf8($content); } else { print $content; } 1;