Command-line and Emacs Calendar Client
1open Icalendar 2 3module CalendarMap = Map.Make (struct 4 type t = string 5 6 let compare a b = String.compare a b 7end) 8 9type t = { path : string; mutable calendar_names : Event.t list CalendarMap.t } 10 11let get_calendar_path ~fs calendar_dir calendar_name_name = 12 Eio.Path.(fs / calendar_dir.path / calendar_name_name) 13 14let ensure_dir path = 15 try 16 Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 path; 17 Ok () 18 with Eio.Exn.Io _ as exn -> 19 Error 20 (`Msg 21 (Fmt.str "Failed to create directory %s: %a" (snd path) Eio.Exn.pp exn)) 22 23let create ~fs path = 24 match ensure_dir Eio.Path.(fs / path) with 25 | Ok () -> Ok { path; calendar_names = CalendarMap.empty } 26 | Error e -> Error e 27 28let list_calendar_names ~fs calendar_dir = 29 try 30 let dir = Eio.Path.(fs / calendar_dir.path) in 31 let calendar_names = 32 Eio.Path.read_dir dir 33 |> List.filter_map (fun file -> 34 if Eio.Path.is_directory Eio.Path.(dir / file) then Some file 35 else None) 36 |> List.sort (fun a b -> String.compare a b) 37 in 38 Ok calendar_names 39 with Eio.Exn.Io _ as exn -> 40 Error 41 (`Msg 42 (Fmt.str "Failed to list calendar directory %s: %a" calendar_dir.path 43 Eio.Exn.pp exn)) 44 45let rec load_events_recursive calendar_name dir_path = 46 try 47 Eio.Path.read_dir dir_path 48 |> List.fold_left 49 (fun acc name -> 50 let path = Eio.Path.(dir_path / name) in 51 if Eio.Path.is_directory path then 52 acc @ load_events_recursive calendar_name path 53 else if Filename.check_suffix name ".ics" then ( 54 try 55 let content = Eio.Path.load path in 56 match parse content with 57 | Ok calendar -> 58 acc 59 @ Event.events_of_icalendar ~file:path calendar_name calendar 60 | Error err -> 61 Printf.eprintf "Failed to parse %s: %s\n%!" (snd path) err; 62 acc 63 with Eio.Exn.Io _ as exn -> 64 Fmt.epr "Failed to read file %s: %a\n%!" (snd path) Eio.Exn.pp 65 exn; 66 acc) 67 else acc) 68 [] 69 with Eio.Exn.Io _ as exn -> 70 Fmt.epr "Failed to read directory %s: %a\n%!" (snd dir_path) Eio.Exn.pp exn; 71 [] 72 73let get_calendar_events ~fs calendar_dir calendar_name = 74 match CalendarMap.find_opt calendar_name calendar_dir.calendar_names with 75 | Some events -> Ok events 76 | None -> ( 77 let calendar_name_path = 78 get_calendar_path ~fs calendar_dir calendar_name 79 in 80 if not (Eio.Path.is_directory calendar_name_path) then Error `Not_found 81 else 82 try 83 let events = load_events_recursive calendar_name calendar_name_path in 84 calendar_dir.calendar_names <- 85 CalendarMap.add calendar_name events calendar_dir.calendar_names; 86 Ok events 87 with e -> 88 Error 89 (`Msg 90 (Printf.sprintf "Exception processing directory %s: %s" 91 (snd calendar_name_path) (Printexc.to_string e)))) 92 93let ( let* ) = Result.bind 94 95let get_events ~fs calendar_dir = 96 match list_calendar_names ~fs calendar_dir with 97 | Error e -> Error e 98 | Ok ids -> ( 99 try 100 let rec process_ids acc = function 101 | [] -> Ok (List.rev acc) 102 | id :: rest -> ( 103 match get_calendar_events ~fs calendar_dir id with 104 | Ok cal -> process_ids (cal :: acc) rest 105 | Error `Not_found -> process_ids acc rest 106 | Error (`Msg e) -> Error (`Msg e)) 107 in 108 let* calendar_names = process_ids [] ids in 109 Ok (List.flatten calendar_names) 110 with exn -> 111 Error 112 (`Msg 113 (Printf.sprintf "Error getting calendar_names: %s" 114 (Printexc.to_string exn)))) 115 116let add_event ~fs calendar_dir event = 117 let calendar_name = Event.get_calendar_name event in 118 let file = Event.get_file event in 119 let calendar_name_path = get_calendar_path ~fs calendar_dir calendar_name in 120 let* () = ensure_dir calendar_name_path in 121 let calendar = Event.to_ical_calendar event in 122 let content = Icalendar.to_ics ~cr:true calendar in 123 let* _ = 124 try 125 Eio.Path.save ~create:(`Or_truncate 0o644) file content; 126 Ok () 127 with Eio.Exn.Io _ as exn -> 128 Error 129 (`Msg 130 (Fmt.str "Failed to write file %s: %a\n%!" (snd file) Eio.Exn.pp exn)) 131 in 132 calendar_dir.calendar_names <- 133 CalendarMap.add calendar_name 134 (event 135 :: 136 (match CalendarMap.find_opt calendar_name calendar_dir.calendar_names with 137 | Some lst -> lst 138 | None -> [])) 139 calendar_dir.calendar_names; 140 Ok () 141 142let edit_event ~fs calendar_dir event = 143 let calendar_name = Event.get_calendar_name event in 144 let event_id = Event.get_id event in 145 let calendar_name_path = get_calendar_path ~fs calendar_dir calendar_name in 146 let* () = ensure_dir calendar_name_path in 147 let ical_event = Event.to_ical_event event in 148 let file = Event.get_file event in 149 let existing_props, existing_components = Event.to_ical_calendar event in 150 let calendar = 151 (* Replace the event with our updated version *) 152 let filtered_components = 153 List.filter 154 (function 155 | `Event e -> 156 (* Filter out the old event *) 157 let uid = e.Icalendar.uid in 158 snd uid <> event_id 159 | _ -> true) 160 existing_components 161 in 162 (existing_props, `Event ical_event :: filtered_components) 163 in 164 let content = Icalendar.to_ics ~cr:true calendar in 165 let* _ = 166 try 167 Eio.Path.save ~create:(`Or_truncate 0o644) file content; 168 Ok () 169 with Eio.Exn.Io _ as exn -> 170 Error 171 (`Msg 172 (Fmt.str "Failed to write file %s: %a\n%!" (snd file) Eio.Exn.pp exn)) 173 in 174 calendar_dir.calendar_names <- 175 CalendarMap.add calendar_name 176 (event 177 :: 178 (match CalendarMap.find_opt calendar_name calendar_dir.calendar_names with 179 (* filter old version *) 180 | Some lst -> List.filter (fun e -> Event.get_id e = event_id) lst 181 | None -> [])) 182 calendar_dir.calendar_names; 183 Ok () 184 185let delete_event ~fs calendar_dir event = 186 let calendar_name = Event.get_calendar_name event in 187 let event_id = Event.get_id event in 188 let calendar_name_path = get_calendar_path ~fs calendar_dir calendar_name in 189 let* () = ensure_dir calendar_name_path in 190 let file = Event.get_file event in 191 let existing_props, existing_components = Event.to_ical_calendar event in 192 let other_events = ref false in 193 let calendar = 194 (* Replace the event with our updated version *) 195 let filtered_components = 196 List.filter 197 (function 198 | `Event e -> 199 (* Filter out the old event *) 200 let uid = e.Icalendar.uid in 201 if snd uid = event_id then false 202 else ( 203 other_events := true; 204 true) 205 | _ -> true) 206 existing_components 207 in 208 (existing_props, filtered_components) 209 in 210 let content = Icalendar.to_ics ~cr:true calendar in 211 let* _ = 212 try 213 (match !other_events with 214 | true -> Eio.Path.save ~create:(`Or_truncate 0o644) file content 215 | false -> Eio.Path.unlink file); 216 Ok () 217 with Eio.Exn.Io _ as exn -> 218 Error 219 (`Msg 220 (Fmt.str "Failed to write file %s: %a\n%!" (snd file) Eio.Exn.pp exn)) 221 in 222 calendar_dir.calendar_names <- 223 CalendarMap.add calendar_name 224 (match CalendarMap.find_opt calendar_name calendar_dir.calendar_names with 225 (* filter old version *) 226 | Some lst -> List.filter (fun e -> Event.get_id e = event_id) lst 227 | None -> []) 228 calendar_dir.calendar_names; 229 Ok () 230 231let get_path t = t.path