aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.perlcriticrc4
-rw-r--r--.perltidyrc21
-rw-r--r--.tidyallrc7
-rw-r--r--ADMIN.md57
-rw-r--r--README.md34
-rw-r--r--TODO.md6
-rw-r--r--USE.md66
-rwxr-xr-xbin/events2md.pl246
-rwxr-xr-xbin/events2semesterplan.pl208
-rw-r--r--lib/Object/Groupware.pm45
-rw-r--r--lib/Object/Groupware/Calendar.pm65
-rw-r--r--lib/Object/Groupware/DAV.pm91
-rw-r--r--lib/Object/Groupware/Event.pm147
-rw-r--r--templates/list.md32
-rw-r--r--templates/semesterplan-list.events7
-rw-r--r--templates/semesterplan.tex12
16 files changed, 967 insertions, 81 deletions
diff --git a/.perlcriticrc b/.perlcriticrc
new file mode 100644
index 0000000..4e08e03
--- /dev/null
+++ b/.perlcriticrc
@@ -0,0 +1,4 @@
+[-Subroutines::ProhibitSubroutinePrototypes]
+
+[TestingAndDebugging::RequireUseStrict]
+equivalent_modules = strictures
diff --git a/.perltidyrc b/.perltidyrc
new file mode 100644
index 0000000..2e08554
--- /dev/null
+++ b/.perltidyrc
@@ -0,0 +1,21 @@
+# use best practices, except use of stdout
+--perl-best-practices
+--no-standard-output
+--no-standard-error-output
+
+# use TAB for lead indentation
+--tabs
+--entab-leading-whitespace=4
+-nola
+
+# indent only already indented comments
+--indent-spaced-block-comments
+
+# put brace on new line for named subroutines
+--opening-sub-brace-on-new-line
+
+# preserve horisontally styled lists
+--break-at-old-comma-breakpoints
+
+# overwrite (we use CVS), and leave backup only on error
+--backup-file-extension=/~
diff --git a/.tidyallrc b/.tidyallrc
new file mode 100644
index 0000000..4bcb16c
--- /dev/null
+++ b/.tidyallrc
@@ -0,0 +1,7 @@
+[PerlTidy]
+select = **/*.{pl,pm,t}
+;select = bin/*
+argv = --profile=$ROOT/.perltidyrc
+
+[PerlCritic]
+select = lib/**/*.pm
diff --git a/ADMIN.md b/ADMIN.md
index 3895db2..663a11f 100644
--- a/ADMIN.md
+++ b/ADMIN.md
@@ -1,4 +1,61 @@
+# Administrating calendar events
+
+This system supports a limited subset of [iCalendar] and [CalDAV] standards,
+and is tested to work with Apple and Mozilla clients.
+
+The CalDAV service uses [Radicale].
+
+[CalDAV]: https://en.wikipedia.org/wiki/CalDAV
+ "CalDAV - Internet standard allowing a client to access scheduling information on a remote server"
+
+[Radicale]: https://radicale.org/
+ "Radicale - Free and Open-Source CalDAV and CardDAV Server"
+
+[iCalendar]: https://en.wikipedia.org/wiki/ICalendar
+ "iCalendar - media type which allows users to store and exchange calendaring and scheduling information"
+
+
+## Recurrences
+
+Recurring events can be too complex to handle for some CalDAV applications.
+
+Recurrence rules are stored in iCalendar field [RRULES].
+
+Locating which events may be cause of problem is tricky,
+because recurrence rules may exist not only directly tied to the event
+but also embedded in timezone objects for the event
+(which is commonly not problematic for applications).
+
Quick'n'dirty locating events containing recurrence rules,
except (most likely) summertime rules:
grep -rP 'RRULE:FREQ=(?!YEARLY(;UNTIL=19430919T000000Z)?;(BYMONTH=(2|3|4|9|10|11);BYDAY=(-1|1|2|3)SU|BYDAY=(-1|1|2|3)SU;BYMONTH=(2|3|4|9|10|11)))'
+
+[RRULES]: <https://devguide.calconnect.org/iCalendar-Topics/Recurrences/>
+ "RRULES - iCalendar data field to express recurrence rules"
+
+
+## Scheduling
+
+Scheduling of events is currently *not* processed -
+invitation and FreeBusy/Availability hints are ignored.
+
+Calendaring applications may request invitations for events,
+but this is currently ignored in the CalDAV service
+(technically this is CalDAV extensions [iTIP] and [iMIP]).
+
+Calendaring applications may query for FreeBusy or Availability of resources,
+but this is currently rejected by the CalDAV service
+(technically this may involve CalDAV extensions [VFREEBUSY] or [VAVAILABILITY]).
+
+[iTIP]: <https://devguide.calconnect.org/Scheduling/iTIP/>
+ "iTIP - specification on handling participant invites for CalDAV events"
+
+[iMIP]: <https://devguide.calconnect.org/Scheduling/iMIP/>
+ "iMIP - specification on distributing iTIP invites via email"
+
+[VFREEBUSY]: <https://devguide.calconnect.org/Scheduling/FreeBusyAvailability/>
+ "VFREEBUSY - specification on sharing free/busy time of resources"
+
+[VAVAILABILITY]: <https://datatracker.ietf.org/doc/html/rfc7953>
+ "VAVAILABILITY - specification on sharing (potentially) available time of resources"
diff --git a/README.md b/README.md
index 1fe0fef..84e7e82 100644
--- a/README.md
+++ b/README.md
@@ -1,29 +1,27 @@
-# Shared calendars and publishing events
+# Syncronizing and sharing calendar events _(event)_
-Decentral event planning and publishing tools.
-Publish or share calendars within a group of people or publish events to a feed or a web page.
+Calendars hold events and tasks
+(tasks, a.k.a. todo items, are events without time or date assigned).
-## Scheduling of calendar events _(event)_
+Each user can create private calendars tied to their personal account.
-iCalendar specifies a file format for events.
+Shared calendars can be created for groups of users.
-CalDAV specifies how to manage events.
+Private and shared calendars can be exported as read-only calendar files
+e.g. for publishing on a website.
-iCalendar spec is relatively simple, and widely supported.
-CalDAV spec is very big and complex, however,
-and no system supports all of it.
+Technically,
+private and shared calendars is a [CalDAV] service using [Radicale],
+and public calendars are [iCalendar] files.
-This system supports a limited subset of CalDAV
-tested to work with Apple and Mozilla clients.
-Currently non-shared calendars are supported,
-without iTIP or iMIP.
-
-It is possible to extend our current system with iTIP/iMIP support,
-but that has not yet been explored.
-
-Technically, the CalDAV service uses [Radicale].
+[CalDAV]: https://en.wikipedia.org/wiki/CalDAV
+ "CalDAV - Internet standard allowing a client to access scheduling information on a remote server"
[Radicale]: https://radicale.org/
+ "Radicale - Free and Open-Source CalDAV and CardDAV Server"
+
+[iCalendar]: https://en.wikipedia.org/wiki/ICalendar
+ "iCalendar - media type which allows users to store and exchange calendaring and scheduling information"
## Invites
diff --git a/TODO.md b/TODO.md
index f98b869..fca42ea 100644
--- a/TODO.md
+++ b/TODO.md
@@ -18,3 +18,9 @@ Pending:
+ Remove CalDAV directory for sharegroup owner
* Document personal calendaring
* Document shared calendaring
+
+Ideas:
+
+ * Wordcloud per month (kurt)
+ <https://datavizcatalogue.com/methods/wordcloud.html>
+ * two-way exchange with calcurse data (kurt)
diff --git a/USE.md b/USE.md
index c13048d..d73fca1 100644
--- a/USE.md
+++ b/USE.md
@@ -21,7 +21,7 @@ eventhost: event.example.org
Recommended calendaring client depends on the operating system you use:
* Mac, iPhone and iPad: Apple Calendar
- * Windows and Linux: [Mozilla Lightning](https://www.mozilla.org/en-US/projects/calendar/)
+ * Windows and Linux: [Mozilla Thunderbird](https://www.thunderbird.net/)
* Android: [DAVdroid](https://f-droid.org/app/at.bitfire.davdroid)
and [ICSdroid](https://f-droid.org/app/at.bitfire.icsdroid)
@@ -46,74 +46,14 @@ To both read a calendar and help maintain its content,
you need to setup a **CalDAV** profile:
1. Open calendaring client, and add new account profile of type "CalDAV".
- 2. Server address: **event.example.org:8443**
+ 2. Server address: **event.example.org**
3. Username and password: Either personal or calendar-group account credentials.
If above fails, or if you want to specifically pick a single calender,
use this alternative approach:
1. Copy address of relevant calendar from web page
- **https://event.example.org:8443/calendars/users/USERNAME/**
+ **https://event.example.org/**
(replace USERNAME with either personal or calendar-group account name)
2. Open calendaring client, and add new account profile of type "CalDAV".
3. Paste the calendar address as server address.
-
-
-## FIXME
-
-FIRST: open Calender -
-Under Calendar>Preferences>Accounts find the old kpstaff calendar,
-mark it and hit the - in the button to delete it,
-It is not working anymore and can not be used.
-
-THEN: set up the new one following the steps below.
-
-Step 1:
-Under Calendar>Preferences>Accounts create a new account by pressing the "+"
-and select "Other CalDAV Account"
-
-If it says 'AUTOMATIC' change it to \'91MANUAL\'92 in the top drop down menu.
-
-Step 2:
-Fill in the following:
-
-Username: Your Username
-Password: Your Password
-
-URL: <https://event.homebase.dk/
-
-press "add"
-
-All team calendars + staff and room booking calendar should now pop up.
-And we can all edit them (students cannot of course).
-
-How to create an additional calendar?
-
-Step 3: Press the "+" icon in the bottom left of the screen:
-you now have a new calendar.
-Name it what you want by double clicking on it and editing it.
-
-This new Calendar is personal and is not shared to other,
-but is synchronised between your own devices.
-
-If you want let's say a new Team xx calendar to be read/write for all staff,
-then create it and write <mailto:teknik@lists.homebase.dk>
-and ask to make it available for all staff.
-
-How do the students see the calendar(s)?
-
-Go to <http://event.homebase.dk/>,
-copy the URL to the calendar(s) they need,
-and mail it to them,
-then in Calendar,
-by going to Calendar > Subscribe in the menu and pasting the URL
-or just dobbelclik the link you send (works most of the time).
-They still need to refresh to update
-and any changes you make will take 10 minutes to go across.
-
-For tech support, contact the teknik list.
-<teknik@lists.homebase.dk>
-
-Important: Never ever share passwords.
-Not to technicians, not to police.
-Never.
diff --git a/bin/events2md.pl b/bin/events2md.pl
new file mode 100755
index 0000000..76c6398
--- /dev/null
+++ b/bin/events2md.pl
@@ -0,0 +1,246 @@
+#!/usr/bin/perl
+
+use v5.36;
+use utf8;
+use open qw(:std :encoding(UTF-8));
+use Feature::Compat::Try;
+
+use FindBin qw($Bin);
+use lib "$Bin/../lib";
+
+# add bogus properties acknowledged uid to silence Cal::DAV
+package Data::ICal::Entry::Alarm::None {
+ use base qw/Data::ICal::Entry::Alarm/;
+
+ sub optional_unique_properties
+ {
+ qw(
+ duration repeat acknowledged uid
+ );
+ }
+}
+
+# add bogus properties acknowledged uid to silence Cal::DAV
+package Data::ICal::Entry::Alarm::Display {
+ use base qw/Data::ICal::Entry::Alarm/;
+
+ sub optional_unique_properties
+ {
+ qw(
+ duration repeat acknowledged uid
+ );
+ }
+}
+
+# add bogus properties acknowledged uid to silence Cal::DAV
+package Data::ICal::Entry::Alarm::Email {
+ use base qw/Data::ICal::Entry::Alarm/;
+
+ sub optional_unique_properties
+ {
+ qw(
+ duration repeat acknowledged uid
+ );
+ }
+}
+
+use Getopt::Complete (
+ 'quiet!' => undef,
+ 'verbose!' => undef,
+ 'debug!' => undef,
+ 'trace!' => undef,
+ 'output' => undef,
+ 'skeldir' => 'directories',
+ 'username' => undef,
+ 'password' => undef,
+ 'locale' => undef,
+ 'timezone' => undef,
+ '<>' => undef,
+);
+use IO::Interactive::Tiny;
+use Log::Any qw($log);
+use Log::Any::Adapter;
+use URI;
+use DateTime;
+use Path::Tiny;
+use Text::Xslate;
+use POSIX qw(locale_h); # resolve LC_TIME
+use locale;
+use DateTime::TimeZone;
+
+use Object::Groupware::DAV;
+use Object::Groupware::Calendar;
+
+# collect settings from command-line options and defaults
+my $SKELDIR = $ARGS{skeldir} || $ENV{SKELDIR} || "$Bin/../templates";
+my $BASE_URI = $ARGS{'<>'}[0] || $ENV{CAL_DAV_URL_BASE};
+my $CALENDAR_URI = $ARGS{'<>'}[1] || $ENV{CAL_DAV_URL_CALENDAR};
+my $USERNAME = $ARGS{username} || $ENV{CAL_DAV_USER};
+my $PASSWORD = $ARGS{password} || $ENV{CAL_DAV_PASS};
+my $LOCALE = $ARGS{locale} || $ENV{CAL_LANG};
+my $TIME_ZONE = $ARGS{timezone};
+my $OUTPUT_FILE = $ARGS{output};
+
+# init logging
+my $LOGLEVEL = 'warning';
+$LOGLEVEL = 'critical' if $ARGS{quiet};
+$LOGLEVEL = 'warning' if defined $ARGS{verbose} and !$ARGS{verbose};
+$LOGLEVEL = 'info' if $ARGS{verbose};
+$LOGLEVEL = 'debug' if $ARGS{debug};
+$LOGLEVEL = 'trace' if $ARGS{trace};
+if ( IO::Interactive::Tiny::is_interactive() ) {
+ Log::Any::Adapter->set( 'Screen', default_level => $LOGLEVEL );
+}
+else {
+ use Log::Any::Adapter ( 'Stderr', default_level => $LOGLEVEL );
+}
+
+# extend DateTime locale with form LONGER
+# * omit year and second
+# * unabbreviate weekday and month
+# * interpose time preposition in combined date and time, where known
+my %at = (
+ C => " 'at' ",
+ ar => " 'في' ",
+ da => " 'kl.' ",
+ de => " 'um' ",
+ en => " 'at' ",
+ es => " 'a las' ",
+ fr => " 'à' ",
+ he => " 'בשעה' ",
+ it => " 'alle' ",
+ ja => "'に'",
+ no => " 'kl.' ",
+ ru => " 'в' ",
+ zh => "'在'",
+);
+my $dt_locale = DateTime::Locale->load( $LOCALE || setlocale(LC_TIME) );
+my ( $locale, $lang ) = $dt_locale->code =~ /^((\w+)(?:-\w+)?)/;
+my $dt = DateTime->now( locale => $dt_locale );
+my %dt_locale_data = $dt_locale->locale_data;
+$dt_locale_data{code} = "${locale}-LONGER";
+$dt_locale_data{name} .= ' nouns unabbreviated';
+$dt_locale_data{date_format_medium} = $dt->locale->format_for('MMMMEd');
+$dt_locale_data{date_format_medium} ||= $dt->locale->format_for('MMMEd');
+$dt_locale_data{date_format_medium} =~ s/\bMMM\b/MMMM/;
+$dt_locale_data{date_format_medium} =~ s/\bMMM\b/MMMM/;
+$dt_locale_data{date_format_medium} =~ s/\bE\b/EEEE/;
+$dt_locale_data{time_format_medium} = $dt->locale->format_for('Hm');
+$dt_locale_data{datetime_format_medium}
+ =~ s/^\{1\}\K,? (?=\{0\}$)/$at{$lang}/
+ if $at{$lang};
+
+# init groupware settings
+my %GROUPWARE_OPTIONS = (
+ dt_locale => DateTime::Locale::FromData->new( \%dt_locale_data ),
+ dt_time_zone => DateTime::TimeZone->new(
+ name => ( $ARGS{timezone} || 'local' ),
+ ),
+);
+$log->infof(
+ 'Will use locale %s and time zone %s',
+ $GROUPWARE_OPTIONS{dt_locale}->code,
+ $GROUPWARE_OPTIONS{dt_time_zone}->name,
+);
+
+# init calendar URIs
+$BASE_URI = URI->new($BASE_URI)
+ or $log->fatal('failed to parse required base URI') && exit 2;
+$BASE_URI->scheme
+ or $BASE_URI->scheme('file');
+
+# get calendar
+my $calendar;
+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 );
+
+ my $session = Object::Groupware::DAV->new(
+ user => $USERNAME,
+ pass => $PASSWORD,
+ uri => $BASE_URI,
+ %GROUPWARE_OPTIONS,
+ );
+ $calendar = $session->get($CALENDAR_URI);
+}
+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 = Object::Groupware::Calendar->new(
+ filename => "$path",
+ %GROUPWARE_OPTIONS,
+ );
+ }
+ else {
+ my $data;
+ $path->visit( sub { $data .= $_->slurp_raw if $_->is_file } );
+ $calendar = Object::Groupware::Calendar->new(
+ data => $data,
+ %GROUPWARE_OPTIONS,
+ );
+ }
+}
+
+# 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
+if ($OUTPUT_FILE) {
+ $OUTPUT_FILE = path($OUTPUT_FILE);
+ $OUTPUT_FILE->parent->mkpath;
+ $OUTPUT_FILE->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_FILE) {
+ $OUTPUT_FILE->append_utf8($content);
+}
+else {
+ print $content;
+}
+
+1;
diff --git a/bin/events2semesterplan.pl b/bin/events2semesterplan.pl
new file mode 100755
index 0000000..2df85ad
--- /dev/null
+++ b/bin/events2semesterplan.pl
@@ -0,0 +1,208 @@
+#!/usr/bin/perl
+
+use v5.36;
+use utf8;
+use open qw(:std :encoding(UTF-8));
+use Feature::Compat::Try;
+
+use FindBin qw($Bin);
+use lib "$Bin/../lib";
+
+use Getopt::Complete (
+ 'quiet!' => undef,
+ 'verbose!' => undef,
+ 'debug!' => undef,
+ 'trace!' => undef,
+ 'output' => 'files',
+ 'skeldir' => 'directories',
+ 'username' => undef,
+ 'password' => undef,
+ 'locale' => undef,
+ 'timezone' => undef,
+ 'title' => undef,
+ '<>' => undef,
+);
+use IO::Interactive::Tiny;
+use Log::Any qw($log);
+use Log::Any::Adapter;
+use URI;
+use DateTime;
+use Path::Tiny 0.119 qw(path tempdir);
+use Text::Xslate;
+use POSIX qw(locale_h); # resolve LC_TIME
+use locale;
+use DateTime::TimeZone;
+use LaTeX::Encode qw(latex_encode);
+use LaTeX::Driver;
+
+use Object::Groupware::DAV;
+use Object::Groupware::Calendar;
+
+# collect settings from command-line options and defaults
+my $SKELDIR = $ARGS{skeldir} || $ENV{SKELDIR} || "$Bin/../templates";
+my $BASE_URI = $ARGS{'<>'}[0] || $ENV{CAL_DAV_URL_BASE};
+my $CALENDAR_URI = $ARGS{'<>'}[1] || $ENV{CAL_DAV_URL_CALENDAR};
+my $USERNAME = $ARGS{username} || $ENV{CAL_DAV_USER};
+my $PASSWORD = $ARGS{password} || $ENV{CAL_DAV_PASS};
+my $LOCALE = $ARGS{locale} || $ENV{CAL_LANG};
+my $TIME_ZONE = $ARGS{timezone};
+my $OUTPUT_FILE = $ARGS{output};
+
+# init logging
+my $LOGLEVEL = 'warning';
+$LOGLEVEL = 'critical' if $ARGS{quiet};
+$LOGLEVEL = 'warning' if defined $ARGS{verbose} and !$ARGS{verbose};
+$LOGLEVEL = 'info' if $ARGS{verbose};
+$LOGLEVEL = 'debug' if $ARGS{debug};
+$LOGLEVEL = 'trace' if $ARGS{trace};
+if ( IO::Interactive::Tiny::is_interactive() ) {
+ Log::Any::Adapter->set( 'Screen', default_level => $LOGLEVEL );
+}
+else {
+ use Log::Any::Adapter ( 'Stderr', default_level => $LOGLEVEL );
+}
+
+# init groupware settings
+my $dt_locale = DateTime::Locale->load( $LOCALE || setlocale(LC_TIME) );
+my %GROUPWARE_OPTIONS = (
+ dt_locale => $dt_locale,
+ dt_time_zone => DateTime::TimeZone->new(
+ name => ( $ARGS{timezone} || 'local' ),
+ ),
+);
+$log->infof(
+ 'Will use locale %s and time zone %s',
+ $GROUPWARE_OPTIONS{dt_locale}->code,
+ $GROUPWARE_OPTIONS{dt_time_zone}->name,
+);
+
+# init calendar URIs
+$BASE_URI = URI->new($BASE_URI)
+ or $log->fatal('failed to parse required base URI') && exit 2;
+$BASE_URI->scheme
+ or $BASE_URI->scheme('file');
+
+# get calendar
+my $calendar;
+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 );
+
+ my $session = Object::Groupware::DAV->new(
+ user => $USERNAME,
+ pass => $PASSWORD,
+ uri => $BASE_URI,
+ %GROUPWARE_OPTIONS,
+ );
+ $calendar = $session->get($CALENDAR_URI);
+}
+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 = Object::Groupware::Calendar->new(
+ filename => "$path",
+ %GROUPWARE_OPTIONS,
+ );
+ }
+ else {
+ my $data;
+ $path->visit( sub { $data .= $_->slurp_raw if $_->is_file } );
+ $calendar = Object::Groupware::Calendar->new(
+ data => $data,
+ %GROUPWARE_OPTIONS,
+ );
+ }
+}
+
+# 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 %vars;
+
+#$vars{metadata} = $calendar->metadata();
+$vars{name} = latex_encode( $ARGS{title} ) || '';
+for (@events) {
+ next unless $_->summary;
+ push @{ $vars{events} }, {
+ start_date => $_->start_date,
+ summary => latex_encode( $_->summary ),
+ };
+}
+
+my %tmpl;
+$tmpl{plan} = path($SKELDIR)->child('semesterplan.tex')->slurp_utf8;
+$tmpl{list} = path($SKELDIR)->child('semesterplan-list.events')->slurp_utf8;
+
+my $template = Text::Xslate->new(
+ path => \%tmpl,
+ syntax => 'TTerse',
+ type => 'text',
+);
+
+my $content_plan = $template->render( 'plan', \%vars );
+my $content_list = $template->render( 'list', \%vars );
+
+my $tempdir = tempdir( CLEANUP => !$log->is_debug );
+my $srcfile = $tempdir->child('plan.tex');
+my $pdffile = $tempdir->child('plan.pdf');
+if ( $log->is_debug ) {
+ $log->warnf(
+ '[debug] temporary directory %s will not be cleaned',
+ $tempdir
+ );
+}
+
+$srcfile->append_utf8($content_plan);
+$tempdir->child('list.events')->append_utf8($content_list);
+
+my $drv = LaTeX::Driver->new(
+ source => "$srcfile",
+ output => "$pdffile",
+ format => 'pdf(lualatex)',
+ DEBUG => $log->is_debug,
+ -capture_stderr,
+);
+
+$drv->run
+ or $log->fatalf( 'failed to generate PDF file: %s', $drv->stderr )
+ && exit 2;
+
+if ($OUTPUT_FILE) {
+ $OUTPUT_FILE = path($OUTPUT_FILE);
+ $OUTPUT_FILE->parent->mkpath;
+ $pdffile->copy($OUTPUT_FILE);
+}
+else {
+ print $pdffile->slurp_raw;
+}
+
+1;
diff --git a/lib/Object/Groupware.pm b/lib/Object/Groupware.pm
new file mode 100644
index 0000000..eac1bf7
--- /dev/null
+++ b/lib/Object/Groupware.pm
@@ -0,0 +1,45 @@
+use v5.36;
+
+#use Feature::Compat::Class 0.07;
+use Object::Pad 0.78;
+
+package Object::Groupware 0.01;
+
+class Object::Groupware;
+
+use utf8;
+
+use Log::Any qw( );
+
+field $log = undef;
+
+field $dt_locale : param : reader = undef;
+field $dt_time_zone : param : reader = undef;
+field $dt_span_time_prefix : param : reader = '';
+
+ADJUST {
+ # TODO: use Object::Pad 0.07 and move this to field initializer
+ $log = Log::Any->get_logger;
+
+ if ( defined $self->dt_locale
+ and not $self->dt_locale isa DateTime::Locale::FromData )
+ {
+ $dt_locale = DateTime::Locale->load( $self->dt_locale );
+ $log->debugf(
+ 'Coerced object %s locale: %s (%s)',
+ __CLASS__, $self->dt_locale->code, $self->dt_locale->name
+ );
+ }
+ if ( defined $self->dt_time_zone
+ and not $self->dt_time_zone isa DateTime::TimeZone )
+ {
+ $dt_time_zone
+ = DateTime::TimeZone->new( name => $self->dt_time_zone );
+ $log->debugf(
+ 'Coerced object %s time zone: %s',
+ __CLASS__, $self->dt_time_zone->name
+ );
+ }
+}
+
+1;
diff --git a/lib/Object/Groupware/Calendar.pm b/lib/Object/Groupware/Calendar.pm
new file mode 100644
index 0000000..8d62e44
--- /dev/null
+++ b/lib/Object/Groupware/Calendar.pm
@@ -0,0 +1,65 @@
+use v5.36;
+
+#use Feature::Compat::Class 0.07;
+use Object::Pad 0.78;
+
+package Object::Groupware::Calendar 0.01;
+
+class Object::Groupware::Calendar : isa(Object::Groupware);
+
+use utf8;
+
+use Log::Any qw( );
+use Data::ICal::DateTime;
+use Encode qw(decode_utf8);
+
+use Object::Groupware::Event;
+
+field $log = undef;
+
+# borrow from Data::ICal::new() signature
+field $data : param = undef;
+field $filename : param = undef;
+
+ADJUST {
+ # TODO: use Object::Pad 0.07 and move this to field initializer
+ $log //= Log::Any->get_logger;
+
+ if ($data) {
+ if ( $data isa Data::ICal ) { }
+ else { $data = Data::ICal->new( data => $data ) }
+ }
+ elsif ($filename) { $data = Data::ICal->new( filename => $filename ) }
+
+ $log->tracef(
+ "Object %s contents:\n%s", __CLASS__,
+ decode_utf8 $data->as_string
+ );
+}
+
+# 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 $dt_locale = $self->dt_locale;
+ my $dt_time_zone = $self->dt_time_zone;
+ my @events = map {
+ Object::Groupware::Event->new(
+ entry => $_,
+ dt_locale => $dt_locale,
+ dt_time_zone => $dt_time_zone,
+ )
+ } sort {
+ $a->start->compare( $b->start )
+ || $a->end ? $a->end->compare( $b->end ) : 0
+ || $a->summary cmp $b->summary
+ } $data->events( $set || (), $period || () );
+
+ return @events;
+}
+
+1;
diff --git a/lib/Object/Groupware/DAV.pm b/lib/Object/Groupware/DAV.pm
new file mode 100644
index 0000000..8487543
--- /dev/null
+++ b/lib/Object/Groupware/DAV.pm
@@ -0,0 +1,91 @@
+use v5.36;
+
+#use Feature::Compat::Class 0.07;
+use Object::Pad 0.78;
+
+package Object::Groupware::DAV 0.01;
+
+class Object::Groupware::DAV : isa(Object::Groupware);
+
+use utf8;
+
+use Feature::Compat::Try;
+
+use Net::Netrc;
+use IO::Interactive::Tiny;
+use Log::Any qw( );
+use URI;
+use IO::Prompter;
+use Cal::DAV;
+use DateTime;
+
+use Object::Groupware::Calendar;
+
+field $log = undef;
+
+field $uri : param;
+field $user : param = undef;
+field $pass : param = undef;
+
+field $session;
+
+ADJUST {
+ # TODO: use Object::Pad 0.07 and move this to field initializer
+ $log //= Log::Any->get_logger;
+
+ $uri = URI->new($uri)
+ or $log->fatal( 'failed to parse URI %s', $uri ) && exit 2;
+
+ $log->debug('resolve credentials...');
+ my $mach;
+ ( $user, $pass ) = split ':', $uri->userinfo
+ if !$user and $uri->userinfo;
+ $mach = Net::Netrc->lookup( $uri->host, $user )
+ unless $user and defined $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 );
+
+}
+
+method get ($new_uri)
+{
+ $uri = URI->new($new_uri)
+ or $log->fatal( 'failed to parse URI %s', $uri ) && exit 2
+ if $new_uri;
+
+ # fetch and parse CalDAV calendar data
+ $log->debug('fetch and parse CalDAV calendar data...');
+ $session //= Cal::DAV->new(
+ user => $user,
+ pass => $pass,
+ url => $uri,
+ );
+
+# TODO: if calendar object is empty on reused session with no new uri,
+# warn on stderr with list of available 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 <https://stackoverflow.com/a/11673483>
+
+ return Object::Groupware::Calendar->new(
+ data => $session->cal,
+ dt_locale => $self->dt_locale,
+ dt_time_zone => $self->dt_time_zone,
+ );
+}
+
+1;
diff --git a/lib/Object/Groupware/Event.pm b/lib/Object/Groupware/Event.pm
new file mode 100644
index 0000000..a7f5842
--- /dev/null
+++ b/lib/Object/Groupware/Event.pm
@@ -0,0 +1,147 @@
+use v5.36;
+
+#use Feature::Compat::Class 0.07;
+use Object::Pad 0.78;
+
+package Object::Groupware::Event 0.01;
+
+class Object::Groupware::Event : isa(Object::Groupware);
+
+use utf8;
+
+use Log::Any qw( );
+use Feature::Compat::Try;
+use DateTime::Locale;
+use Encode qw(decode_utf8);
+
+field $log = undef;
+
+field $entry : param;
+field $dt_locale;
+field $span;
+
+field $start : reader = undef;
+field $end : reader = undef;
+field $summary : reader = undef;
+field $description : reader = undef;
+field $location : reader = undef;
+field $price : reader;
+field @attendees;
+field @attachments;
+
+ADJUST {
+ # TODO: use Object::Pad 0.07 and move these to field initializer
+ $log //= Log::Any->get_logger;
+ $start //= $entry->start;
+ $end //= $entry->end;
+ $summary //= decode_utf8 $entry->summary;
+ $description //= decode_utf8 $entry->description || '';
+ $location //= decode_utf8 $entry->_simple_property('location');
+
+ if ( $self->dt_locale ) {
+ $dt_locale = $self->dt_locale;
+ $start->set_locale($dt_locale);
+ $end->set_locale($dt_locale) if defined $end;
+ }
+ else {
+
+ # we need the object regardless, to fetch CLDR patterns
+ $dt_locale = DateTime::Locale->load('en_US');
+ }
+
+ $start->set_locale( $self->dt_locale );
+
+ for ( $self->dt_time_zone || () ) {
+ $start->set_time_zone($_);
+ $end->set_time_zone($_) if defined $end;
+ }
+ $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'} )
+ || decode_utf8( $_->value =~ s/^mailto://r );
+ }
+ }
+ if ( $entry->property('attach') ) {
+ for ( @{ $entry->property('attach') } ) {
+ my $uri;
+ try { $uri = URI->new( decode_utf8 $_->value ) }
+ catch ($e) {
+ $log->errorf( 'failed to parse URI %s: %s', $uri, $e );
+ next;
+ }
+ $uri->authority and $uri->host
+ or next;
+ push @attachments, $uri;
+ }
+ }
+
+ $log->tracef(
+ "Object %s contents:\n%s", __CLASS__,
+ decode_utf8 $entry->as_string
+ );
+}
+
+method start_date { $start->strftime('%F') }
+method end_date { $end->strftime('%F') }
+method attendees { !!@attendees ? [@attendees] : undef }
+method attachments { !!@attachments ? [@attachments] : undef }
+
+method span ()
+{
+ return $span
+ if defined($span);
+
+ return $span = ''
+ unless $end;
+
+ require DateTime::Span;
+ $span = DateTime::Span->from_datetimes( start => $start, before => $end );
+
+ return $span;
+}
+
+method datespan ()
+{
+ return ucfirst( $start->format_cldr( $dt_locale->date_format_medium() ) )
+ if $entry->all_day;
+
+ return ''
+ if !$span
+ or $end->clone->truncate( to => 'day' ) eq
+ $start->clone->truncate( to => 'day' );
+
+ return ucfirst(
+ sprintf '%s - %s',
+ $start->format_cldr( $dt_locale->date_format_medium() ),
+ $end->format_cldr( $dt_locale->date_format_medium() )
+ );
+}
+
+method timespan ()
+{
+ return ''
+ if $span
+ and $end->clone->truncate( to => 'day' ) ne
+ $start->clone->truncate( to => 'day' );
+
+ return ucfirst( $start->format_cldr( $dt_locale->date_format_medium() ) )
+ if $entry->all_day;
+
+ return $span = ucfirst(
+ sprintf '%s-%s',
+ $start->format_cldr( $dt_locale->datetime_format_medium() ),
+ $end->format_cldr( $dt_locale->time_format_medium() )
+ );
+}
+
+method time_brief ()
+{
+ return $self->datespan
+ || ucfirst(
+ $start->format_cldr( $dt_locale->datetime_format_medium() ) );
+}
+
+1;
diff --git a/templates/list.md b/templates/list.md
new file mode 100644
index 0000000..02b8c1b
--- /dev/null
+++ b/templates/list.md
@@ -0,0 +1,32 @@
+[%- FOREACH e IN events -%]
+### [% e.time_brief %].
+[%- IF e.summary %] [% e.summary %]
+[%- END %]
+[% e.description %]
+[%- IF e.attendees %]
+Med [% e.attendees.join(' og ') %].
+[%- END %]
+[%- IF e.location %]
+**Mødested:** [% e.location %]
+[%- END %]
+[%- IF e.timespan %]
+**Tid:** [% e.timespan %].
+[%- END %]
+[%- IF e.price %]
+**Pris:** [% e.price %]
+[%- END %]
+[%- FOREACH uri IN e.attachments %]
+[%- IF uri.host == 'byvandring.holdbar.com' %]
+[Køb billet på Holdbar]([% uri.as_iri %])
+[%- END %]
+[%- IF uri.host == 'billetto.dk' %]
+[Køb billet på Billetto]([% uri.as_iri %])
+[%- END %]
+[%- IF uri.host == 'byvandring.nu' %]
+[Læs mere her]([% uri.as_iri %])
+[%- END %]
+[%- END %]
+
+---
+
+[% END %]
diff --git a/templates/semesterplan-list.events b/templates/semesterplan-list.events
new file mode 100644
index 0000000..01033d1
--- /dev/null
+++ b/templates/semesterplan-list.events
@@ -0,0 +1,7 @@
+% Annually recurring events should contain the macro \year.
+
+[% FOREACH e IN events -%]
+\event*{[% e.start_date %]}{[% e.summary %]}
+[% END %]
+
+\endinput
diff --git a/templates/semesterplan.tex b/templates/semesterplan.tex
new file mode 100644
index 0000000..8b873ad
--- /dev/null
+++ b/templates/semesterplan.tex
@@ -0,0 +1,12 @@
+\documentclass{tikz-kalender}
+\setup{%
+,lang=danish%
+,year=2024%
+,title={[% name %]}
+,showweeknumbers%
+,events={list}% events and periods (files with ending ".events")
+}
+
+\begin{document}
+ \makeKalender
+\end{document}