diff --git a/cmd/create_command.go b/cmd/create_command.go index 3f29205..548925d 100644 --- a/cmd/create_command.go +++ b/cmd/create_command.go @@ -21,6 +21,7 @@ const ( CVS Format = iota Table JSON + ICS ) func RunCreateShiftPlan(writer io.Writer, arguments []string) error { @@ -35,6 +36,9 @@ func RunCreateShiftPlan(writer io.Writer, arguments []string) error { JSON: func() apis.Exporter { return export.NewJSONExporter() }, + ICS: func() apis.Exporter { + return export.NewICSExporter() + }, } start := new(time.Time) @@ -52,7 +56,7 @@ func RunCreateShiftPlan(writer io.Writer, arguments []string) error { createCommand.Func("start", "(required) start time of the schedule plan", cli.TimeValueVar(start)) createCommand.Func("end", "(required) end time of the schedule plan", cli.TimeValueVar(end)) createCommand.Func("team-file", "(required) path to the file that contain all on-call duties", cli.FilePathVar(teamFilePath)) - createCommand.Func("output", "output format. One of (cvs, table, json)", cli.EnumValueVar(enums, &outputFormat)) + createCommand.Func("output", "output format. One of (cvs, table, json, ics)", cli.EnumValueVar(enums, &outputFormat)) createCommand.StringVar(primaryRules, "primary-rules", "vacation", "Rule to decide which employee should be on-call for the next shift") createCommand.StringVar(secondaryRules, "secondary-rules", "vacation", "Rule to decide which employee should be on-call for the next shift") createCommand.Usage = func() { diff --git a/internal/export/ics.go b/internal/export/ics.go new file mode 100644 index 0000000..52c344e --- /dev/null +++ b/internal/export/ics.go @@ -0,0 +1,76 @@ +package export + +import ( + _ "embed" + "errors" + "fmt" + "io" + "text/template" + + "github.com/orltom/on-call-schedule/pkg/apis" +) + +//go:embed ics.tmpl +var templateICSFile string + +var _ apis.Exporter = &TableExporter{} + +type event struct { + apis.Shift + UID string + Category string + XColor string + Attendee string + Summary string + Description string +} + +type ICSExporter struct { + template *template.Template +} + +func NewICSExporter() *ICSExporter { + tmpl, _ := template.New("ical").Parse(templateICSFile) + return &ICSExporter{template: tmpl} +} + +func (e *ICSExporter) Write(plan []apis.Shift, writer io.Writer) error { + if plan == nil { + return errors.New("shifts must not be nil") + } + + events := make([]event, 0, len(plan)) + for idx := range plan { + s := plan[idx] + events = append(events, mapPrimaryEvent(s), mapSecondaryEvent(s)) + } + + if err := e.template.ExecuteTemplate(writer, "icalCalendar", events); err != nil { + return fmt.Errorf("can not generate ICS: %w", err) + } + return nil +} + +func mapPrimaryEvent(shift apis.Shift) event { + return event{ + Shift: shift, + UID: "primary-" + shift.Start.Format("20060102"), + Category: "Primary", + XColor: "#FF5733", + Attendee: string(shift.Primary), + Summary: "Primary On-Call: " + string(shift.Primary), + Description: fmt.Sprintf("Primary: %s\\nSecondary: %s", shift.Primary, shift.Secondary), + } +} + +func mapSecondaryEvent(shift apis.Shift) event { + return event{ + Shift: shift, + UID: "secondary-" + shift.Start.Format("20060102"), + Category: "Secondary", + XColor: "#33FF57", + Attendee: string(shift.Secondary), + Summary: "Secondary On-Call: " + string(shift.Secondary), + Description: fmt.Sprintf("Primary: %s\\nSecondary: %s", shift.Primary, shift.Secondary), + } +} diff --git a/internal/export/ics.tmpl b/internal/export/ics.tmpl new file mode 100644 index 0000000..5b74fe9 --- /dev/null +++ b/internal/export/ics.tmpl @@ -0,0 +1,29 @@ +{{- define "icalEvent" }} +BEGIN:VEVENT +UID:{{ .UID }} +DTSTART:{{ .Start.Format "20060102T150405Z" }} +DTEND:{{ .End.Format "20060102T150405Z" }} +SUMMARY:{{ .Summary }} +DESCRIPTION:Primary: {{ .Primary }}\nSecondary: {{ .Secondary }} +CATEGORIES:{{ .Category }} +ATTENDEE:mailto:{{ .Attendee }} +X-COLOR:{{ .XColor }} +BEGIN:VALARM +TRIGGER:-PT8H +ACTION:DISPLAY +DESCRIPTION:Reminder: {{ .Summary }} starts in 8 hours. +END:VALARM +END:VEVENT +{{ end -}} + +{{- define "icalCalendar" -}} +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//ocsctl//on-call-shift//EN +CALSCALE:GREGORIAN +{{ range . }} +{{- template "icalEvent" . }} +{{- end }} +END:VCALENDAR + +{{- end -}} diff --git a/internal/export/ics_test.go b/internal/export/ics_test.go new file mode 100644 index 0000000..26e832d --- /dev/null +++ b/internal/export/ics_test.go @@ -0,0 +1,95 @@ +package export + +import ( + "bytes" + "testing" + + "github.com/orltom/on-call-schedule/pkg/apis" +) + +func TestICSExporter_Write(t *testing.T) { + type args struct { + plan []apis.Shift + } + tests := []struct { + name string + args args + wantWriter string + wantErr bool + }{ + { + name: "When plan is nil, throw an error", + args: args{ + plan: nil, + }, + wantWriter: "", + wantErr: true, + }, + { + name: "Print ICS result according shift plan", + args: args{ + plan: []apis.Shift{ + { + Start: date("2024-12-25"), + End: date("2024-12-26"), + Primary: "a", + Secondary: "b", + }, + }, + }, + wantWriter: `BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//ocsctl//on-call-shift//EN +CALSCALE:GREGORIAN + +BEGIN:VEVENT +UID:primary-20241225 +DTSTART:20241225T000000Z +DTEND:20241226T000000Z +SUMMARY:Primary On-Call: a +DESCRIPTION:Primary: a\nSecondary: b +CATEGORIES:Primary +ATTENDEE:mailto:a +X-COLOR:#FF5733 +BEGIN:VALARM +TRIGGER:-PT8H +ACTION:DISPLAY +DESCRIPTION:Reminder: Primary On-Call: a starts in 8 hours. +END:VALARM +END:VEVENT + +BEGIN:VEVENT +UID:secondary-20241225 +DTSTART:20241225T000000Z +DTEND:20241226T000000Z +SUMMARY:Secondary On-Call: b +DESCRIPTION:Primary: a\nSecondary: b +CATEGORIES:Secondary +ATTENDEE:mailto:b +X-COLOR:#33FF57 +BEGIN:VALARM +TRIGGER:-PT8H +ACTION:DISPLAY +DESCRIPTION:Reminder: Secondary On-Call: b starts in 8 hours. +END:VALARM +END:VEVENT + +END:VCALENDAR`, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := NewICSExporter() + writer := &bytes.Buffer{} + err := e.Write(tt.args.plan, writer) + if (err != nil) != tt.wantErr { + t.Errorf("Write() error = %v, wantErr %v", err, tt.wantErr) + return + } + if gotWriter := writer.String(); gotWriter != tt.wantWriter { + t.Errorf("Write() gotWriter = %v, want %v", gotWriter, tt.wantWriter) + } + }) + } +}