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