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