feat: initial implementation of iCalendar to Remind converter

- Add project scaffolding (dune, dune-project, opam, .ocamlformat)
- Implement basic parsing and handling of iCalendar events
- Add event predicates for common event types (all-day, timed,
  recurrence, exceptions)
- Add transformation logic to map iCalendar events to Remind format
  (stub implementation)
- Provide utilities for extracting event details and converting
  dates/times
- Set up executable entrypoint and command-line interface using Cmdliner
- Include Remind event type definitions and helpers
This commit is contained in:
2025-11-30 19:33:35 +01:00
parent d79471cc62
commit 83dfd0dfa9
12 changed files with 2645 additions and 0 deletions

19
.ocamlformat Normal file
View File

@@ -0,0 +1,19 @@
version = 0.28.1
profile = conventional
break-cases = fit-or-vertical
break-infix = fit-or-vertical
break-separators = after
cases-exp-indent = 2
exp-grouping = preserve
if-then-else = keyword-first
leading-nested-match-parens = false
let-and = sparse
margin = 120
space-around-arrays = false
space-around-lists = false
space-around-records = false
space-around-records = true
space-around-variants = false
type-decl = sparse
wrap-fun-args = false

17
bin/commandLine.ml Normal file
View File

@@ -0,0 +1,17 @@
open Cmdliner
open Cmdliner.Term.Syntax
let ical_file =
let doc = "TODO" in
let docv = "ICAL" in
Arg.(required & pos ~rev:true 0 (some string) None & info [] ~docv ~doc)
let main_command f =
let doc = "Convert iCalendar files to remind format" in
let man = [] in
Cmd.make (Cmd.info "ical2rem" ~version:"%%VERSION%%" ~doc ~man)
@@
let+ ical_file = ical_file in
f ical_file
let main f = Cmd.eval @@ main_command f

13
bin/dune Normal file
View File

@@ -0,0 +1,13 @@
(executable
(public_name remind_sync)
(name main)
(modules main commandLine remind eventTransformer eventPredicates utils)
(preprocess
(pps ppx_deriving.show))
(libraries
;remind_sync
cmdliner
icalendar
timedesc-tzdb.full
timedesc-tzlocal.unix
timedesc))

2301
bin/eventPredicates.ml Normal file

File diff suppressed because it is too large Load Diff

15
bin/eventTransformer.ml Normal file
View File

@@ -0,0 +1,15 @@
let default_implementation = Remind.Omit (Ptime.epoch |> Ptime.to_date)
let remind_of_event (ev : Icalendar.event) : Remind.event =
let found =
ListLabels.fold_left ~init:[] EventPredicates.all_predicates ~f:(fun acc (pred, desc) ->
if pred ev then desc :: acc else acc)
|> List.rev
in
if List.length found > 0
then begin
Printf.printf " 󰧓 ⇒ matches these predicates:\n";
List.iter (fun d -> Printf.printf " - %s\n" (EventPredicates.show_event_description d)) found;
Printf.printf "\n"
end;
default_implementation

34
bin/main.ml Normal file
View File

@@ -0,0 +1,34 @@
let ical2rem ical_file =
let ic = open_in ical_file in
let n = in_channel_length ic in
let s = Bytes.create n in
really_input ic s 0 n;
close_in ic;
let cal_or_error = Icalendar.parse (Bytes.unsafe_to_string s) in
match cal_or_error with
| Error e -> prerr_endline ("Error parsing iCalendar file: " ^ e)
| Ok (_, components) -> begin
let events = ref 0 in
List.iter
(fun comp ->
match comp with
| `Event event ->
events := !events + 1;
let uid = Utils.get_uid event in
Printf.printf "󰧓 ⇒ UID: %s\n" uid;
Printf.printf "%s\n\n\n" (Icalendar.show_component comp)
| _ -> () (* Ignore non-event components *))
components;
Printf.printf "\nEvents: %d\n" !events;
let events =
List.filter_map
(function
| `Event ev -> Some ev
| _ -> None)
components
in
let _reminders = List.map EventTransformer.remind_of_event events in
()
end
let () = if !Sys.interactive then () else exit (CommandLine.main ical2rem)

