• Publicity: Public Only All

caldav-interface-procs.tcl

CalDav implementation for OpenACS. Abstraction between calendars and items Parsing, formatting, retrieving calendar items.

Location:
packages/caldav/tcl/caldav-interface-procs.tcl
Created:
Jan, 2017
Authors:
Gustaf Neumann
marmoser@wu.ac.at

Procedures in this file

Detailed information

[ hide source ] | [ make this the default ]

Content File Source

ad_library {
    CalDav implementation for OpenACS. Abstraction between calendars
    and items Parsing, formatting, retrieving calendar items.

    @author Gustaf Neumann
    @author marmoser@wu.ac.at
    @creation-date Jan, 2017
}

::xo::library require caldav-item-procs

namespace eval ::caldav {}

nx::Object create ::caldav::calendars {
    #
    #  The class "calendars" implements the interface to the database
    #  structures.
    #

    :object method debug {msg} {
        ns_log Debug(caldav) "[uplevel current proc]: $msg"
    }

    # TODO move get_sync_calendar here
    #
    :public object method format_recurrence {
        {-recurrence_id:integer,0..1}
    } {
        # Return the recurrence specification in form of a formatted
        # ical RRULE.  @param recurrence_id is the unique id of the
        # recurrence item.

        if {$recurrence_id eq ""} {
            return ""
        }
        #ns_log notice "recurrence_id $recurrence_id"
        set recur_rule "RRULE:FREQ="

        ::xo::dc 1row -prepare integer select_recurrence {
            select
                recurrence_id,
                recurrences.interval_type,
                interval_name,
                every_nth_interval,
                days_of_week,
                recur_until
            from
                recurrences,
                recurrence_interval_types
            where recurrence_id= :recurrence_id
            and   recurrences.interval_type = recurrence_interval_types.interval_type
        }

        switch -glob $interval_name {
            day      { append recur_rule "DAILY" }
            week     { append recur_rule "WEEKLY" }
            *month*  { append recur_rule "MONTHLY"}
            year     { append recur_rule "YEARLY"}
        }

        if { $interval_name eq "week"
             && $days_of_week ne ""
         } {
            #DRB: Standard indicates ordinal week days are OK, but Outlook
            #only takes two-letter abbreviation form.

            set week_list [list "SU" "MO" "TU" "WE" "TH" "FR" "SA" "SU"]
            set rec_list [list]
            foreach day [split $days_of_week " "] {
                lappend rec_list [lindex $week_list $day]
            }
            append recur_rule ";BYDAY=" [join $rec_list ,]
        }

        if {$every_nth_interval ne ""} {
            append recur_rule ";INTERVAL=$every_nth_interval"
        }

        if {$recur_until ne ""} {
            set stamp [string range $recur_until 0 18]
            append recur_rule ";UNTIL=" [xo::ical tcl_time_to_utc $stamp]
        }

        #ns_log notice "recur_rule $recur_rule"
        return [::xo::ical reflow_content_line $recur_rule]\r\n
    }


    :public object method get_cal_item_from_uid {
        {-calendar_ids:integer,0..n}
        uid
    } {
        # @return for a uid the cal_item_id(s?)
        # @param uid unique id of an calendar item

        #
        # GN TODO:
        #
        # - document, why and how the or test "e.activity_id = :uid" is
        #   needed this looks like a hack, since the UID can be modified
        #   by a calendar client, we can't assume that this is the same
        #   as some OpenACS id.
        #
        # - when the uid refers to a recurrence item, multiple
        #   cal_item_ids are returned. (a) is this needed (maybe limit
        #   1 is sufficient)? (b) is this handled everywhere
        #   correctly? (c) if needed, name should be change to
        #   get_cal_items_from_uid
        #
        # - HOW ABOUT using activity_id instead of the cal_item_id... such as get_activity_from_uid
        #
        # - probably base on get_calendar_and_cal_item_from_uid
        #
        #
        if {[llength $calendar_ids] > 1} {
            set calclause "in ( [ns_dbquotelist $calendar_ids] )"
        } elseif {[llength $calendar_ids] eq 0} {
            return 0
        } else {
            set calclause "= :calendar_ids"
        }
        set e_clause [expr {[nsf::is integer $uid] ? " or e.activity_id = :uid" : ""}]

        return [::xo::dc list get_cal_item_from_uid [subst {
            select cal_item_id
            from cal_items c, acs_events e
            left outer join cal_uids u on u.on_which_activity = e.activity_id
            where c.cal_item_id = e.event_id
            and ( u.cal_uid = :uid
                  $e_clause
                  )
            and  c.on_which_calendar $calclause
            order by 1 desc
        }]]
    }

    :public object method get_calendar_and_cal_item_from_uid {
        {-calendar_ids:integer,0..n}
        uid
    } {
        # @return for a uid the cal_item_id(s?)
        # @param uid unique id of an calendar item

        #
        # GN TODO:
        #
        # - see above... get_cal_item_from_uid
        #
        # The following query is tricky, since it avoids
        # an error "invalid input syntax for integer" on uids like
        #
        #     23009F17-383F-4FBD-92D4-AB0F27CF7326
        #
        # needs probably work for Oracle.
        #
        return [::xo::dc list_of_lists query_calendar_and_cal_item {
            select c.on_which_calendar, c.cal_item_id
            from acs_events e, cal_items c
            where e.activity_id in
            (
             select CASE WHEN :uid !~ '^[0-9]+$' THEN NULL ELSE :uid ::text::integer END
             union (select on_which_activity from cal_uids where cal_uid = :uid)
            )
            and c.cal_item_id = e.event_id
            order by 2 desc
            limit 1
        }]

        # set e_clause [expr {[nsf::is integer $uid] ? " or e.activity_id = :uid" : ""}]
        #
        # - we could pass-in a calendar-clause, would save a query in
        #   the PUT case
        #if {[llength $calendar_ids] > 1} {
        #    set calclause "in ( [ns_dbquotelist $calendar_ids] )"
        #} elseif {[llength $calendar_ids] eq 0} {
        #    return 0
        #} else {
        #    set calclause "= :calendar_ids"
        #}

        # return [::xo::dc list query_calendar_and_cal_item [subst {
        #     select c.on_which_calendar, cal_item_id
        #     from cal_items c, acs_events e
        #     left outer join cal_uids u on u.on_which_activity = e.activity_id
        #     where c.cal_item_id = e.event_id
        #     and ( u.cal_uid = :uid $e_clause )
        #     and  c.on_which_calendar $calclause
        #     order by 2 desc}]]

    }


    :public object method alwaysQueriedCalendars {
        {-with_sync_calendar:boolean true}
        user_id:integer
    } {
        #
        # @return the calendar_ids, which should be always returned
        #
        lappend calendar_ids {*}[::caldav::get_public_calendars]
        if {$with_sync_calendar} {
            lappend calendar_ids [::caldav::get_sync_calendar -user_id $user_id]
        }
        return $calendar_ids
    }

    :public object method alwaysQueriedClause {
        user_id:integer
    } {
        #
        # @return SQL clause which is always queried
        #
        set calendar_ids [:alwaysQueriedCalendars $user_id]
        if {[llength $calendar_ids] > 0} {
            set values [::xo::db::list_to_values $calendar_ids integer]
            set result [list "select calendar_id from $values as values(calendar_id)"]
        } else {
            set result {}
        }
        return $result
    }

    :public object method communityCalendarClause {
        user_id:integer
    } {
        #
        # Get calendars from communities, when DotLRN is active.
        #
        if {[info commands ::dotlrn_calendar::my_package_key] ne ""} {
            set result [list "
                WITH communities AS (
                                     select distinct dcc.community_id
                                     from dotlrn_communities_core dcc
                                     inner join dotlrn_member_rels_approved dma
                                     on dcc.community_id = dma.community_id
                                     and dma.user_id = $user_id
                                     and dcc.archived_p = 'f'
                                     )
                select calendar_id
                from communities m, calendars c
                join dotlrn_community_applets a on (a.package_id = c.package_id)
                join dotlrn_applets ap on (ap.applet_id = a.applet_id) where
                ap.package_key = 'dotlrn-calendar' and
                a.community_id = m.community_id
            "]
        } else {
            set result {}
        }
        return $result
    }

    :public object method calendar_clause {
        {-calendar_ids:integer,0..n ""}
        {-user_id:integer}
        {-attr c.on_which_calendar}
    } {
        #
        # When calendar_ids are empty, user_id has to be specified
        #

        if {$calendar_ids eq ""} {
            lappend clauses \
                {*}[:communityCalendarClause $user_id] \
                {*}[:alwaysQueriedClause $user_id]
            set clause [subst { and $attr in ([join $clauses " union "]) }]
        } elseif {[llength $calendar_ids] == 1} {
            set clause [subst {and $attr = :calendar_ids}]
        } else {
            set clause [subst {and $attr in ( [ns_dbquotelist $calendar_ids] )}]
        }
        :debug calendar_clause=$clause-calendar_ids=$calendar_ids
        return $clause
    }


    #
    # API for selecting calendar items
    #
    :public object method get_calitems {
        {-user_id:integer ""}
        {-start_date ""}
        {-end_date ""}
        {-calendar_ids:integer,0..n ""}
    } {
        #
        # Get feed of calendar items for a given user.
        #
        # @return list set of calendar item objects

        :debug "get_calitems [current args]"

        if {$start_date ne "" && $end_date ne ""} {
            set time_limitation_clause [subst { and start_date between
                to_timestamp('$start_date','YYYY-MM-DD HH24:MI:SS')
                and to_timestamp('$end_date', 'YYYY-MM-DD HH24:MI:SS')
            }]
        } else {
            set time_limitation_clause ""
        }

        set eventlist {}
        set recurrences {}

        #
        # Note that we can have items without a entry in cal_uids for
        # these we will use the activity_id as uid calendars of
        # communities, personal calendars, and public calendars in the
        # same package as the personal calendar-
        #
        foreach item [::xo::dc list_of_lists cal_items [subst {
            select md5(last_modified::text) as etag,
            coalesce(cal_uid, e.activity_id::varchar),
            ical_vars,
            on_which_calendar,
            c.item_type_id,
            to_char(start_date, 'YYYY-MM-DD HH24:MI:SS'),
            to_char(end_date, 'YYYY-MM-DD HH24:MI:SS'),
            coalesce(e.name, a.name),
            coalesce(e.description, a.description),
            c.cal_item_id,
            recurrence_id,
            creation_date,
            last_modified
            from
            acs_objects ao, acs_events e
            left outer join cal_uids u on u.on_which_activity = e.activity_id,
            acs_activities a, timespans s,
            time_intervals t, cal_items c
            where e.event_id = ao.object_id
            and a.activity_id = e.activity_id
            and c.cal_item_id = e.event_id
            and e.timespan_id = s.timespan_id
            and s.interval_id = t.interval_id
            $time_limitation_clause
            [:calendar_clause -calendar_ids $calendar_ids -user_id $user_id]
            order by start_date asc
        }]] {

            lassign $item \
                etag cal_uid ical_vars calendar_id item_type \
                start_date end_date name description \
                cal_item_id recurrence_id creation_date last_modified

            #ns_log notice "get_calitems: item $cal_item_id calendar $calendar_id" \
                "we got an recurrence <$recurrence_id>"

            if {$recurrence_id ne "" && $recurrence_id in $recurrences} {
                #
                # Don't report calendar items with recurrence multiple
                # times.
                #
                continue
            }
            set caldavItem [::caldav::calitem new \
                                -uid $cal_uid \
                                -ical_vars $ical_vars \
                                -etag $etag \
                                -creation_date $creation_date \
                                -last_modified $last_modified \
                                -dtstart $start_date \
                                -is_day_item [dt_no_time_p -start_time $start_date -end_time $end_date] \
                                -formatted_recurrences [:format_recurrence -recurrence_id $recurrence_id] \
                                -dtend $end_date \
                                -summary $name \
                                -description $description \
                               ]

            $caldavItem destroy_on_cleanup
            lappend eventlist $caldavItem
            lappend recurrences $recurrence_id
        }
        return $eventlist
    }
}


caldav::calendars eval {
    # TODO: should be probably moved to the ical procs.

    set :opaque_tags {CATEGORIES CLASS COMMENT GEO
        PERCENT-COMPLETE PRIORITY RESOURCES STATUS SEQUENCE URL
        X-APPLE-STRUCTURED-LOCATION
    }

    :object method set_date_time {date time utc:boolean tz} {
        #
        # Format a date-time value based on the provided date and time
        # components, optionally in utc. This function is just used be
        # the ical parser.
        #
        # GN TODO: TZ is currently ignored, we assume, if not UTC, then
        # use localtime of the host.
        #
        set clock [::xo::ical date_time_to_clock $date $time $utc]
        #:debug "set_date_time parses $date $time $utc ($tz)-> [::xo::ical clock_to_oacstime $clock]"
        return [::xo::ical clock_to_oacstime $clock]
    }

    :public object method parse {
        text
    } {
        #
        # Parse the ical file passed in as string and output a list of
        # CalItem objects. The attributes specified in opaque_tags are
        # passed as opaque values. Opaque attributes are not shown in
        # OpenACS, but output when the calendar item is requested in
        # ical format.
        #
        # @param text the text do be parsed
        #
        # TODO: this should go into ical-procs..... but first check dependencies
        # - $item add_ical_var ....
        # - ::caldav::calitem new

        set parse_error 0
        set in_valarm 0
        set in_vevent 0
        set item_list {}
        #opaque_tags are the tags that will be persisted in ical_vars
        set opaque_re ^([join ${:opaque_tags} |]):(.*)$
        :debug opaque_re=$opaque_re
        set prefix ""
        regsub -all "\n " $text "" text
        regsub -all "\r" $text "" text
        foreach line [split $text \n] {
            ns_log notice "======== <$line>"
            if {$in_valarm} {
                #
                # treat everything in an VALARM as opaque for the time being
                #
                append :OPAQUE-VALARM $line\r\n
                if { $line eq "END:VALARM"} {
                    # end of valarm section
                    set in_valarm 0
                    $item add_ical_var OPAQUE-VALARM "" [set :OPAQUE-VALARM]
                }
            } elseif$in_vevent && [regexp $opaque_re $line _ tag value] } {
                $item add_ical_var $tag "" [::xo::ical ical_to_text $value]
            } elseif$line eq "BEGIN:VEVENT" } {
                # reset values
                set in_valarm 0
                set in_vevent 1
                set r_error 0
                #
                #setting a creation date is only needed for debugging
                #
                # GN TODO: DO NOT HARDCODE calitem HERE!
                #
                set item [::caldav::calitem new \
                              -description "" \
                              -creation_date [xo::ical clock_to_utc [clock seconds]]]
                $item destroy_on_cleanup
                #$item set description ""
                #$item set creation_date  [xo::ical clock_to_utc [clock seconds]]
                lappend item_list $item
            } elseif$line eq {BEGIN:VALARM} } {
                # begin of VALARM section
                set in_valarm 1
                set :OPAQUE-VALARM $line\r\n
            } elseif$in_vevent && [regexp {^LOCATION[^:]*:(.*)$} $line _ location] } {
                $item location set [::xo::ical ical_to_text $location]
            } elseif$in_vevent && [regexp {^SUMMARY[^:]*:(.*)$} $line _ title] } {
                $item summary set [::xo::ical ical_to_text $title]
            } elseif$in_vevent && [regexp {^(DTSTAMP|UID|LAST-MODIFIED)[^:]*:(.*)$} $line _ field entry] } {
                $item [string tolower $field] set $entry
            } elseif$in_vevent && [regexp {^DTSTART(\;TZID.*)?:([0-9]+)T+([0-9]+)(Z?).*$} $line _ tz date time utc] } {
                if {[string length $date] != 8 || [string length $time] != 6} {
                    set parse_error 1
                } else {
                    $item dtstart set [:set_date_time $date $time [expr {$utc ne ""}] $tz]
                }
            } elseif$in_vevent && [regexp {^DTSTART.+DATE[^:]*:([0-9]+).*$} $line _ date] } {
                if {[string length $date] != 8} {
                    set parse_error 1
                } else {
                    $item dtstart set [:set_date_time $date "0000" 0 ""]
                }
            } elseif$in_vevent && [regexp {^DTEND(\;TZID.*)?[^:]*:([0-9]+)T+([0-9]+)(Z?).*$} $line _ tz date time utc] } {
                if {[string length $date] != 8 || [string length $time] != 6} {
                    set parse_error 1
                } else {
                    $item dtend set [:set_date_time $date $time [expr {$utc ne ""}] $tz]
                }
            } elseif$in_vevent && [regexp {^DTEND.+?DATE[^:]*:([0-9]+).*$} $line _ date ] } {
                if {[string length $date] != 8} {
                    set parse_error 1
                } else {
                    $item dtend set [:set_date_time $date "0000" 0 ""]
                }
            } elseif {$in_vevent && [regexp {^DURATION[^:]*:P(.*)$} $line _ duration] } {
                $item duration set 0
                if {[regexp {^([0-9]+)W(.*)$} $duration _ units duration]} {
                    $item incr duration [expr {[util::trim_leading_zeros $units]*24*3600*7}]
                }
                if {[regexp {([0-9]+)D(.*)$} $duration _ units duration]} {
                    $item incr duration [expr {[util::trim_leading_zeros $units]*24*3600}]
                }
                if {[regexp {([0-9]+)H(.*)$} $duration _ units duration]} {
                    $item incr duration [expr {[util::trim_leading_zeros $units]*3600}]
                }
                if {[regexp {([0-9]+)M(.*)$} $duration _ units duration]} {
                    $item incr duration [expr {[util::trim_leading_zeros $units]*60}]
                }
                if {[regexp {([0-9]+)S(.*)$} $duration _ units duration]} {
                    $item incr duration [util::trim_leading_zeros $units]
                }
            } elseif {$in_vevent && [regexp {^DESCRIPTION[^:]*:(.*)$} $line _ desc] } {
                $item description set [::xo::ical ical_to_text $desc]
            }  elseif {$in_vevent && [regexp {^URL[^:]*:(.*)$} $line _ desc] } {
                $item add_ical_var URL "" [::xo::ical ical_to_text $desc]
            } elseif$in_vevent && [regexp {^RRULE[^:]*:(.*)$} $line _ recurrule] } {
                $item parse RRULE $recurrule
            } elseif$in_vevent && $line eq "END:VEVENT" } {
                set in_vevent 0
                $item finish $parse_error
            } elseif {$in_vevent && [regexp {^X-APPLE-STRUCTURED-LOCATION(\;[^:]+|):(.*)$} $line . params value]} {
                #
                # Special handling for Apple ical implementations
                #
                $item add_ical_var X-APPLE-STRUCTURED-LOCATION $params $value
            } else {
                # Ignore unused ical lines
                :debug "ical parse ignores <$line>"
            }
        }
        return $item_list
    }

    :public object method header {
        {-calendar_name ""}
    } {
        #
        # Return the header of the ical file.
        #
        # GN TODO: don't hardcode TIMEZONE
        # "X-WR-TIMEZONE:Europe/Vienna"
        #
        if {$calendar_name eq ""} {
            set $calendar_name "Calendar from [ad_system_name]"
        }

        append lines \
            "BEGIN:VCALENDAR" \r\n \
            "X-WR-CALNAME:$calendar_name" \r\n \
            "PRODID:-//OpenACS//OpenACS 6.0 MIMEDIR//EN" \r\n \
            "CALSCALE:GREGORIAN" \r\n \
            "VERSION:2.0" \r\n \
            "METHOD:PUBLISH" \r\n
    }

    :public object method footer {} {
        #
        # Return the footer of the ical file.
        #

        return "END:VCALENDAR\r\n"
    }

    :public object method timezone {} {
        #
        # Return the timezone
        #

        # GN TODO: don't hardcode timezone

        set timezone [lang::system::timezone]
        set date_info [exec [util::which date] "+%Z %z"]
        set TZNAME [linex $date_info 0]
        set default_offset [linex $date_info 1]

        # TZOFFSETFROM: local time offset from GMT when daylight saving time is in operation,
        # TZOFFSETTO is the local time offset from GMT when standard time is in operation.
        # set TZOFFSETFROM "+0100"
        # set TZOFFSETTO "+0200"

        set TZOFFSETFROM $default_offset
        set TZOFFSETTO $default_offset

        #
        # Compute offsets. It is not so easy to come up with a variant
        # that works under linux and macOS, since the results of zdump
        # is different (no gmtoff= under macOS) and date has well
        # different arguments.
        #
        try {
            set year [clock format [clock seconds] -format %Y ]
            set lines [exec [::util::which zdump] -v [lang::system::timezone] | fgrep $year]
            foreach l [split $lines \n] {
                #
                # Compute date difference in seconds
                #
                set diff [expr {([clock scan [lindex $l 11]]-[clock scan [lindex $l 4]]) / 60}]
                #
                # Format diff in seconds in a form like "+0200"
                #
                set sign [expr {$diff>0 ? "+" : "-"}]
                set H [format %02d [expr {$diff/60}]]
                set M [format %02d [expr {$diff%60}]]
                dict set time [lindex $l 14] $sign$H$M
            }

            if {[dict exists $time isdst=1]} {
                set TZOFFSETFROM [dict get $time isdst=1]
            }
            if {[dict exists $time isdst=0]} {
                set TZOFFSETTO [dict get $time isdst=0]
            }
        }

        return "BEGIN:VTIMEZONE
TZID:$timezone
TZURL:http://tzurl.org/zoneinfo-outlook/timezone
X-LIC-LOCATION:[lang::system::timezone]
BEGIN:DAYLIGHT
TZOFFSETFROM:$TZOFFSETFROM
TZOFFSETTO:$TZOFFSETTO
TZNAME:$TZNAME
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
END:STANDARD
END:VTIMEZONE"
    }
}

::xo::library source_dependent
#
# Local variables:
#    mode: tcl
#    tcl-indent-level: 4
#    indent-tabs-mode: nil
#    eval: (setq tcl-type-alist (remove* "method" tcl-type-alist :test 'equal :key 'car))
# End: