I calendar parsing
1

Configure Feed

Select the types of activity you want to include in your feed.

inital commit

author
Hannes Nevalainen
date (Jul 26, 2026, 10:29 PM +0200) commit 7e53fb62
+436
+23
.github/workflows/test.yml
··· 1 + name: test 2 + 3 + on: 4 + push: 5 + branches: 6 + - master 7 + - main 8 + pull_request: 9 + 10 + jobs: 11 + test: 12 + runs-on: ubuntu-latest 13 + steps: 14 + - uses: actions/checkout@v6 15 + - uses: erlef/setup-beam@v1 16 + with: 17 + otp-version: "29" 18 + gleam-version: "1.17.0" 19 + rebar3-version: "3" 20 + # elixir-version: "1" 21 + - run: gleam deps download 22 + - run: gleam test 23 + - run: gleam format --check src test
+5
.gitignore
··· 1 + *.beam 2 + *.ez 3 + /build 4 + erl_crash.dump 5 + calendar.ical
+24
README.md
··· 1 + # gcal 2 + 3 + [![Package Version](https://img.shields.io/hexpm/v/gcal)](https://hex.pm/packages/gcal) 4 + [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/gcal/) 5 + 6 + ```sh 7 + gleam add gcal@1 8 + ``` 9 + ```gleam 10 + import gcal 11 + 12 + pub fn main() -> Nil { 13 + // TODO: An example of the project in use 14 + } 15 + ``` 16 + 17 + Further documentation can be found at <https://hexdocs.pm/gcal>. 18 + 19 + ## Development 20 + 21 + ```sh 22 + gleam run # Run the project 23 + gleam test # Run the tests 24 + ```
+20
gleam.toml
··· 1 + name = "gcal" 2 + version = "1.0.0" 3 + 4 + # Fill out these fields if you intend to generate HTML documentation or publish 5 + # your project to the Hex package manager. 6 + # 7 + # description = "" 8 + # licences = ["Apache-2.0"] 9 + # repository = { type = "github", user = "", repo = "" } 10 + # links = [{ title = "Website", href = "" }] 11 + # 12 + # For a full reference of all the available options, you can have a look at 13 + # https://gleam.run/writing-gleam/gleam-toml/. 14 + 15 + [dependencies] 16 + gleam_stdlib = ">= 1.0.0 and < 2.0.0" 17 + simplifile = ">= 2.6.0 and < 3.0.0" 18 + 19 + [dev_dependencies] 20 + gleeunit = ">= 1.0.0 and < 2.0.0"
+19
manifest.toml
··· 1 + # Do not manually edit this file, it is managed by Gleam. 2 + # 3 + # This file locks the dependency versions used, to make your build 4 + # deterministic and to prevent unexpected versions from being included 5 + # in your application. 6 + # 7 + # You should check this file into your source control repository. 8 + 9 + packages = [ 10 + { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, 11 + { name = "gleam_stdlib", version = "1.0.3", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1F543AFBA5D33DA493E6087F4E4C4F20D899411343512686C98A8ABB2963CF22" }, 12 + { name = "gleeunit", version = "1.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "EC31ABA74256AEA531EDF8169931D775BBB384FED0A8A1BDC4DD9354E3E21826" }, 13 + { name = "simplifile", version = "2.6.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "A33C345F0A4FFB91DCCD4220114534A58C387964A5F17B3E472CEBD1ADA9FFB4" }, 14 + ] 15 + 16 + [requirements] 17 + gleam_stdlib = { version = ">= 1.0.0 and < 2.0.0" } 18 + gleeunit = { version = ">= 1.0.0 and < 2.0.0" } 19 + simplifile = { version = ">= 2.6.0 and < 3.0.0" }
+222
src/gcal.gleam
··· 1 + import gleam/list 2 + import gleam/result 3 + import gleam/string 4 + 5 + pub type Calendar { 6 + Calendar( 7 + version: String, 8 + prodid: String, 9 + properties: List(Property), 10 + events: List(Event), 11 + ) 12 + } 13 + 14 + pub type Event { 15 + Event(properties: List(Property)) 16 + } 17 + 18 + pub type Property { 19 + Property(name: String, params: List(Parameter), value: String) 20 + } 21 + 22 + pub type Parameter { 23 + Parameter(name: String, value: String) 24 + } 25 + 26 + pub type Error { 27 + ParseError(String) 28 + } 29 + 30 + type Component { 31 + Component(kind: String, properties: List(Property), children: List(Component)) 32 + } 33 + 34 + pub fn parse(input: String) -> Result(Calendar, Error) { 35 + let lines = 36 + input 37 + |> string.replace("\r\n", "\n") 38 + |> string.replace("\r", "\n") 39 + |> string.split("\n") 40 + |> unfold_lines 41 + |> list.filter(fn(line) { line != "" }) 42 + 43 + case parse_all_components(lines, []) { 44 + Ok(components) -> build_calendar(components) 45 + Error(err) -> Error(err) 46 + } 47 + } 48 + 49 + fn unfold_lines(lines: List(String)) -> List(String) { 50 + lines 51 + |> list.fold([], fn(acc: List(String), line: String) { 52 + case acc { 53 + [] -> [line] 54 + [prev, ..rest] -> 55 + case string.starts_with(line, " ") || string.starts_with(line, "\t") { 56 + True -> 57 + case string.pop_grapheme(line) { 58 + Ok(#(_, remaining)) -> [prev <> remaining, ..rest] 59 + Error(_) -> [prev, ..acc] 60 + } 61 + False -> [line, prev, ..rest] 62 + } 63 + } 64 + }) 65 + |> list.reverse 66 + } 67 + 68 + fn parse_all_components( 69 + lines: List(String), 70 + acc: List(Component), 71 + ) -> Result(List(Component), Error) { 72 + case lines { 73 + [] -> Ok(list.reverse(acc)) 74 + ["BEGIN:" <> kind, ..rest] -> 75 + case parse_component(kind, rest, [], []) { 76 + Ok(#(component, remaining)) -> 77 + parse_all_components(remaining, [component, ..acc]) 78 + Error(err) -> Error(err) 79 + } 80 + [line, ..] -> Error(ParseError("Unexpected line: " <> line)) 81 + } 82 + } 83 + 84 + fn parse_component( 85 + kind: String, 86 + lines: List(String), 87 + props: List(Property), 88 + children: List(Component), 89 + ) -> Result(#(Component, List(String)), Error) { 90 + let end_marker = "END:" <> kind 91 + case lines { 92 + [] -> Error(ParseError("Unexpected end of input, missing END:" <> kind)) 93 + [line, ..rest] -> 94 + case line == end_marker { 95 + True -> 96 + Ok(#( 97 + Component(kind, list.reverse(props), list.reverse(children)), 98 + rest, 99 + )) 100 + False -> 101 + case string.starts_with(line, "BEGIN:") { 102 + True -> { 103 + let subkind = 104 + case string.split_once(line, "BEGIN:") { 105 + Ok(#(_, k)) -> k 106 + Error(_) -> "" 107 + } 108 + case parse_component(subkind, rest, [], []) { 109 + Ok(#(child, remaining)) -> 110 + parse_component(kind, remaining, props, [child, ..children]) 111 + Error(err) -> Error(err) 112 + } 113 + } 114 + False -> 115 + case parse_property(line) { 116 + Ok(prop) -> 117 + parse_component(kind, rest, [prop, ..props], children) 118 + Error(err) -> Error(err) 119 + } 120 + } 121 + } 122 + } 123 + } 124 + 125 + fn parse_property(line: String) -> Result(Property, Error) { 126 + case string.split_once(line, ":") { 127 + Ok(#(name_part, value)) -> { 128 + let #(name, params) = parse_name_and_params(name_part) 129 + Ok(Property(name, params, unescape_text(value))) 130 + } 131 + Error(_) -> Error(ParseError("Invalid property line: " <> line)) 132 + } 133 + } 134 + 135 + fn parse_name_and_params(part: String) -> #(String, List(Parameter)) { 136 + case string.split_once(part, ";") { 137 + Ok(#(name, params_str)) -> #(name, parse_params(params_str)) 138 + Error(_) -> #(part, []) 139 + } 140 + } 141 + 142 + fn parse_params(params_str: String) -> List(Parameter) { 143 + params_str 144 + |> string.split(";") 145 + |> list.filter_map(fn(param) { 146 + case string.split_once(param, "=") { 147 + Ok(#(name, value)) -> Ok(Parameter(name, value)) 148 + Error(_) -> Error(Nil) 149 + } 150 + }) 151 + } 152 + 153 + fn unescape_text(text: String) -> String { 154 + text 155 + |> string.replace("\\n", "\n") 156 + |> string.replace("\\,", ",") 157 + |> string.replace("\\;", ";") 158 + |> string.replace("\\\\", "\\") 159 + } 160 + 161 + fn flatten_components( 162 + components: List(Component), 163 + ) -> List(#(String, List(Property), List(Component))) { 164 + components 165 + |> list.map(fn(c) { #(c.kind, c.properties, c.children) }) 166 + } 167 + 168 + fn build_calendar( 169 + components: List(Component), 170 + ) -> Result(Calendar, Error) { 171 + let flat = flatten_components(components) 172 + case list.find(flat, fn(c) { c.0 == "VCALENDAR" }) { 173 + Ok(#(_, cal_props, cal_children)) -> { 174 + let version = 175 + cal_props 176 + |> list.find(fn(p) { p.name == "VERSION" }) 177 + |> result.map(fn(p) { p.value }) 178 + |> result.unwrap("") 179 + 180 + let prodid = 181 + cal_props 182 + |> list.find(fn(p) { p.name == "PRODID" }) 183 + |> result.map(fn(p) { p.value }) 184 + |> result.unwrap("") 185 + 186 + let events = 187 + cal_children 188 + |> list.filter_map(fn(c) { 189 + case c.kind { 190 + "VEVENT" -> Ok(Event(c.properties)) 191 + _ -> Error(Nil) 192 + } 193 + }) 194 + 195 + Ok(Calendar(version, prodid, cal_props, events)) 196 + } 197 + Error(_) -> Error(ParseError("No VCALENDAR component found")) 198 + } 199 + } 200 + 201 + pub fn get_event_property(event: Event, name: String) -> Result(String, Error) { 202 + case list.find(event.properties, fn(p) { p.name == name }) { 203 + Ok(prop) -> Ok(prop.value) 204 + Error(_) -> Error(ParseError("Property not found: " <> name)) 205 + } 206 + } 207 + 208 + pub fn get_event_summary(event: Event) -> Result(String, Error) { 209 + get_event_property(event, "SUMMARY") 210 + } 211 + 212 + pub fn get_event_uid(event: Event) -> Result(String, Error) { 213 + get_event_property(event, "UID") 214 + } 215 + 216 + pub fn get_event_location(event: Event) -> Result(String, Error) { 217 + get_event_property(event, "LOCATION") 218 + } 219 + 220 + pub fn get_event_description(event: Event) -> Result(String, Error) { 221 + get_event_property(event, "DESCRIPTION") 222 + }
+123
test/gcal_test.gleam
··· 1 + import gcal 2 + import gleam/io 3 + import gleam/list 4 + import gleam/string 5 + import gleeunit 6 + import simplifile 7 + 8 + pub fn main() -> Nil { 9 + gleeunit.main() 10 + } 11 + 12 + pub fn parse_simple_calendar_test() { 13 + let input = "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//Test//EN\nEND:VCALENDAR" 14 + let assert Ok(calendar) = gcal.parse(input) 15 + 16 + assert calendar.version == "2.0" 17 + assert calendar.prodid == "-//Test//EN" 18 + assert calendar.events == [] 19 + } 20 + 21 + pub fn parse_calendar_with_one_event_test() { 22 + let input = 23 + "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//Test//EN\nBEGIN:VEVENT\nSUMMARY:Meeting\nUID:123@test\nEND:VEVENT\nEND:VCALENDAR" 24 + let assert Ok(calendar) = gcal.parse(input) 25 + 26 + let assert [event] = calendar.events 27 + assert gcal.get_event_summary(event) == Ok("Meeting") 28 + assert gcal.get_event_uid(event) == Ok("123@test") 29 + } 30 + 31 + pub fn parse_event_with_location_test() { 32 + let input = 33 + "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//Test//EN\nBEGIN:VEVENT\nSUMMARY:Conference\nLOCATION:Stockholm\nUID:456@test\nEND:VEVENT\nEND:VCALENDAR" 34 + let assert Ok(calendar) = gcal.parse(input) 35 + let assert [event] = calendar.events 36 + assert gcal.get_event_location(event) == Ok("Stockholm") 37 + } 38 + 39 + pub fn parse_event_with_parameters_test() { 40 + let input = 41 + "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//Test//EN\nBEGIN:VEVENT\nDTSTART;TZID=Europe/Stockholm:20230101T100000\nSUMMARY:Test\nUID:789@test\nEND:VEVENT\nEND:VCALENDAR" 42 + let assert Ok(calendar) = gcal.parse(input) 43 + let assert [event] = calendar.events 44 + 45 + let assert Ok(dtstart) = 46 + list.find(event.properties, fn(p) { p.name == "DTSTART" }) 47 + 48 + let assert [param] = dtstart.params 49 + assert param.name == "TZID" 50 + assert param.value == "Europe/Stockholm" 51 + } 52 + 53 + pub fn parse_multiple_events_test() { 54 + let input = 55 + "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//Test//EN\nBEGIN:VEVENT\nSUMMARY:Event 1\nUID:1@test\nEND:VEVENT\nBEGIN:VEVENT\nSUMMARY:Event 2\nUID:2@test\nEND:VEVENT\nEND:VCALENDAR" 56 + let assert Ok(calendar) = gcal.parse(input) 57 + let assert [_, _] = calendar.events 58 + True 59 + } 60 + 61 + pub fn parse_folded_lines_test() { 62 + let input = 63 + "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//Test//EN\nBEGIN:VEVENT\nDESCRIPTION:This is a long des\n cription that spans\n multiple lines\nUID:fold@test\nEND:VEVENT\nEND:VCALENDAR" 64 + let assert Ok(calendar) = gcal.parse(input) 65 + let assert [event] = calendar.events 66 + assert gcal.get_event_description(event) 67 + == Ok("This is a long description that spans multiple lines") 68 + } 69 + 70 + pub fn parse_escaped_text_test() { 71 + let input = 72 + "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//Test//EN\nBEGIN:VEVENT\nDESCRIPTION:Line 1\\nLine 2 with\\, comma\nUID:esc@test\nEND:VEVENT\nEND:VCALENDAR" 73 + let assert Ok(calendar) = gcal.parse(input) 74 + let assert [event] = calendar.events 75 + let assert Ok(desc) = gcal.get_event_description(event) 76 + assert string.contains(desc, "\n") 77 + assert string.contains(desc, ",") 78 + } 79 + 80 + pub fn parse_empty_summary_test() { 81 + let input = 82 + "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//Test//EN\nBEGIN:VEVENT\nSUMMARY:\nUID:empty@test\nEND:VEVENT\nEND:VCALENDAR" 83 + let assert Ok(calendar) = gcal.parse(input) 84 + let assert [event] = calendar.events 85 + assert gcal.get_event_summary(event) == Ok("") 86 + } 87 + 88 + pub fn parse_real_ical_file_test() { 89 + let input = 90 + "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//caldav.icloud.com//CALDAVJ 2626B756//EN\nX-WR-CALNAME:Privat\nBEGIN:VEVENT\nCREATED:20171104T221935Z\nDTEND;TZID=Europe/Stockholm:20171128T203000\nDTSTAMP:20171125T133010Z\nDTSTART;TZID=Europe/Stockholm:20171128T193000\nLOCATION:Malmö Live\nSUMMARY:Anders och Måns\nUID:001E66D2-7DF8-40A8-B0BC-EBC4E3F9C2FD\nEND:VEVENT\nEND:VCALENDAR" 91 + let assert Ok(calendar) = gcal.parse(input) 92 + assert calendar.version == "2.0" 93 + assert calendar.prodid == "-//caldav.icloud.com//CALDAVJ 2626B756//EN" 94 + let assert [event] = calendar.events 95 + assert gcal.get_event_summary(event) == Ok("Anders och Måns") 96 + assert gcal.get_event_location(event) == Ok("Malmö Live") 97 + } 98 + 99 + pub fn get_property_not_found_test() { 100 + let event = gcal.Event([]) 101 + let assert Error(_) = gcal.get_event_property(event, "NONEXISTENT") 102 + } 103 + 104 + pub fn parse_real_calender_test() { 105 + let assert Ok(data) = simplifile.read("calendar.ical") 106 + 107 + let assert Ok(calendar) = gcal.parse(data) 108 + 109 + calendar.events 110 + |> list.map(pretty_print_event) 111 + |> string.join("\n------------------------\n") 112 + |> io.println_error 113 + } 114 + 115 + fn pretty_print_event(event: gcal.Event) { 116 + list.fold(event.properties, [], fn(acc, prop) { 117 + let params = 118 + list.map(prop.params, fn(param) { param.name <> "=" <> param.value }) 119 + |> string.join(", ") 120 + [prop.name <> " " <> string.inspect(prop.value) <> " " <> params, ..acc] 121 + }) 122 + |> string.join("\n") 123 + }