119
bin/remind.ml Normal file
View File

@@ -0,0 +1,119 @@
(* Alias esplicito per chiarezza *)
type date = Ptime.date
let pp_date fmt (y, m, d) = Format.fprintf fmt "%04d-%02d-%02d" y m d
let show_date (d : date) : string =
let y, m, day = d in
Printf.sprintf "%04d-%02d-%02d" y m day
type tod = Ptime.Span.t (* secondi da mezzanotte locale *)
let pp_tod = Ptime.Span.pp
let span_min (m : int) : tod = Ptime.Span.of_int_s (m * 60)
let span_hm ~(h : int) ~(m : int) : tod = Ptime.Span.of_int_s ((h * 3600) + (m * 60))
let hm_of_tod (t : tod) : int * int =
match Ptime.Span.to_int_s t with
| None -> invalid_arg "tod out of range"
| Some s -> (s / 3600, s mod 3600 / 60)
type weekday =
| Mon
| Tue
| Wed
| Thu
| Fri
| Sat
| Sun
type time_spec =
| All_day
| Timed of {
at : tod;
duration : Ptime.Span.t option;
}
type range = {
from_ : date;
until : date option;
}
(* UNTIL inclusivo *)
type recurrence =
| No_recur
| Daily of {
interval : int;
span : range;
}
| Weekly of {
interval : int;
days : weekday list;
span : range;
}
| Monthly_dom of {
interval : int;
day : int;
span : range;
}
| Monthly_nth_weekday of {
interval : int;
n : int;
wday : weekday;
span : range;
}
| Yearly of {
month : int;
day : int;
}
type alarm = { warn_before : Ptime.Span.t }
type meta = {
location : string option;
url : string option;
categories : string list;
attendees : string list;
}
type instance_override = {
on : date;
cancel : bool;
summary : string option;
time_spec : time_spec option;
notes : string option;
alarm : alarm option;
meta : meta option;
}
type series = {
summary : string;
start : date; (* DTSTART locale *)
time_spec : time_spec;
recur : recurrence;
exdates : date list;
overrides : instance_override list;
alarm : alarm option;
notes : string option;
meta : meta;
}
type info_header = string [@@deriving show]
type info_value = string [@@deriving show]
type event =
| Rem of {
date_expr : string;
at : tod option;
duration : Ptime.Span.t option;
warn : Ptime.Span.t option;
msg : string;
exdates : date list;
infos : (info_header * info_value) list;
}
| Omit of date
[@@deriving show]
let string_of_date (d : date) : string =
let y, m, day = d in
Printf.sprintf "%04d-%02d-%02d" y m day

70
bin/utils.ml Normal file
View File

@@ -0,0 +1,70 @@
open Icalendar
let get_uid ev =
let _, uid = ev.uid in
uid
let timedesc_of_date_or_datetime (t : date_or_datetime) : Timedesc.t =
match t with
| `Datetime (`Local _ptime_ts) ->
(* TODO: this case is not present in my current dataset… *)
failwith "Unhandled case: `Local datetime"
| `Datetime (`Utc ts) ->
Timedesc.Utils.timestamp_of_ptime ts |> Timedesc.of_timestamp_exn ~tz_of_date_time:(Timedesc.Time_zone.local_exn ())
| `Datetime (`With_tzid (ts, (_b, tz_name))) ->
(* Qui il timestamp è SCRITTO come se fosse UTC (+00:00) ma in realtà va interpretato con
il fuso orario indicato da tz_name. *)
let tz = Timedesc.Time_zone.make_exn tz_name in
let wrong_ts = Timedesc.Utils.timestamp_of_ptime ts in
let date = Timedesc.date (Timedesc.of_timestamp_exn ~tz_of_date_time:Timedesc.Time_zone.utc wrong_ts) in
let year, month, day = (Timedesc.Date.year date, Timedesc.Date.month date, Timedesc.Date.day date) in
let time = Timedesc.time_view (Timedesc.of_timestamp_exn ~tz_of_date_time:Timedesc.Time_zone.utc wrong_ts) in
let hour, minute, second = (time.Timedesc.Time.hour, time.Timedesc.Time.minute, time.Timedesc.Time.second) in
Timedesc.make_exn ~year ~month ~day ~hour ~minute ~second ~tz ()
| `Date (year, month, day) ->
Timedesc.make_exn ~year ~month ~day ~hour:0 ~minute:0 ~second:0 ~tz:(Timedesc.Time_zone.local_exn ()) ()
let get_start ev =
let _, start = ev.dtstart in
timedesc_of_date_or_datetime start
let get_exdates ev =
let event_props = ev.props in
let dates_or_datetimes =
List.filter_map
(fun prop ->
match prop with
| `Exdate (_, dates) -> Some dates
| _ -> None)
event_props
in
ListLabels.fold_left ~init:[] dates_or_datetimes ~f:(fun acc dates ->
let added =
match dates with
| `Datetimes ts_list -> List.map (fun ts -> `Datetime ts) ts_list
| `Dates date_list -> List.map (fun date -> `Date date) date_list
in
added @ acc)
|> List.map timedesc_of_date_or_datetime
let get_rdates ev =
let event_props = ev.props in
let dates_or_datetimes =
List.filter_map
(fun prop ->
match prop with
| `Rdate (_, dates) -> Some dates
| _ -> None)
event_props
in
ListLabels.fold_left ~init:[] dates_or_datetimes ~f:(fun acc dates ->
let added =
match dates with
| `Datetimes ts_list -> List.map (fun ts -> `Datetime ts) ts_list
| `Dates date_list -> List.map (fun date -> `Date date) date_list
| `Periods _ ->
(* TODO: Ignored for now, does not appear in my current dataset *)
failwith "Unhandled case: `Periods in RDATE"
in
added @ acc)
|> List.map timedesc_of_date_or_datetime

1
dune Normal file
View File

@@ -0,0 +1 @@
(data_only_dirs contrib)

24
dune-project Normal file
View File

@@ -0,0 +1,24 @@
(lang dune 3.20)
(name remind_sync)
(generate_opam_files true)
(source
(uri https://git.donadeo.net/pdonadeo/remind-sync))
(authors "Paolo Donadeo <paolo@donadeo.net>")
(maintainers "Maintainer Name <maintainer@example.com>")
(license MIT)
(documentation https://git.donadeo.net/pdonadeo/remind-sync)
(package
(name remind_sync)
(synopsis "A short synopsis")
(description "A longer description")
(depends ocaml)
(tags
("add topics" "to describe" your project)))

2
lib/dune Normal file
View File

@@ -0,0 +1,2 @@
(library
(name remind_sync))

30
remind_sync.opam Normal file
View File

@@ -0,0 +1,30 @@
# This file is generated by dune, edit dune-project instead
opam-version: "2.0"
synopsis: "A short synopsis"
description: "A longer description"
maintainer: ["Maintainer Name <maintainer@example.com>"]
authors: ["Paolo Donadeo <paolo@donadeo.net>"]
license: "MIT"
tags: ["add topics" "to describe" "your" "project"]
doc: "https://git.donadeo.net/pdonadeo/remind-sync"
depends: [
"dune" {>= "3.20"}
"ocaml"
"odoc" {with-doc}
]
build: [
["dune" "subst"] {dev}
[
"dune"
"build"
"-p"
name
"-j"
jobs
"@install"
"@runtest" {with-test}
"@doc" {with-doc}
]
]
dev-repo: "https://git.donadeo.net/pdonadeo/remind-sync"
x-maintenance-intent: ["(latest)"]