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