From 28df24caa1b1df90f4c2c4905d8b9cef4cb1df07 Mon Sep 17 00:00:00 2001 From: Paolo Donadeo Date: Sun, 17 May 2026 12:58:08 +0200 Subject: [PATCH] feat: support EXDATE, DATE+DURATION, and weekly day inference - Handle DATE+DURATION all-day events by computing end_date from duration - Add PUSH/POP-OMIT-CONTEXT and OMIT/SKIP support for EXDATE in daily/weekly rendering - Adjust UNTIL date when local time of UNTIL timestamp precedes event start time - Default weekly RRULE to event's own weekday when BYDAY list is empty - Remove EXDATE check from complex-recurrence guard (handled via OMIT now) - Add timedesc_wd_to_ical weekday conversion utility - Remove verbose EXDATE debug logging - Fix minor newline in file processing log message --- bin/eventPredicates.ml | 15 +++++++++----- bin/main.ml | 2 +- bin/remind.ml | 44 ++++++++++++++++++++++++++++++++++++++++-- bin/utils.ml | 15 +++++++++----- 4 files changed, 63 insertions(+), 13 deletions(-) diff --git a/bin/eventPredicates.ml b/bin/eventPredicates.ml index 4de96df..04f0068 100644 --- a/bin/eventPredicates.ml +++ b/bin/eventPredicates.ml @@ -82,7 +82,7 @@ open Utils snippet: 'REM Jul 29 MSG Compleanno' priorita: Dopo - - id: P10 + - id: P10 ✅ pattern: Eccezioni ics: "EXDATE (una o più), RDATE aggiuntive" remind_support: nativo+accorgimenti @@ -192,9 +192,13 @@ let collect_start_end_duration rem ev : (Remind.rem, error) result = Ok { rem with Remind.date = day_start; Remind.end_date = None } else Ok { rem with Remind.date = day_start; Remind.end_date = Some day_end } end - | Some (`Duration (_, _duration)) -> - (* Start is a date, duration is not supported: invalid case for all-day event *) - skip + | Some (`Duration (_, duration)) -> + (* DATE + DURATION: compute end_date as start + duration_in_days - 1 *) + let days, _ = Ptime.Span.to_d_ps duration in + if days <= 1 then Ok { rem with Remind.date = day_start; Remind.end_date = None } + else + let day_end = Timedesc.Date.add ~days:(days - 1) day_start in + Ok { rem with Remind.date = day_start; Remind.end_date = Some day_end } end) | `Datetime datetime -> begin let start_td = Utils.timedesc_of_timestamp datetime in @@ -291,7 +295,7 @@ RRULE: (`Weekly, (Some `Until (`Utc (2026-07-01 09:00:00 +00:00))), None, []) | Some (_, (`Yearly, None, None, [])) -> Ok rem (* handled in yearly_simple_date *) | Some (_, ((`Weekly as freq), count_or_until, interval, recurs)) | Some (_, ((`Daily as freq), count_or_until, interval, recurs)) -> - begin if List.length rem.recurring > 0 || List.length rem.exdate > 0 then ( + begin if List.length rem.recurring > 0 then ( Printf.eprintf "Warning: skipping complex recurrence with EXDATE/RDATE/overrides, not supported\t\t\tUID: %s\n" (Utils.get_uid ev); debug_print_of_recurrence_and_skip ev (freq, count_or_until, interval, recurs)) @@ -311,6 +315,7 @@ RRULE: (`Weekly, (Some `Until (`Utc (2026-07-01 09:00:00 +00:00))), None, []) match freq with | `Daily -> Ok { rem with Remind.weekly = None; Remind.daily = Some { count_or_until; interval; week_start } } | `Weekly -> + let days = if days = [] then [ timedesc_wd_to_ical (Timedesc.Date.weekday rem.date) ] else days in Ok { rem with diff --git a/bin/main.ml b/bin/main.ml index 2c6da95..b4adce9 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -18,7 +18,7 @@ let ical2rem ical_files = let good_rems = ListLabels.fold_left ~init:[] ical_files ~f:(fun good_rems_acc filename -> try - Printf.eprintf "\nProcessing file: %s\n" filename; + Printf.eprintf "Processing file: %s\n" filename; let file_content = read_file filename in let basename = Filename.remove_extension (Filename.basename filename) in match Icalendar.parse file_content with diff --git a/bin/remind.ml b/bin/remind.ml index 79a2a38..576f008 100644 --- a/bin/remind.ml +++ b/bin/remind.ml @@ -67,12 +67,24 @@ let add_interval_daily b (d : simple_daily) = let n = Option.value ~default:1 d.interval in Buffer.add_string b (spf "*%d " n) +(** Adjust an UNTIL date: if the event has a start time and the local time of the UNTIL timestamp is strictly before + that start time, the last valid occurrence is on the previous day. *) +let until_date_adjusted (until_ts : Timedesc.t) (event_time : Timedesc.Time.t option) : Timedesc.Date.t = + let until_date = Timedesc.date until_ts in + match event_time with + | None -> until_date + | Some evt_t -> + let until_t = Timedesc.time until_ts in + let cmp = Timedesc.Span.compare (Timedesc.Time.to_span until_t) (Timedesc.Time.to_span evt_t) in + if cmp < 0 then Timedesc.Date.add ~days:(-1) until_date else until_date + let add_until b rem (w : simple_weekly) = match w.count_or_until with | None -> () | Some (`Until d) -> let ts = timedesc_of_utc_or_timestamp_local d in - Buffer.add_string b (spf "UNTIL %s " (Timedesc.Date.to_rfc3339 (Timedesc.date ts))) + let date = until_date_adjusted ts rem.time in + Buffer.add_string b (spf "UNTIL %s " (Timedesc.Date.to_rfc3339 date)) | Some (`Count count) -> let wd = Timedesc.Date.weekday rem.date in let wd_int = Timedesc.Utils.tm_int_of_weekday wd in @@ -90,7 +102,8 @@ let add_until_daily b rem (d : simple_daily) = | None -> () | Some (`Until dt) -> let ts = timedesc_of_utc_or_timestamp_local dt in - Buffer.add_string b (spf "UNTIL %s " (Timedesc.Date.to_rfc3339 (Timedesc.date ts))) + let date = until_date_adjusted ts rem.time in + Buffer.add_string b (spf "UNTIL %s " (Timedesc.Date.to_rfc3339 date)) | Some (`Count count) -> let iv = Option.value ~default:1 d.interval in let until = Timedesc.Date.add ~days:((count - 1) * iv) rem.date in @@ -110,10 +123,32 @@ let add_through b = function let add_msg b summary = Buffer.add_string b (spf " MSG %s\n" summary) +let date_of_date_or_datetime (d : Icalendar.date_or_datetime) : Timedesc.Date.t = + match d with + | `Date (year, month, day) -> Timedesc.Date.Ymd.make_exn ~year ~month ~day + | `Datetime ts -> Timedesc.date (timedesc_of_timestamp ts) + +let add_omit b (d : Icalendar.date_or_datetime) = + let date = date_of_date_or_datetime d in + let day = Timedesc.Date.day date in + let month = string_of_month (month_of_int (Timedesc.Date.month date)) in + let year = Timedesc.Date.year date in + Buffer.add_string b (spf "OMIT %d %s %d\n" day month year) + +let add_omit_context b exdates = + if exdates <> [] then begin + Buffer.add_string b "PUSH-OMIT-CONTEXT\n"; + List.iter (add_omit b) exdates + end + +let close_omit_context b exdates = if exdates <> [] then Buffer.add_string b "POP-OMIT-CONTEXT\n" +let add_skip b exdates = if exdates <> [] then Buffer.add_string b "SKIP " + (* ── rendering ────────────────────────────────────────────────── *) let render_daily rem (d : simple_daily) = let b = Buffer.create 256 in + add_omit_context b rem.exdate; add_rem b; add_info b rem.original_uuid; add_source b rem.source; @@ -121,13 +156,16 @@ let render_daily rem (d : simple_daily) = Buffer.add_char b ' '; add_interval_daily b d; add_until_daily b rem d; + add_skip b rem.exdate; add_at b rem.time; add_duration b rem.duration; add_msg b rem.summary; + close_omit_context b rem.exdate; Buffer.contents b let render_weekly rem (w : simple_weekly) = let b = Buffer.create 256 in + add_omit_context b rem.exdate; List.iter (fun wd -> add_rem b; @@ -138,10 +176,12 @@ let render_weekly rem (w : simple_weekly) = Buffer.add_char b ' '; add_interval b w; add_until b rem w; + add_skip b rem.exdate; add_at b rem.time; add_duration b rem.duration; add_msg b rem.summary) w.byday; + close_omit_context b rem.exdate; Buffer.contents b let render_single rem = diff --git a/bin/utils.ml b/bin/utils.ml index 4c238ba..5bf9d09 100644 --- a/bin/utils.ml +++ b/bin/utils.ml @@ -2,6 +2,16 @@ open Icalendar type months = Jan | Feb | Mar | Apr | May | Jun | Jul | Aug | Sep | Oct | Nov | Dec +let timedesc_wd_to_ical (wd : Timedesc.weekday) : Icalendar.weekday = + match wd with + | `Mon -> `Monday + | `Tue -> `Tuesday + | `Wed -> `Wednesday + | `Thu -> `Thursday + | `Fri -> `Friday + | `Sat -> `Saturday + | `Sun -> `Sunday + let show_error (e : Timedesc.Date.Ymd.error) : string = match e with | `Does_not_exist -> "Date does not exist" @@ -103,7 +113,6 @@ let timedesc_of_utc_or_timestamp_local (ts : utc_or_timestamp_local) : Timedesc. | `Utc t -> t |> Timedesc.Utils.timestamp_of_ptime |> Timedesc.of_timestamp_exn ~tz_of_date_time:local_tz let get_exdates ev = - let uid = get_uid ev in let event_props = ev.props in let dates_or_datetimes = List.filter_map @@ -121,10 +130,6 @@ let get_exdates ev = | `Datetimes ts_list -> (acc_datetimes @ ts_list, acc_dates)) in - if List.length dates > 0 then Printf.eprintf "Found EXDATE with dates: %d entries; UID: %s\n" (List.length dates) uid; - if List.length datetimes > 0 then - Printf.eprintf "Found EXDATE with datetimes: %d entries; UID: %s\n" (List.length datetimes) uid; - List.map (fun d -> `Date d) dates @ List.map (fun dt -> `Datetime dt) datetimes let get_rdates ev =