aboutsummaryrefslogtreecommitdiff
path: root/bin/events2md.pl
blob: a83fd13894514371b579e2fba788920142b528b8 (plain)
  1. #!/usr/bin/perl
  2. use v5.36;
  3. use strict;
  4. use utf8;
  5. use open qw(:std :encoding(UTF-8));
  6. use autodie;
  7. use Feature::Compat::Try;
  8. use Feature::Compat::Class 0.07;
  9. use FindBin qw($Bin);
  10. use POSIX qw(locale_h);
  11. use locale;
  12. use Encode qw(decode_utf8); # TODO: modernize CalDAV access instead
  13. use Net::Netrc;
  14. use List::Util qw(first);
  15. use IO::Interactive::Tiny;
  16. use Log::Any qw($log);
  17. use Log::Any::Adapter;
  18. use URI;
  19. use IO::Prompter;
  20. use Cal::DAV;
  21. use Data::ICal::DateTime;
  22. use DateTime;
  23. use Path::Tiny;
  24. use Text::Xslate;
  25. if ( IO::Interactive::Tiny::is_interactive() ) {
  26. Log::Any::Adapter->set( 'Screen', default_level => 'info' );
  27. }
  28. # set defaults and parse command-line options
  29. my ( $BASE_URI, $CALENDAR_URI, $SKELDIR, $OUTPUT_FILE );
  30. $BASE_URI = $ENV{CAL_DAV_URL_BASE};
  31. $CALENDAR_URI = $ENV{CAL_DAV_URL_CALENDAR};
  32. $SKELDIR = $ENV{SKELDIR} || "$Bin/../templates";
  33. $BASE_URI ||= shift @ARGV
  34. if @ARGV;
  35. $CALENDAR_URI ||= shift @ARGV
  36. if @ARGV;
  37. $OUTPUT_FILE = shift @ARGV
  38. if @ARGV;
  39. class Event {
  40. use Encode qw(decode_utf8); # TODO: modernize CalDAV access instead
  41. field $log = Log::Any->get_logger;
  42. field $entry : param;
  43. field $begin : reader = $entry->start;
  44. field $date_begin : reader = $begin->strftime('%A %e. %B');
  45. field $time_begin : reader = $begin->strftime('%k.%M');
  46. field $end : reader = $entry->end;
  47. field $date_end : reader;
  48. field $time_end : reader;
  49. field $datespan : reader;
  50. field $timespan : reader;
  51. field $time_brief : reader;
  52. field $summary : reader
  53. = $entry->property('summary')
  54. ? decode_utf8 $entry->property('summary')->[0]->value
  55. : '';
  56. field $description : reader
  57. = $entry->property('description')
  58. ? decode_utf8 $entry->property('description')->[0]->value
  59. : '';
  60. field $location : reader
  61. = $entry->property('location')
  62. ? decode_utf8 $entry->property('location')->[0]->value
  63. : '';
  64. field $price : reader;
  65. field @attendees;
  66. field @attachments;
  67. ADJUST {
  68. if ( defined $end ) {
  69. $date_end = $end->strftime('%A %e. %B');
  70. $time_end = $end->strftime('%k.%M');
  71. }
  72. $datespan
  73. = ( defined $end and $date_end ne $date_begin )
  74. ? ucfirst("$date_begin - $date_end")
  75. : ucfirst("$date_begin");
  76. $timespan
  77. = ( defined $end and not $entry->all_day )
  78. ? ucfirst("$date_begin kl. $time_begin-$time_end")
  79. : undef;
  80. $time_brief
  81. = $entry->all_day
  82. ? $datespan
  83. : ucfirst("$date_begin kl. $time_begin");
  84. $description =~ s/\n\n[Pp]ris:\s*((?!\n).+)\s*\z//m;
  85. $price = $1;
  86. if ( $entry->property('attendee') ) {
  87. for ( @{ $entry->property('attendee') } ) {
  88. push @attendees, decode_utf8 $_->parameters->{'CN'}
  89. || $_->value =~ s/^mailto://r;
  90. }
  91. }
  92. if ( $entry->property('attach') ) {
  93. for ( @{ $entry->property('attach') } ) {
  94. my $uri;
  95. try { $uri = URI->new( $_->value ) }
  96. catch ($e) {
  97. $log->errorf( 'failed to parse URI %s: %s', $uri, $e );
  98. next;
  99. }
  100. $uri->authority and $uri->host
  101. or next;
  102. push @attachments, $uri;
  103. }
  104. }
  105. if ( $log->is_trace ) {
  106. use DDP;
  107. p $entry;
  108. p $begin;
  109. p $end;
  110. }
  111. }
  112. method attendees { !!@attendees ? [@attendees] : undef }
  113. method attachments { !!@attachments ? [@attachments] : undef }
  114. }
  115. # use system locale to format DateTime objects parsed from iCal data
  116. DateTime->DefaultLocale( setlocale(LC_TIME) );
  117. # resolve calendar URIs
  118. my ( $base_uri, $calendar_uri, $calendar );
  119. $base_uri = URI->new($BASE_URI)
  120. if ($BASE_URI);
  121. $base_uri
  122. or $log->fatal('required base URI not provided') && exit 2;
  123. $base_uri->scheme
  124. or $base_uri->scheme('file');
  125. if ( $base_uri->scheme eq 'http' or $base_uri->scheme eq 'https' ) {
  126. $log->infof( 'will use base URI %s', $base_uri );
  127. $calendar_uri = URI->new( $CALENDAR_URI || $base_uri );
  128. $calendar_uri and $calendar_uri->authority
  129. or $log->fatal('bad calendar URI: must be an internet URI') && exit 2;
  130. $base_uri->eq($calendar_uri) and $calendar_uri = undef
  131. or $log->infof( 'will use calendar URI %s', $calendar_uri );
  132. # resolve credentials
  133. $log->debug('resolve credentials...');
  134. my ( $mach, $user, $pass );
  135. ( $user, $pass ) = split ':', $base_uri->userinfo
  136. if $base_uri->userinfo;
  137. $user ||= $ENV{CAL_DAV_USER};
  138. $pass ||= $ENV{CAL_DAV_PASS};
  139. $mach = Net::Netrc->lookup( $base_uri->host, $user )
  140. if !$user or !$pass;
  141. if ($mach) {
  142. $user ||= $mach->login;
  143. $pass ||= $mach->password;
  144. $log->infof(
  145. 'will use .netrc provided credentials for user %s',
  146. $user
  147. );
  148. }
  149. elsif ( IO::Interactive::Tiny::is_interactive() ) {
  150. $log->warn(
  151. 'will ask for missing info - this will fail in headless mode');
  152. $user ||= prompt 'Enter your username';
  153. $pass ||= prompt 'Enter your password', -echo => '*';
  154. }
  155. $log->debugf( 'resolved credentials for user %s', $user );
  156. # fetch and parse CalDAV calendar data
  157. $log->debug('fetch and parse CalDAV calendar data...');
  158. $calendar = Cal::DAV->new(
  159. user => $user,
  160. pass => $pass,
  161. url => $base_uri,
  162. );
  163. $calendar->get($calendar_uri)
  164. if $calendar_uri;
  165. }
  166. elsif ( $base_uri->scheme eq 'file' ) {
  167. defined $base_uri->file
  168. or $log->fatal('bad base URI: cannot open file') && exit 2;
  169. $log->infof( 'will use base URI %s', $base_uri );
  170. # parse local calendar data
  171. $log->debug('parse local calendar data...');
  172. my $path = path( $base_uri->file );
  173. if ( $path->is_file ) {
  174. $calendar = Data::ICal->new( data => $path->slurp_raw );
  175. }
  176. else {
  177. my $data;
  178. $path->visit( sub { $data .= $_->slurp_raw if $_->is_file } );
  179. $calendar = Data::ICal->new( data => $data );
  180. }
  181. }
  182. if ( $log->is_trace ) {
  183. use DDP;
  184. p $calendar;
  185. }
  186. # TODO: if list is empty and no calendar uri was explicitly supplied,
  187. # warn on stdout with list of abailable collections using this sequence:
  188. # 1. PROPFIND on base-URL for {DAV:}current-user-principal
  189. # 2. PROPFIND for calendar-home-set property in caldav namespace
  190. # 3. PROPFIND with depth: 1
  191. # as documented at <https://stackoverflow.com/a/11673483>
  192. # serialize calendar events
  193. $log->debug('serialize calendar events...');
  194. my $start;
  195. if ( $ENV{CAL_DAV_NOW} ) {
  196. try { require DateTimeX::Easy }
  197. catch ($e) {
  198. $log->fatalf( 'failed parsing CAL_DAV_NOW: %s', $e ) && exit 2
  199. }
  200. $start = DateTimeX::Easy->new( $ENV{CAL_DAV_NOW} );
  201. $log->fatalf(
  202. 'failed parsing CAL_DAV_NOW: unknown start time "%s"',
  203. $ENV{CAL_DAV_NOW}
  204. )
  205. && exit 2
  206. unless defined $start;
  207. }
  208. $start ||= DateTime->now;
  209. my $end = $start->clone->add( months => 6 );
  210. $log->infof( 'will pick events between %s and %s', $start, $end );
  211. my $span = DateTime::Span->from_datetimes( start => $start, end => $end );
  212. my @events = sort {
  213. DateTime->compare( $a->begin, $b->begin )
  214. || DateTime->compare( $a->end, $b->end )
  215. || $a->summary cmp $b->summary
  216. } map { Event->new( entry => $_ ) } $calendar->events($span);
  217. if ( $log->is_trace ) {
  218. use DDP;
  219. p @events;
  220. }
  221. my $output_path;
  222. if ($OUTPUT_FILE) {
  223. $output_path = path($OUTPUT_FILE);
  224. $output_path->parent->mkpath;
  225. $output_path->remove;
  226. }
  227. my %vars;
  228. for (@events) {
  229. next unless $_->summary;
  230. push @{ $vars{events} }, $_;
  231. }
  232. my %tmpl;
  233. $tmpl{list} = path($SKELDIR)->child('list.md')->slurp_utf8;
  234. my $template = Text::Xslate->new(
  235. path => \%tmpl,
  236. syntax => 'TTerse',
  237. type => 'text',
  238. );
  239. my $content = $template->render( 'list', \%vars );
  240. if ($output_path) {
  241. $output_path->append_utf8($content);
  242. }
  243. else {
  244. print $content;
  245. }
  246. 1;