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