Index: openacs-4/packages/xotcl-core/tcl/ical-procs.tcl =================================================================== RCS file: /usr/local/cvsroot/openacs-4/packages/xotcl-core/tcl/ical-procs.tcl,v diff -u -r1.23 -r1.24 --- openacs-4/packages/xotcl-core/tcl/ical-procs.tcl 1 Feb 2021 10:41:30 -0000 1.23 +++ openacs-4/packages/xotcl-core/tcl/ical-procs.tcl 9 May 2022 17:22:45 -0000 1.24 @@ -1,28 +1,32 @@ ::xo::library doc { - Utility functions for ical data + Utility functions for ical data - @author neumann@wu-wien.ac.at - @creation-date July 20, 2005 - - Incomplete backport from :calendar extensions + @author neumann@wu-wien.ac.at + @creation-date July 20, 2005, modernized in 2019 } -namespace eval ::xo { - ::xotcl::Object ::xo::ical -ad_doc { - The Object ::calendar::ical provides the methods for - importing and exporting single or multiple calendar items - in the ical format (see rfc 2445). Currently only the part - of ical is implemented, which is used by the mozilla - calendar (Sunbird, or Lightning for Thunderbird). +namespace eval ::xo::ical {} - @author Gustaf Neumann +nx::Object create ::xo::ical { + # The Object ::calendar::ical provides the methods for + # importing and exporting single or multiple calendar items + # in the ical format (see rfc 2445). Currently only the part + # of ical is implemented, which is used by the mozilla + # calendar (Sunbird, or Lightning for Thunderbird). + # + # @author Gustaf Neumann + + :object method debug {msg} { + # + # TODO: mabe add Debug(ical)? + # + ns_log Debug(caldav) "[uplevel current proc]: $msg" } - # # conversion routines from and to the date formats used by ical # - ical proc date_time_to_clock {date time utc} { + :public object method date_time_to_clock {date time utc} { set year [string range $date 0 3] set month [string range $date 4 5] set day [string range $date 6 7] @@ -32,242 +36,487 @@ set TZ [expr {$utc ? "GMT" : ""}] return [clock scan "$year-$month-$day $hour:$min $TZ"] } - ical proc tcl_time_to_utc {time} { + + :public object method tcl_time_to_utc {time} { clock format [clock scan $time] -format "%Y%m%dT%H%M%SZ" -gmt 1 } - ical proc tcl_time_to_local_day {time} { + + :public object method tcl_time_to_local_day {time} { # https://tools.ietf.org/html/rfc5545#section-3.3.4 return "VALUE=DATE:[:clock_to_local_day [clock scan $time]]" } - ical proc utc_to_clock {utc_time} { + + :public object method utc_to_clock {utc_time} { clock scan $utc_time -format "%Y%m%dT%H%M%SZ" -gmt 1 } - ical proc clock_to_utc {seconds} { + + :public object method clock_to_utc {seconds:integer} { clock format $seconds -format "%Y%m%dT%H%M%SZ" -gmt 1 } - ical proc clock_to_iso {seconds} { + + :public object method clock_to_iso {seconds:integer} { clock format $seconds -format "%Y-%m-%dT%H:%M:%SZ" -gmt 1 } - ical proc clock_to_local_day {seconds} { + + :public object method clock_to_local_day {seconds:integer} { clock format $seconds -format "%Y%m%d" } - ical proc clock_to_oacstime {seconds} { + + :public object method clock_to_oacstime {seconds:integer} { clock format $seconds -format "%Y-%m-%d %H:%M" } - ical ad_proc dates_valid_p { + :public object method dates_valid_p { -start_date:required -end_date:required } { - A sanity check that the start time is before the end time. - This is a rewrite of calendar::item::dates_valid_p, but - about 100 times faster. - } { + # A sanity check that the start time is before the end time. + # This is a rewrite of calendar::item::dates_valid_p, but + # about 100 times faster. + # + #my log "$start_date <= $end_date = [expr {[clock scan $start_date] <= [clock scan $end_date]}]" expr {[clock scan $start_date] <= [clock scan $end_date]} } - ical ad_proc text_to_ical {{-remove_tags false} text} { - Transform arbitrary text to the escaped ical text format - (see rfc 2445) - } { - if {$remove_tags} {regsub -all -- {<[^>]+>} $text "" text} - regsub -all -- {(\\|\;|\,)} $text {\\\1} text + :public object method text_to_ical {{-remove_tags:boolean false} text} { + # + # Transform arbitrary text to the escaped ical text format + # (see rfc 2445) + # + + if {$remove_tags} { + regsub -all {<[^>]+>} $text "" text + } + regsub -all {(\\|\;|\,)} $text {\\\1} text regsub -all \n $text {\\n} text return $text } - ical ad_proc ical_to_text {text} { - Transform the escaped ical text format to plain text - } { - regsub -all -- {\\(n|N)} $text \n text - regsub -all -- {\\(\\|\;|\,)} $text {\1} text + + :public object method ical_to_text {text} { + # + # Transform the escaped ical text format to plain text + # + regsub -all {\\(n|N)} $text \n text + regsub -all {\\(\\|\;|\,)} $text {\1} text return $text } } namespace eval ::xo { - # - # VCALITEM is the superclass of a VTODO and a VEVENT, intended as an - # abstract class - # - Class create ::xo::ical::VCALITEM -parameter { - creation_date - last_modified - dtstart - dtstamp - uid - priority - summary - url - description - location - geo - status - {is_day_item false} - } + nx::Class create ::xo::ical::VCALITEM { + # + # VCALITEM is the superclass of a VTODO and a VEVENT, intended as + # an abstract class. + # - ::xo::ical::VCALITEM instproc tag {-tag -conv -value slot} { - #ns_log notice "::xo::ical::VCALITEM tag [self args]" - if {![info exists tag]} { - set tag [string toupper $slot] + :property -accessor public creation_date + :property -accessor public last_modified + :property -accessor public dtstart + :property -accessor public dtstamp + :property -accessor public uid + :property -accessor public priority + :property -accessor public summary + :property -accessor public url + :property -accessor public description + :property -accessor public location + :property -accessor public geo + :property -accessor public status + :property -accessor public {is_day_item false} + :property -accessor public last-modified ;# TODO: who (which client) needs this? not a standard ical attribute + + :public method get {property {default ""}} { + # + # Return a certain property of an ical items. In case, the + # item has no such property, return the default value. + # + if {[info exists :$property]} { + return [set :$property] + } + return $default } - if {![info exists value]} { - if {[info exists :$slot]} { - set value [set :$slot] + + :method tag {-tag -param -conv -value slot:required} { + # + # Generate a single (line) entry in ical format starting with + # the specified tag. If no "tag" is specified, the tag name is + # uppercase of the slot. The value might be specified literally + # or it can be the name of an instance variable (named by the + # "slot" attribute". + # + # The functions returns empty (produces no output) when either + # the value is empty or no instance variable exists. + # Optionally, the value can be converted. + # + # + + #ns_log notice "::xo::ical::VCALITEM tag [self args]" + if {![info exists tag]} { + set tag [string toupper $slot] + } + + # + # if we get a param, we expect it to include delimiters, such + # as the leading semicolon + # + if {[info exists param]} { + append tag $param + } + + if {![info exists value]} { + if {[info exists :$slot]} { + set value [set :$slot] + } else { + # + # If we have no value and no instance variable set, do not + # output this slot. + # + return "" + } + } + + # + # When a converter is given, apply it + # + if {[info exists conv]} { + set value [::xo::ical $conv $value] + } + + if {$value ne ""} { + set result "$tag:$value" + # + # Perform line folding: According to RFC 5545 section 3.1, + # SHOULD NOT be longer than 75 octets, excluding the line break. + # https://www.ietf.org/rfc/rfc5545.txt + # + if {[string length $result] > 73} { + set lines "" + while {[string length $result] > 73} { + append lines [string range $result 0 73] \r\n " " + set result [string range $result 74 end] + } + append lines $result + set result $lines + } + append result \r\n + #ns_log notice "::xo::ical::VCALITEM tag [self args] -> len: [string length $result]" } else { - return "" + set result "" } + return $result } - if {[info exists conv]} { - set value [::xo::ical $conv $value] + + :method start_end {} { + # + # Output either a DAY-EVENT (denoted by a single DTSTART with + # appropriate VALUE format) or a state and end time stamps. + # + if {${:is_day_item}} { + append result \ + "DTSTART;" [::xo::ical tcl_time_to_local_day ${:dtstart}] \r\n + } else { + append result \ + [:tag -conv tcl_time_to_utc dtstart] \ + [:tag -conv tcl_time_to_utc dtend] + } } - if {$value ne ""} { - set result "$tag:$value" + + :public method as_ical {} { # - # Perform line folding: According to RFC 5545 section 3.1, - # SHOULD NOT be longer than 75 octets, excluding the line break. - # https://www.ietf.org/rfc/rfc5545.txt + # Output a VCALITEM (VTODO or VEVENT) "object" in ical notation. # - if {[string length $result] > 73} { - set lines "" - while {[string length $result] > 73} { - append lines [string range $result 0 73] \r\n " " - set result [string range $result 74 end] + set item_type [namespace tail [:info class]] + append t "BEGIN:$item_type\r\n" \ + [:ical_body] \ + "END:$item_type\r\n" + return $t + } + + :public method ical_body {} { + # + # The method ical_body returns the ical-formatted content of the + # variables. All variables of VEVENTs and VTODOs are listed below, + # since the names are distinct, and no methods are used. + # + # So far there is no handling for the repetition fields (which + # might occur more than once). An option would be to handle these + # as lists. + # + # + # All date/time stamps are provided either by + # the ANSI date (from postgres) or by a date + # which can be processed via clock scan + # + + if {![info exists :dtstamp]} {set :dtstamp ${:creation_date}} + if {![info exists :last_modified]} {set :last_modified ${:dtstamp}} + + set tcl_stamp [::xo::db::tcl_date ${:dtstamp} tz] + set tcl_creation_date [::xo::db::tcl_date ${:creation_date} tz] + set tcl_last_modified [::xo::db::tcl_date ${:last_modified} tz] + + # status values: + # VEVENT: TENTATIVE, CONFIRMED, CANCELLED + # VTODO: NEEDS-ACTION, COMPLETED, IN-PROCESS, CANCELLED + # VJOURNAL: DRAFT, FINAL, CANCELLED + + append t \ + [:tag -conv tcl_time_to_utc -value $tcl_creation_date created] \ + [:tag -conv tcl_time_to_utc -value $tcl_last_modified last-modified] \ + [:tag -conv tcl_time_to_utc -value $tcl_stamp dtstamp] \ + [:start_end] \ + [:tag -conv tcl_time_to_utc completed] \ + [:tag -conv tcl_time_to_utc percent-complete] \ + [:tag transp] \ + [:tag uid] \ + [:tag url] \ + [:tag geo] \ + [:tag priority] \ + [:tag sequence] \ + [:tag CLASS] \ + [:tag -conv text_to_ical location] \ + [:tag status] \ + [:tag -conv text_to_ical description] \ + [:tag -conv text_to_ical summary] \ + [:tag -conv tcl_time_to_utc due] + + if {[info exists :formatted_recurrences]} { + append t ${:formatted_recurrences} + } + return $t + } + + :public method "parse RRULE" {recurrule} { + # + # parse recurrence rule provided in cal syntax. This method + # assumes that the instance variable dtstart is already set, + # before this method is called. + # + set r_freq "" + set every_n 1 + set r_error 0 + set r_until "" + set days_of_week "" + set r_count 0 + foreach rval [split $recurrule ";"] { + if { [regexp {^FREQ\=+(.*)$} $rval _ freqval] } { + switch $freqval { + DAILY { set r_freq "day" } + WEEKLY { set r_freq "week" } + MONTHLY { set r_freq "month_by_day"} + YEARLY { set r_freq "year"} + default { set r_error 1 } + } + } elseif { [regexp {^COUNT=(.*)$} $rval _ countval] } { + set r_count $countval + } elseif { [regexp {^UNTIL=([0-9]+)(T([0-9]+)Z?)?$} $rval _ untildate untiltime] } { + if {$untiltime eq ""} { + set untiltime 000000 + } + append r_until \ + "[string range $untildate 0 3]-[string range $untildate 4 5]-[string range $untildate 6 7]" \ + " " \ + "[string range $untiltime 0 1]:[string range $untiltime 2 3]" + } elseif { [regexp {^INTERVAL\=+(.*)$} $rval _ intval] } { + set every_n $intval + } elseif { [regexp {^BYDAY\=+(.*)$} $rval _ bydayval] } { + # + # Build days_of_week list + # + foreach dayval [split $bydayval ","] { + switch $dayval { + SU { lappend days_of_week "0" } + MO { lappend days_of_week "1" } + TU { lappend days_of_week "2" } + WE { lappend days_of_week "3" } + TH { lappend days_of_week "4" } + FR { lappend days_of_week "5" } + SA { lappend days_of_week "6" } + } + } + } elseif { [regexp {^BYMONTHDAY\=+(.*)$} $rval _ bymonthdayval] } { + set r_freq "month_by_date" + } else { + # other rules don't work with OpenACS recurrence model + :debug "ignore recurrence rule <$rval> of <$recurrule>" } - append lines $result - set result $lines + #check we can make this rule, else ignore } - append result \r\n - #ns_log notice "::xo::ical::VCALITEM tag [self args] -> len: [string length $result]" - } else { - set result "" + + # + # We should have now 'r_freq' computed. If 'r_until' is not + # provided, calculate it based on 'r_count' (COUNT is not + # directly supported by OpenACS). If both UNTIL and COUNT are + # not set, it is an unlimited event and skipped. + # + if { $r_until eq "" && $r_freq ne "" && $r_count > 0 } { + # current date + r_count * r_freq * every_n (/ num_days) + # set num seconds per frequency + switch $r_freq { + day { set r_freq_amount 86400 } + week { set r_freq_amount 604800 } + month_by_day { set r_freq_amount 2419200 } + month_by_date { set r_freq_amount 2678400 } + year { set r_freq_amount 31449600 } + } + # start date is count=1, so adjust count + set r_count [expr {$r_count - 1}] + set r_extra [expr {$r_count * $r_freq_amount * $every_n}] + if { $r_freq eq "week" && [llength $days_of_week] > 0} { + set r_extra [expr {$r_extra / [llength $days_of_week]}] + } + set r_until [::xo::ical clock_to_oacstime [expr {[clock scan ${:dtstart}] + $r_extra}]] + } + + # + # If we have no errors, and 'r_freq' is computed, then keep the + # computed values in form of a parameter list for + # calendar::item::add_recurrence + # + if { !$r_error && $r_freq ne ""} { + set :recurrence_options [list -interval_type $r_freq -every_n $every_n] + if {$days_of_week ne ""} { + lappend :recurrence_options -days_of_week $days_of_week + } + if {$r_until ne ""} { + lappend :recurrence_options -recur_until $r_until + } + } } - return $result - } - ::xo::ical::VCALITEM instproc start_end {} { - if {${:is_day_item}} { - append result \ - "DTSTART;" [::xo::ical tcl_time_to_local_day ${:dtstart}] \r\n - } else { - append result \ - [:tag -conv tcl_time_to_utc dtstart] \ - [:tag -conv tcl_time_to_utc dtend] + :public method add_recurrence {-cal_item_id:integer} { + # + # Call calendar::item::add_recurrence with the options + # calculated by "parse RRULE" + # + if { [info exists :recurrence_options] } { + calendar::item::add_recurrence \ + -cal_item_id $cal_item_id \ + {*}${:recurrence_options} + } } - } - ::xo::ical::VCALITEM instproc as_ical {} { - set item_type [namespace tail [:info class]] - append t "BEGIN:$item_type\r\n" \ - [:ical_body] \ - "END:$item_type\r\n" - return $t + :public method edit_recurrence { + -cal_item_id:integer + -recurrence_id:integer,0..1 + } { + # + # We might or might not have a recurrence on the old entry. + # In case we have one, the old one might have started earlier, + # so we try to terminate it. + :debug "do we have a recurrence? [info exists :recurrence_options]" + if { [info exists :recurrence_options] } { + if {$recurrence_id ne ""} { + + # + # The current implementation of calendar::item::edit_recurrence is + # just built for the interface of the calendar package, which does + # not allow one to change multiple attributes. We have to assume, that a + # calendar client allows this. + # + # When the old start date is the same as the new start date, + # we could delete the recurrence and add a new one. + # + # calendar::item::delete_recurrence -recurrence_id $recurrence_id + + lassign [::xo::dc list -prepare integer get_old_start_date_and_event_id { + select start_date, + from acs_events e, timespans t, time_intervals i + where recurrence_id = :recurrence_id + and e.timespan_id = t.timespan_id + and i.interval_id = t.interval_id + order by 1 limit 1 + }] old_start_date old_event_id + + :debug "recurrence_id $recurrence_id old_event_id $old_event_id cal_item_id $cal_item_id \ + old_start_date $old_start_date new start_date ${:dtstart} opts ${:recurrence_options}" + + ::xo::db::sql::acs_event recurrence_timespan_edit \ + -event_id $old_event_id \ + -start_date $old_start_date \ + -end_date ${:dtstart} + } + calendar::item::add_recurrence \ + -cal_item_id $cal_item_id \ + {*}${:recurrence_options} + } + } + + # + # End of class definition of ::xo::ical::VCALITEM + # } - ::xo::ical::VCALITEM instproc ical_body {} { + nx::Class create ::xo::ical::VTODO -superclass ::xo::ical::VCALITEM { # - # The method ical_body returns the ical-formatted content of the - # variables. All variables of VEVENTs and VTODOs are listed below, - # since the names are distinct, and no methods are used. + # VTODO # - # So far there is no handling for the repetition fields (which - # might occur more than once). An option would be to handle these - # as lists. + # optional fields, must not occur more than once # + # class / *completed / *created / *description / *dtstamp / + # *dtstart / *geo / *last-mod / *location / organizer / + # *percent-complete / *priority / recurid / seq / *status / + # *summary / *uid / *url / # - # All date/timestamps are provided either by - # the ANSI date (from postgres) or by a date - # which can be processed via clock scan + # optional, but mutual exclusive + # *due / duration / # - if {![info exists :dtstamp]} {set :dtstamp ${:creation_date}} - if {![info exists :last_modified]} {set :last_modified ${:dtstamp}} + # optional fields, may occur more than once + # + # attach / attendee / categories / comment / contact / + # exdate / exrule / rstatus / related / resources / + # rdate / rrule / x-prop - set tcl_stamp [::xo::db::tcl_date ${:dtstamp} tz] - set tcl_creation_date [::xo::db::tcl_date ${:creation_date} tz] - set tcl_last_modified [::xo::db::tcl_date ${:last_modified} tz] + :property -accessor public due + :property -accessor public completed + :property -accessor public percent-complete + } - # status values: - # VEVENT: TENTATIVE, CONFIRMED, CANCELLED - # VTODO: NEEDS-ACTION, COMPLETED, IN-PROCESS, CANCELLED - # VJOURNAL: DRAFT, FINAL, CANCELLED - append t \ - [:tag -conv tcl_time_to_utc -value $tcl_creation_date created] \ - [:tag -conv tcl_time_to_utc -value $tcl_last_modified last-modified] \ - [:tag -conv tcl_time_to_utc -value $tcl_stamp dtstamp] \ - [:start_end] \ - [:tag -conv tcl_time_to_utc completed] \ - [:tag -conv tcl_time_to_utc percent-complete] \ - [:tag transp] \ - [:tag uid] \ - [:tag url] \ - [:tag geo] \ - [:tag priority] \ - [:tag sequence] \ - [:tag CLASS] \ - [:tag -conv text_to_ical location] \ - [:tag status] \ - [:tag -conv text_to_ical description] \ - [:tag -conv text_to_ical summary] \ - [:tag -conv tcl_time_to_utc due] - if {[info exists :formatted_recurrences]} { - append t ${:formatted_recurrences} - } - return $t - } + nx::Class create ::xo::ical::VEVENT -superclass ::xo::ical::VCALITEM { + # + # VEVENT + # + # optional fields, must not occur more than once + # + # class / *created / *description / *dtstart / *geo / + # *last-mod / *location / organizer / *priority / + # *dtstamp / seq / *status / *summary / transp / + # *uid / *url / recurid / + # + # dtend or duration may appear, but dtend and duration are mutual exclusive + # *dtend / duration / + # + # optional fields, may occur more than once + # + # attach / attendee / categories / comment / contact / + # exdate / exrule / rstatus / related / resources / + # rdate / rrule / x-prop + # - # - # VTODO - # - # optional fields, must not occur more than once - # - # class / *completed / *created / *description / *dtstamp / - # *dtstart / *geo / *last-mod / *location / organizer / - # *percent-complete / *priority / recurid / seq / *status / - # *summary / *uid / *url / - # - # optional, but mutual exclusive - # *due / duration / - # - # optional fields, may occur more than once - # - # attach / attendee / categories / comment / contact / - # exdate / exrule / rstatus / related / resources / - # rdate / rrule / x-prop + :property -accessor public dtend + :property -accessor public sequence + :property -accessor public transp + :property -accessor public formatted_recurrences - Class create ::xo::ical::VTODO -superclass ::xo::ical::VCALITEM -parameter { - due - completed - percent-complete + :public method finish {parse_error} { + # + # In case, there was no :dtend given, set it either to the value + # of :dtstart, or :dtstart + :duration + # + if {![info exists :dtend]} { + set end_clock [clock scan ${:dtstart}] + if {[info exists :duration]} { + incr end_clock ${:duration} + } + set :dtend [::xo::ical clock_to_oacstime $end_clock] + } + # TODO: not sure, for what purpose we need parse_error + incr $parse_error $parse_error + } } - # - # VEVENT - # - # optional fields, must not occur more than once - # - # class / *created / *description / *dtstart / *geo / - # *last-mod / *location / organizer / *priority / - # *dtstamp / seq / *status / *summary / transp / - # *uid / *url / recurid / - # - # dtend or duration may appear, but dtend and duration are mutual exclusive - # *dtend / duration / - # - # optional fields, may occur more than once - # - # attach / attendee / categories / comment / contact / - # exdate / exrule / rstatus / related / resources / - # rdate / rrule / x-prop - # - # just a stub for now - Class create ::xo::ical::VEVENT -superclass ::xo::ical::VCALITEM -parameter { - dtend - sequence - transp - formatted_recurrences - } # # This class is designed to be a mixin for an ordered composite @@ -301,4 +550,5 @@ # mode: tcl # tcl-indent-level: 2 # indent-tabs-mode: nil +# eval: (setq tcl-type-alist (remove* "method" tcl-type-alist :test 'equal :key 'car)) # End: