diff options
-rw-r--r-- | .perlcriticrc | 4 | ||||
-rw-r--r-- | .perltidyrc | 21 | ||||
-rw-r--r-- | .tidyallrc | 7 | ||||
-rw-r--r-- | ADMIN.md | 57 | ||||
-rw-r--r-- | README.md | 34 | ||||
-rw-r--r-- | TODO.md | 6 | ||||
-rw-r--r-- | USE.md | 66 | ||||
-rwxr-xr-x | bin/events2md.pl | 246 | ||||
-rwxr-xr-x | bin/events2semesterplan.pl | 208 | ||||
-rw-r--r-- | lib/Object/Groupware.pm | 45 | ||||
-rw-r--r-- | lib/Object/Groupware/Calendar.pm | 65 | ||||
-rw-r--r-- | lib/Object/Groupware/DAV.pm | 91 | ||||
-rw-r--r-- | lib/Object/Groupware/Event.pm | 147 | ||||
-rw-r--r-- | templates/list.md | 32 | ||||
-rw-r--r-- | templates/semesterplan-list.events | 7 | ||||
-rw-r--r-- | templates/semesterplan.tex | 12 |
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 @@ -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" @@ -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 @@ -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) @@ -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} |