diff --git a/calendar.go b/calendar.go index 57bd14f..829c574 100644 --- a/calendar.go +++ b/calendar.go @@ -9,6 +9,7 @@ import ( "io" "net/http" "reflect" + "strings" "time" ) @@ -169,6 +170,10 @@ func (cp ComponentProperty) Multiple(c Component) bool { return false } +func ComponentPropertyExtended(s string) ComponentProperty { + return ComponentProperty("X-" + strings.TrimPrefix("X-", s)) +} + type Property string const ( @@ -232,6 +237,14 @@ const ( type Parameter string +func (p Parameter) IsQuoted() bool { + switch p { + case ParameterAltrep: + return true + } + return false +} + const ( ParameterAltrep Parameter = "ALTREP" ParameterCn Parameter = "CN" @@ -404,25 +417,72 @@ func NewCalendarFor(service string) *Calendar { return c } -func (cal *Calendar) Serialize() string { +func (cal *Calendar) Serialize(ops ...any) string { b := bytes.NewBufferString("") // We are intentionally ignoring the return value. _ used to communicate this to lint. - _ = cal.SerializeTo(b) + _ = cal.SerializeTo(b, ops...) return b.String() } -func (cal *Calendar) SerializeTo(w io.Writer) error { - _, _ = fmt.Fprint(w, "BEGIN:VCALENDAR", "\r\n") +type WithLineLength int +type WithNewLine string + +func (cal *Calendar) SerializeTo(w io.Writer, ops ...any) error { + serializeConfig, err := parseSerializeOps(ops) + if err != nil { + return err + } + _, _ = fmt.Fprint(w, "BEGIN:VCALENDAR", serializeConfig.NewLine) for _, p := range cal.CalendarProperties { - p.serialize(w) + err := p.serialize(w, serializeConfig) + if err != nil { + return err + } } for _, c := range cal.Components { - c.SerializeTo(w) + err := c.SerializeTo(w, serializeConfig) + if err != nil { + return err + } } - _, _ = fmt.Fprint(w, "END:VCALENDAR", "\r\n") + _, _ = fmt.Fprint(w, "END:VCALENDAR", serializeConfig.NewLine) return nil } +type SerializationConfiguration struct { + MaxLength int + NewLine string + PropertyMaxLength int +} + +func parseSerializeOps(ops []any) (*SerializationConfiguration, error) { + serializeConfig := defaultSerializationOptions() + for opi, op := range ops { + switch op := op.(type) { + case WithLineLength: + serializeConfig.MaxLength = int(op) + case WithNewLine: + serializeConfig.NewLine = string(op) + case *SerializationConfiguration: + return op, nil + case error: + return nil, op + default: + return nil, fmt.Errorf("unknown op %d of type %s", opi, reflect.TypeOf(op)) + } + } + return serializeConfig, nil +} + +func defaultSerializationOptions() *SerializationConfiguration { + serializeConfig := &SerializationConfiguration{ + MaxLength: 75, + PropertyMaxLength: 75, + NewLine: string(NewLine), + } + return serializeConfig +} + func (cal *Calendar) SetMethod(method Method, params ...PropertyParameter) { cal.setProperty(PropertyMethod, string(method), params...) } diff --git a/calendar_serialization_test.go b/calendar_serialization_test.go index eec5871..a51db82 100644 --- a/calendar_serialization_test.go +++ b/calendar_serialization_test.go @@ -31,15 +31,16 @@ func TestCalendar_ReSerialization(t *testing.T) { } for _, filename := range testFileNames { - t.Run(fmt.Sprintf("compare serialized -> deserialized -> serialized: %s", filename), func(t *testing.T) { + fp := filepath.Join(testDir, filename) + t.Run(fmt.Sprintf("compare serialized -> deserialized -> serialized: %s", fp), func(t *testing.T) { //given - originalSeriailizedCal, err := os.ReadFile(filepath.Join(testDir, filename)) + originalSeriailizedCal, err := os.ReadFile(fp) require.NoError(t, err) //when deserializedCal, err := ParseCalendar(bytes.NewReader(originalSeriailizedCal)) require.NoError(t, err) - serializedCal := deserializedCal.Serialize() + serializedCal := deserializedCal.Serialize(WithNewLineWindows) //then expectedCal, err := os.ReadFile(filepath.Join(expectedDir, filename)) diff --git a/calendar_test.go b/calendar_test.go index 6ddd606..df36b80 100644 --- a/calendar_test.go +++ b/calendar_test.go @@ -2,10 +2,12 @@ package ics import ( "bytes" + "embed" _ "embed" + "github.com/google/go-cmp/cmp" "io" + "io/fs" "net/http" - "os" "path/filepath" "regexp" "strings" @@ -16,8 +18,13 @@ import ( "github.com/stretchr/testify/assert" ) +var ( + //go:embed testdata/* + TestData embed.FS +) + func TestTimeParsing(t *testing.T) { - calFile, err := os.OpenFile("./testdata/timeparsing.ics", os.O_RDONLY, 0400) + calFile, err := TestData.Open("testdata/timeparsing.ics") if err != nil { t.Errorf("read file: %v", err) } @@ -165,12 +172,15 @@ CLASS:PUBLIC func TestRfc5545Sec4Examples(t *testing.T) { rnReplace := regexp.MustCompile("\r?\n") - err := filepath.Walk("./testdata/rfc5545sec4/", func(path string, info os.FileInfo, _ error) error { + err := fs.WalkDir(TestData, "testdata/rfc5545sec4", func(path string, info fs.DirEntry, err error) error { + if err != nil { + return err + } if info.IsDir() { return nil } - inputBytes, err := os.ReadFile(path) + inputBytes, err := fs.ReadFile(TestData, path) if err != nil { return err } @@ -389,13 +399,13 @@ END:VCALENDAR } func TestIssue52(t *testing.T) { - err := filepath.Walk("./testdata/issue52/", func(path string, info os.FileInfo, _ error) error { + err := fs.WalkDir(TestData, "testdata/issue52", func(path string, info fs.DirEntry, _ error) error { if info.IsDir() { return nil } _, fn := filepath.Split(path) t.Run(fn, func(t *testing.T) { - f, err := os.Open(path) + f, err := TestData.Open(path) if err != nil { t.Fatalf("Error reading file: %s", err) } @@ -414,6 +424,46 @@ func TestIssue52(t *testing.T) { } } +func TestIssue97(t *testing.T) { + err := fs.WalkDir(TestData, "testdata/issue97", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + if !strings.HasSuffix(d.Name(), ".ics") && !strings.HasSuffix(d.Name(), ".ics_disabled") { + return nil + } + t.Run(path, func(t *testing.T) { + if strings.HasSuffix(d.Name(), ".ics_disabled") { + t.Skipf("Test disabled") + } + b, err := TestData.ReadFile(path) + if err != nil { + t.Fatalf("Error reading file: %s", err) + } + ics, err := ParseCalendar(bytes.NewReader(b)) + if err != nil { + t.Fatalf("Error parsing file: %s", err) + } + + got := ics.Serialize(WithLineLength(74)) + if diff := cmp.Diff(string(b), got, cmp.Transformer("ToUnixText", func(a string) string { + return strings.ReplaceAll(a, "\r\n", "\n") + })); diff != "" { + t.Errorf("ParseCalendar() mismatch (-want +got):\n%s", diff) + t.Errorf("Complete got:\b%s", got) + } + }) + return nil + }) + + if err != nil { + t.Fatalf("cannot read test directory: %v", err) + } +} + type MockHttpClient struct { Response *http.Response Error error diff --git a/cmd/issues/test97_1/main.go b/cmd/issues/test97_1/main.go new file mode 100644 index 0000000..3eceab4 --- /dev/null +++ b/cmd/issues/test97_1/main.go @@ -0,0 +1,34 @@ +package main + +import ( + "fmt" + ics "github.com/arran4/golang-ical" + "net/url" +) + +func main() { + i := ics.NewCalendarFor("Mozilla.org/NONSGML Mozilla Calendar V1.1") + tz := i.AddTimezone("Europe/Berlin") + tz.AddProperty(ics.ComponentPropertyExtended("TZINFO"), "Europe/Berlin[2024a]") + tzstd := tz.AddStandard() + tzstd.AddProperty(ics.ComponentProperty(ics.PropertyTzoffsetto), "+010000") + tzstd.AddProperty(ics.ComponentProperty(ics.PropertyTzoffsetfrom), "+005328") + tzstd.AddProperty(ics.ComponentProperty(ics.PropertyTzname), "Europe/Berlin(STD)") + tzstd.AddProperty(ics.ComponentProperty(ics.PropertyDtstart), "18930401T000000") + tzstd.AddProperty(ics.ComponentProperty(ics.PropertyRdate), "18930401T000000") + vEvent := i.AddEvent("d23cef0d-9e58-43c4-9391-5ad8483ca346") + vEvent.AddProperty(ics.ComponentPropertyCreated, "20240929T120640Z") + vEvent.AddProperty(ics.ComponentPropertyLastModified, "20240929T120731Z") + vEvent.AddProperty(ics.ComponentPropertyDtstamp, "20240929T120731Z") + vEvent.AddProperty(ics.ComponentPropertySummary, "Test Event") + vEvent.AddProperty(ics.ComponentPropertyDtStart, "20240929T144500", ics.WithTZID("Europe/Berlin")) + vEvent.AddProperty(ics.ComponentPropertyDtEnd, "20240929T154500", ics.WithTZID("Europe/Berlin")) + vEvent.AddProperty(ics.ComponentPropertyTransp, "OPAQUE") + vEvent.AddProperty(ics.ComponentPropertyLocation, "Github") + uri := &url.URL{ + Scheme: "data", + Opaque: "text/html,I%20want%20a%20custom%20linkout%20for%20Thunderbird.%3Cbr%3EThis%20is%20the%20Github%20%3Ca%20href%3D%22https%3A%2F%2Fgithub.com%2Farran4%2Fgolang-ical%2Fissues%2F97%22%3EIssue%3C%2Fa%3E.", + } + vEvent.AddProperty(ics.ComponentPropertyDescription, "I want a custom linkout for Thunderbird.\nThis is the Github Issue.", ics.WithAlternativeRepresentation(uri)) + fmt.Println(i.Serialize()) +} diff --git a/components.go b/components.go index 4029faa..e2e1429 100644 --- a/components.go +++ b/components.go @@ -20,7 +20,7 @@ import ( type Component interface { UnknownPropertiesIANAProperties() []IANAProperty SubComponents() []Component - SerializeTo(b io.Writer) + SerializeTo(b io.Writer, serialConfig *SerializationConfiguration) error } var ( @@ -43,15 +43,22 @@ func (cb *ComponentBase) SubComponents() []Component { return cb.Components } -func (cb ComponentBase) serializeThis(writer io.Writer, componentType string) { - _, _ = fmt.Fprint(writer, "BEGIN:"+componentType, "\r\n") +func (cb *ComponentBase) serializeThis(writer io.Writer, componentType ComponentType, serialConfig *SerializationConfiguration) error { + _, _ = fmt.Fprint(writer, "BEGIN:"+componentType, serialConfig.NewLine) for _, p := range cb.Properties { - p.serialize(writer) + err := p.serialize(writer, serialConfig) + if err != nil { + return err + } } for _, c := range cb.Components { - c.SerializeTo(writer) + err := c.SerializeTo(writer, serialConfig) + if err != nil { + return err + } } - _, _ = fmt.Fprint(writer, "END:"+componentType, "\r\n") + _, err := fmt.Fprint(writer, "END:"+componentType, serialConfig.NewLine) + return err } func NewComponent(uniqueId string) ComponentBase { @@ -524,14 +531,19 @@ type VEvent struct { ComponentBase } -func (event *VEvent) SerializeTo(w io.Writer) { - event.ComponentBase.serializeThis(w, "VEVENT") +func (event *VEvent) SerializeTo(w io.Writer, serialConfig *SerializationConfiguration) error { + return event.ComponentBase.serializeThis(w, ComponentVEvent, serialConfig) } -func (event *VEvent) Serialize() string { +func (event *VEvent) Serialize(serialConfig *SerializationConfiguration) string { + s, _ := event.serialize(serialConfig) + return s +} + +func (event *VEvent) serialize(serialConfig *SerializationConfiguration) (string, error) { b := &bytes.Buffer{} - event.ComponentBase.serializeThis(b, "VEVENT") - return b.String() + err := event.ComponentBase.serializeThis(b, ComponentVEvent, serialConfig) + return b.String(), err } func NewEvent(uniqueId string) *VEvent { @@ -549,6 +561,7 @@ func (event *VEvent) SetLastModifiedAt(t time.Time, props ...PropertyParameter) event.SetProperty(ComponentPropertyLastModified, t.UTC().Format(icalTimestampFormatUtc), props...) } +// TODO use generics func (event *VEvent) SetGeo(lat interface{}, lng interface{}, params ...PropertyParameter) { event.setGeo(lat, lng, params...) } @@ -592,14 +605,22 @@ type VTodo struct { ComponentBase } -func (c *VTodo) SerializeTo(w io.Writer) { - c.ComponentBase.serializeThis(w, "VTODO") +func (todo *VTodo) SerializeTo(w io.Writer, serialConfig *SerializationConfiguration) error { + return todo.ComponentBase.serializeThis(w, ComponentVTodo, serialConfig) +} + +func (todo *VTodo) Serialize(serialConfig *SerializationConfiguration) string { + s, _ := todo.serialize(serialConfig) + return s } -func (c *VTodo) Serialize() string { +func (todo *VTodo) serialize(serialConfig *SerializationConfiguration) (string, error) { b := &bytes.Buffer{} - c.ComponentBase.serializeThis(b, "VTODO") - return b.String() + err := todo.ComponentBase.serializeThis(b, ComponentVTodo, serialConfig) + if err != nil { + return "", err + } + return b.String(), nil } func NewTodo(uniqueId string) *VTodo { @@ -630,38 +651,38 @@ func (cal *Calendar) Todos() []*VTodo { return r } -func (c *VTodo) SetCompletedAt(t time.Time, params ...PropertyParameter) { - c.SetProperty(ComponentPropertyCompleted, t.UTC().Format(icalTimestampFormatUtc), params...) +func (todo *VTodo) SetCompletedAt(t time.Time, params ...PropertyParameter) { + todo.SetProperty(ComponentPropertyCompleted, t.UTC().Format(icalTimestampFormatUtc), params...) } -func (c *VTodo) SetAllDayCompletedAt(t time.Time, params ...PropertyParameter) { +func (todo *VTodo) SetAllDayCompletedAt(t time.Time, params ...PropertyParameter) { params = append(params, WithValue(string(ValueDataTypeDate))) - c.SetProperty(ComponentPropertyCompleted, t.Format(icalDateFormatLocal), params...) + todo.SetProperty(ComponentPropertyCompleted, t.Format(icalDateFormatLocal), params...) } -func (c *VTodo) SetDueAt(t time.Time, params ...PropertyParameter) { - c.SetProperty(ComponentPropertyDue, t.UTC().Format(icalTimestampFormatUtc), params...) +func (todo *VTodo) SetDueAt(t time.Time, params ...PropertyParameter) { + todo.SetProperty(ComponentPropertyDue, t.UTC().Format(icalTimestampFormatUtc), params...) } -func (c *VTodo) SetAllDayDueAt(t time.Time, params ...PropertyParameter) { +func (todo *VTodo) SetAllDayDueAt(t time.Time, params ...PropertyParameter) { params = append(params, WithValue(string(ValueDataTypeDate))) - c.SetProperty(ComponentPropertyDue, t.Format(icalDateFormatLocal), params...) + todo.SetProperty(ComponentPropertyDue, t.Format(icalDateFormatLocal), params...) } -func (c *VTodo) SetPercentComplete(p int, params ...PropertyParameter) { - c.SetProperty(ComponentPropertyPercentComplete, strconv.Itoa(p), params...) +func (todo *VTodo) SetPercentComplete(p int, params ...PropertyParameter) { + todo.SetProperty(ComponentPropertyPercentComplete, strconv.Itoa(p), params...) } -func (c *VTodo) SetGeo(lat interface{}, lng interface{}, params ...PropertyParameter) { - c.setGeo(lat, lng, params...) +func (todo *VTodo) SetGeo(lat interface{}, lng interface{}, params ...PropertyParameter) { + todo.setGeo(lat, lng, params...) } -func (c *VTodo) SetPriority(p int, params ...PropertyParameter) { - c.setPriority(p, params...) +func (todo *VTodo) SetPriority(p int, params ...PropertyParameter) { + todo.setPriority(p, params...) } -func (c *VTodo) SetResources(r string, params ...PropertyParameter) { - c.setResources(r, params...) +func (todo *VTodo) SetResources(r string, params ...PropertyParameter) { + todo.setResources(r, params...) } // SetDuration updates the duration of an event. @@ -669,53 +690,61 @@ func (c *VTodo) SetResources(r string, params ...PropertyParameter) { // The duration defines the length of a event relative to start or end time. // // Notice: It will not set the DURATION key of the ics - only DTSTART and DTEND will be affected. -func (c *VTodo) SetDuration(d time.Duration) error { - t, err := c.GetStartAt() +func (todo *VTodo) SetDuration(d time.Duration) error { + t, err := todo.GetStartAt() if err == nil { - c.SetDueAt(t.Add(d)) + todo.SetDueAt(t.Add(d)) return nil } else { - t, err = c.GetDueAt() + t, err = todo.GetDueAt() if err == nil { - c.SetStartAt(t.Add(-d)) + todo.SetStartAt(t.Add(-d)) return nil } } return errors.New("start or end not yet defined") } -func (c *VTodo) AddAlarm() *VAlarm { - return c.addAlarm() +func (todo *VTodo) AddAlarm() *VAlarm { + return todo.addAlarm() } -func (c *VTodo) AddVAlarm(a *VAlarm) { - c.addVAlarm(a) +func (todo *VTodo) AddVAlarm(a *VAlarm) { + todo.addVAlarm(a) } -func (c *VTodo) Alarms() []*VAlarm { - return c.alarms() +func (todo *VTodo) Alarms() []*VAlarm { + return todo.alarms() } -func (c *VTodo) GetDueAt() (time.Time, error) { - return c.getTimeProp(ComponentPropertyDue, false) +func (todo *VTodo) GetDueAt() (time.Time, error) { + return todo.getTimeProp(ComponentPropertyDue, false) } -func (c *VTodo) GetAllDayDueAt() (time.Time, error) { - return c.getTimeProp(ComponentPropertyDue, true) +func (todo *VTodo) GetAllDayDueAt() (time.Time, error) { + return todo.getTimeProp(ComponentPropertyDue, true) } type VJournal struct { ComponentBase } -func (c *VJournal) SerializeTo(w io.Writer) { - c.ComponentBase.serializeThis(w, "VJOURNAL") +func (journal *VJournal) SerializeTo(w io.Writer, serialConfig *SerializationConfiguration) error { + return journal.ComponentBase.serializeThis(w, ComponentVJournal, serialConfig) +} + +func (journal *VJournal) Serialize(serialConfig *SerializationConfiguration) string { + s, _ := journal.serialize(serialConfig) + return s } -func (c *VJournal) Serialize() string { +func (journal *VJournal) serialize(serialConfig *SerializationConfiguration) (string, error) { b := &bytes.Buffer{} - c.ComponentBase.serializeThis(b, "VJOURNAL") - return b.String() + err := journal.ComponentBase.serializeThis(b, ComponentVJournal, serialConfig) + if err != nil { + return "", err + } + return b.String(), nil } func NewJournal(uniqueId string) *VJournal { @@ -750,14 +779,22 @@ type VBusy struct { ComponentBase } -func (c *VBusy) Serialize() string { +func (busy *VBusy) Serialize(serialConfig *SerializationConfiguration) string { + s, _ := busy.serialize(serialConfig) + return s +} + +func (busy *VBusy) serialize(serialConfig *SerializationConfiguration) (string, error) { b := &bytes.Buffer{} - c.ComponentBase.serializeThis(b, "VFREEBUSY") - return b.String() + err := busy.ComponentBase.serializeThis(b, ComponentVFreeBusy, serialConfig) + if err != nil { + return "", err + } + return b.String(), nil } -func (c *VBusy) SerializeTo(w io.Writer) { - c.ComponentBase.serializeThis(w, "VFREEBUSY") +func (busy *VBusy) SerializeTo(w io.Writer, serialConfig *SerializationConfiguration) error { + return busy.ComponentBase.serializeThis(w, ComponentVFreeBusy, serialConfig) } func NewBusy(uniqueId string) *VBusy { @@ -792,14 +829,28 @@ type VTimezone struct { ComponentBase } -func (c *VTimezone) Serialize() string { +func (timezone *VTimezone) Serialize(serialConfig *SerializationConfiguration) string { + s, _ := timezone.serialize(serialConfig) + return s +} + +func (timezone *VTimezone) serialize(serialConfig *SerializationConfiguration) (string, error) { b := &bytes.Buffer{} - c.ComponentBase.serializeThis(b, "VTIMEZONE") - return b.String() + err := timezone.ComponentBase.serializeThis(b, ComponentVTimezone, serialConfig) + if err != nil { + return "", err + } + return b.String(), nil } -func (c *VTimezone) SerializeTo(w io.Writer) { - c.ComponentBase.serializeThis(w, "VTIMEZONE") +func (timezone *VTimezone) SerializeTo(w io.Writer, serialConfig *SerializationConfiguration) error { + return timezone.ComponentBase.serializeThis(w, ComponentVTimezone, serialConfig) +} + +func (timezone *VTimezone) AddStandard() *Standard { + e := NewStandard() + timezone.Components = append(timezone.Components, e) + return e } func NewTimezone(tzId string) *VTimezone { @@ -838,17 +889,26 @@ type VAlarm struct { ComponentBase } -func (c *VAlarm) Serialize() string { +func (c *VAlarm) Serialize(serialConfig *SerializationConfiguration) string { + s, _ := c.serialize(serialConfig) + return s +} + +func (c *VAlarm) serialize(serialConfig *SerializationConfiguration) (string, error) { b := &bytes.Buffer{} - c.ComponentBase.serializeThis(b, "VALARM") - return b.String() + err := c.ComponentBase.serializeThis(b, ComponentVAlarm, serialConfig) + if err != nil { + return "", err + } + return b.String(), nil } -func (c *VAlarm) SerializeTo(w io.Writer) { - c.ComponentBase.serializeThis(w, "VALARM") +func (c *VAlarm) SerializeTo(w io.Writer, serialConfig *SerializationConfiguration) error { + return c.ComponentBase.serializeThis(w, ComponentVAlarm, serialConfig) } func NewAlarm(tzId string) *VAlarm { + // Todo How did this come about? e := &VAlarm{} return e } @@ -880,28 +940,51 @@ type Standard struct { ComponentBase } -func (c *Standard) Serialize() string { +func NewStandard() *Standard { + e := &Standard{ + ComponentBase{}, + } + return e +} + +func (standard *Standard) Serialize(serialConfig *SerializationConfiguration) string { + s, _ := standard.serialize(serialConfig) + return s +} + +func (standard *Standard) serialize(serialConfig *SerializationConfiguration) (string, error) { b := &bytes.Buffer{} - c.ComponentBase.serializeThis(b, "STANDARD") - return b.String() + err := standard.ComponentBase.serializeThis(b, ComponentStandard, serialConfig) + if err != nil { + return "", err + } + return b.String(), nil } -func (c *Standard) SerializeTo(w io.Writer) { - c.ComponentBase.serializeThis(w, "STANDARD") +func (standard *Standard) SerializeTo(w io.Writer, serialConfig *SerializationConfiguration) error { + return standard.ComponentBase.serializeThis(w, ComponentStandard, serialConfig) } type Daylight struct { ComponentBase } -func (c *Daylight) Serialize() string { +func (daylight *Daylight) Serialize(serialConfig *SerializationConfiguration) string { + s, _ := daylight.serialize(serialConfig) + return s +} + +func (daylight *Daylight) serialize(serialConfig *SerializationConfiguration) (string, error) { b := &bytes.Buffer{} - c.ComponentBase.serializeThis(b, "DAYLIGHT") - return b.String() + err := daylight.ComponentBase.serializeThis(b, ComponentDaylight, serialConfig) + if err != nil { + return "", err + } + return b.String(), nil } -func (c *Daylight) SerializeTo(w io.Writer) { - c.ComponentBase.serializeThis(w, "DAYLIGHT") +func (daylight *Daylight) SerializeTo(w io.Writer, serialConfig *SerializationConfiguration) error { + return daylight.ComponentBase.serializeThis(w, ComponentDaylight, serialConfig) } type GeneralComponent struct { @@ -909,141 +992,195 @@ type GeneralComponent struct { Token string } -func (c *GeneralComponent) Serialize() string { +func (general *GeneralComponent) Serialize(serialConfig *SerializationConfiguration) string { + s, _ := general.serialize(serialConfig) + return s +} + +func (general *GeneralComponent) serialize(serialConfig *SerializationConfiguration) (string, error) { b := &bytes.Buffer{} - c.ComponentBase.serializeThis(b, c.Token) - return b.String() + err := general.ComponentBase.serializeThis(b, ComponentType(general.Token), serialConfig) + if err != nil { + return "", err + } + return b.String(), nil } -func (c *GeneralComponent) SerializeTo(w io.Writer) { - c.ComponentBase.serializeThis(w, c.Token) +func (general *GeneralComponent) SerializeTo(w io.Writer, serialConfig *SerializationConfiguration) error { + return general.ComponentBase.serializeThis(w, ComponentType(general.Token), serialConfig) } func GeneralParseComponent(cs *CalendarStream, startLine *BaseProperty) (Component, error) { var co Component - switch startLine.Value { - case "VCALENDAR": + var err error + switch ComponentType(startLine.Value) { + case ComponentVCalendar: return nil, errors.New("malformed calendar; vcalendar not where expected") - case "VEVENT": - co = ParseVEvent(cs, startLine) - case "VTODO": - co = ParseVTodo(cs, startLine) - case "VJOURNAL": - co = ParseVJournal(cs, startLine) - case "VFREEBUSY": - co = ParseVBusy(cs, startLine) - case "VTIMEZONE": - co = ParseVTimezone(cs, startLine) - case "VALARM": - co = ParseVAlarm(cs, startLine) - case "STANDARD": - co = ParseStandard(cs, startLine) - case "DAYLIGHT": - co = ParseDaylight(cs, startLine) + case ComponentVEvent: + co, err = ParseVEventWithError(cs, startLine) + case ComponentVTodo: + co, err = ParseVTodoWithError(cs, startLine) + case ComponentVJournal: + co, err = ParseVJournalWithError(cs, startLine) + case ComponentVFreeBusy: + co, err = ParseVBusyWithError(cs, startLine) + case ComponentVTimezone: + co, err = ParseVTimezoneWithError(cs, startLine) + case ComponentVAlarm: + co, err = ParseVAlarmWithError(cs, startLine) + case ComponentStandard: + co, err = ParseStandardWithError(cs, startLine) + case ComponentDaylight: + co, err = ParseDaylightWithError(cs, startLine) default: - co = ParseGeneralComponent(cs, startLine) + co, err = ParseGeneralComponentWithError(cs, startLine) } - return co, nil + return co, err } func ParseVEvent(cs *CalendarStream, startLine *BaseProperty) *VEvent { + ev, _ := ParseVEventWithError(cs, startLine) + return ev +} + +func ParseVEventWithError(cs *CalendarStream, startLine *BaseProperty) (*VEvent, error) { r, err := ParseComponent(cs, startLine) if err != nil { - return nil + return nil, fmt.Errorf("failed to parse event: %w", err) } rr := &VEvent{ ComponentBase: r, } - return rr + return rr, nil } func ParseVTodo(cs *CalendarStream, startLine *BaseProperty) *VTodo { + c, _ := ParseVTodoWithError(cs, startLine) + return c +} + +func ParseVTodoWithError(cs *CalendarStream, startLine *BaseProperty) (*VTodo, error) { r, err := ParseComponent(cs, startLine) if err != nil { - return nil + return nil, err } rr := &VTodo{ ComponentBase: r, } - return rr + return rr, nil } func ParseVJournal(cs *CalendarStream, startLine *BaseProperty) *VJournal { + c, _ := ParseVJournalWithError(cs, startLine) + return c +} + +func ParseVJournalWithError(cs *CalendarStream, startLine *BaseProperty) (*VJournal, error) { r, err := ParseComponent(cs, startLine) if err != nil { - return nil + return nil, err } rr := &VJournal{ ComponentBase: r, } - return rr + return rr, nil } func ParseVBusy(cs *CalendarStream, startLine *BaseProperty) *VBusy { + c, _ := ParseVBusyWithError(cs, startLine) + return c +} + +func ParseVBusyWithError(cs *CalendarStream, startLine *BaseProperty) (*VBusy, error) { r, err := ParseComponent(cs, startLine) if err != nil { - return nil + return nil, err } rr := &VBusy{ ComponentBase: r, } - return rr + return rr, nil } func ParseVTimezone(cs *CalendarStream, startLine *BaseProperty) *VTimezone { + c, _ := ParseVTimezoneWithError(cs, startLine) + return c +} + +func ParseVTimezoneWithError(cs *CalendarStream, startLine *BaseProperty) (*VTimezone, error) { r, err := ParseComponent(cs, startLine) if err != nil { - return nil + return nil, err } rr := &VTimezone{ ComponentBase: r, } - return rr + return rr, nil } func ParseVAlarm(cs *CalendarStream, startLine *BaseProperty) *VAlarm { + c, _ := ParseVAlarmWithError(cs, startLine) + return c +} + +func ParseVAlarmWithError(cs *CalendarStream, startLine *BaseProperty) (*VAlarm, error) { r, err := ParseComponent(cs, startLine) if err != nil { - return nil + return nil, err } rr := &VAlarm{ ComponentBase: r, } - return rr + return rr, nil } func ParseStandard(cs *CalendarStream, startLine *BaseProperty) *Standard { + c, _ := ParseStandardWithError(cs, startLine) + return c +} + +func ParseStandardWithError(cs *CalendarStream, startLine *BaseProperty) (*Standard, error) { r, err := ParseComponent(cs, startLine) if err != nil { - return nil + return nil, err } rr := &Standard{ ComponentBase: r, } - return rr + return rr, nil } func ParseDaylight(cs *CalendarStream, startLine *BaseProperty) *Daylight { + c, _ := ParseDaylightWithError(cs, startLine) + return c +} + +func ParseDaylightWithError(cs *CalendarStream, startLine *BaseProperty) (*Daylight, error) { r, err := ParseComponent(cs, startLine) if err != nil { - return nil + return nil, err } rr := &Daylight{ ComponentBase: r, } - return rr + return rr, nil } func ParseGeneralComponent(cs *CalendarStream, startLine *BaseProperty) *GeneralComponent { + c, _ := ParseGeneralComponentWithError(cs, startLine) + return c +} + +func ParseGeneralComponentWithError(cs *CalendarStream, startLine *BaseProperty) (*GeneralComponent, error) { r, err := ParseComponent(cs, startLine) if err != nil { - return nil + return nil, err } rr := &GeneralComponent{ ComponentBase: r, Token: startLine.Value, } - return rr + return rr, nil } func ParseComponent(cs *CalendarStream, startLine *BaseProperty) (ComponentBase, error) { diff --git a/components_test.go b/components_test.go index daa7831..faedc40 100644 --- a/components_test.go +++ b/components_test.go @@ -52,7 +52,7 @@ END:VEVENT err := e.SetDuration(duration) // we're not testing for encoding here so lets make the actual output line breaks == expected line breaks - text := strings.ReplaceAll(e.Serialize(), "\r\n", "\n") + text := strings.ReplaceAll(e.Serialize(defaultSerializationOptions()), "\r\n", "\n") assert.Equal(t, tc.output, text) assert.Equal(t, nil, err) @@ -115,7 +115,7 @@ END:VEVENT assert.NoError(t, err) } - text := strings.ReplaceAll(e.Serialize(), "\r\n", "\n") + text := strings.ReplaceAll(e.Serialize(defaultSerializationOptions()), "\r\n", "\n") assert.Equal(t, tc.output, text) }) @@ -140,22 +140,22 @@ func TestSetMailtoPrefix(t *testing.T) { e := NewEvent("test-set-organizer") e.SetOrganizer("org1@provider.com") - if !strings.Contains(e.Serialize(), "ORGANIZER:mailto:org1@provider.com") { + if !strings.Contains(e.Serialize(defaultSerializationOptions()), "ORGANIZER:mailto:org1@provider.com") { t.Errorf("expected single mailto: prefix for email org1") } e.SetOrganizer("mailto:org2@provider.com") - if !strings.Contains(e.Serialize(), "ORGANIZER:mailto:org2@provider.com") { + if !strings.Contains(e.Serialize(defaultSerializationOptions()), "ORGANIZER:mailto:org2@provider.com") { t.Errorf("expected single mailto: prefix for email org2") } e.AddAttendee("att1@provider.com") - if !strings.Contains(e.Serialize(), "ATTENDEE:mailto:att1@provider.com") { + if !strings.Contains(e.Serialize(defaultSerializationOptions()), "ATTENDEE:mailto:att1@provider.com") { t.Errorf("expected single mailto: prefix for email att1") } e.AddAttendee("mailto:att2@provider.com") - if !strings.Contains(e.Serialize(), "ATTENDEE:mailto:att2@provider.com") { + if !strings.Contains(e.Serialize(defaultSerializationOptions()), "ATTENDEE:mailto:att2@provider.com") { t.Errorf("expected single mailto: prefix for email att2") } } @@ -184,7 +184,7 @@ END:VTODO e.RemoveProperty("X-TESTREMOVE") // adjust to expected linebreaks, since we're not testing the encoding - text := strings.ReplaceAll(e.Serialize(), "\r\n", "\n") + text := strings.ReplaceAll(e.Serialize(defaultSerializationOptions()), "\r\n", "\n") assert.Equal(t, tc.output, text) }) diff --git a/os.go b/os.go new file mode 100644 index 0000000..1cebf9a --- /dev/null +++ b/os.go @@ -0,0 +1,6 @@ +package ics + +const ( + WithNewLineUnix WithNewLine = "\n" + WithNewLineWindows WithNewLine = "\r\n" +) diff --git a/os_unix.go b/os_unix.go new file mode 100644 index 0000000..fe1bc84 --- /dev/null +++ b/os_unix.go @@ -0,0 +1,5 @@ +package ics + +const ( + NewLine = WithNewLineUnix +) diff --git a/os_windows.go b/os_windows.go new file mode 100644 index 0000000..81ebfce --- /dev/null +++ b/os_windows.go @@ -0,0 +1,5 @@ +package ics + +const ( + NewLineString = WithNewLineWindows +) diff --git a/property.go b/property.go index 3e7291d..23ff1b2 100644 --- a/property.go +++ b/property.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "log" + "net/url" "regexp" "sort" "strconv" @@ -39,6 +40,21 @@ func WithCN(cn string) PropertyParameter { } } +func WithTZID(tzid string) PropertyParameter { + return &KeyValues{ + Key: string(ParameterTzid), + Value: []string{tzid}, + } +} + +// WithAlternativeRepresentation takes what must be a valid URI in quotation marks +func WithAlternativeRepresentation(uri *url.URL) PropertyParameter { + return &KeyValues{ + Key: string(ParameterAltrep), + Value: []string{uri.String()}, + } +} + func WithEncoding(encType string) PropertyParameter { return &KeyValues{ Key: string(ParameterEncoding), @@ -69,20 +85,23 @@ func WithRSVP(b bool) PropertyParameter { func trimUT8StringUpTo(maxLength int, s string) string { length := 0 - lastSpace := -1 + lastWordBoundary := -1 + var lastRune rune for i, r := range s { - if r == ' ' { - lastSpace = i + if r == ' ' || r == '<' { + lastWordBoundary = i + } else if lastRune == '>' { + lastWordBoundary = i } - + lastRune = r newLength := length + utf8.RuneLen(r) if newLength > maxLength { break } length = newLength } - if lastSpace > 0 { - return s[:lastSpace] + if lastWordBoundary > 0 { + return s[:lastWordBoundary] } return s[:length] @@ -146,9 +165,9 @@ func (bp *BaseProperty) GetValueType() ValueDataType { } } -func (bp *BaseProperty) serialize(w io.Writer) { +func (bp *BaseProperty) serialize(w io.Writer, serialConfig *SerializationConfiguration) error { b := bytes.NewBufferString("") - fmt.Fprint(b, bp.IANAToken) + _, _ = fmt.Fprint(b, bp.IANAToken) var keys []string for k := range bp.ICalParameters { @@ -157,43 +176,87 @@ func (bp *BaseProperty) serialize(w io.Writer) { sort.Strings(keys) for _, k := range keys { vs := bp.ICalParameters[k] - fmt.Fprint(b, ";") - fmt.Fprint(b, k) - fmt.Fprint(b, "=") + _, _ = fmt.Fprint(b, ";") + _, _ = fmt.Fprint(b, k) + _, _ = fmt.Fprint(b, "=") for vi, v := range vs { if vi > 0 { - fmt.Fprint(b, ",") + _, _ = fmt.Fprint(b, ",") } - if strings.ContainsAny(v, ";:\\\",") { - v = strings.ReplaceAll(v, "\\", "\\\\") - v = strings.ReplaceAll(v, ";", "\\;") - v = strings.ReplaceAll(v, ":", "\\:") - v = strings.ReplaceAll(v, "\"", "\\\"") - v = strings.ReplaceAll(v, ",", "\\,") + if Parameter(k).IsQuoted() { + v = quotedValueString(v) + _, _ = fmt.Fprint(b, v) + } else { + v = escapeValueString(v) + _, _ = fmt.Fprint(b, v) } - fmt.Fprint(b, v) } } - fmt.Fprint(b, ":") + _, _ = fmt.Fprint(b, ":") propertyValue := bp.Value if bp.GetValueType() == ValueDataTypeText { propertyValue = ToText(propertyValue) } - fmt.Fprint(b, propertyValue) + _, _ = fmt.Fprint(b, propertyValue) r := b.String() - if len(r) > 75 { - l := trimUT8StringUpTo(75, r) - fmt.Fprint(w, l, "\r\n") + if len(r) > serialConfig.MaxLength { + l := trimUT8StringUpTo(serialConfig.MaxLength, r) + _, err := fmt.Fprint(w, l, serialConfig.NewLine) + if err != nil { + return fmt.Errorf("property %s serialization: %w", bp.IANAToken, err) + } r = r[len(l):] - for len(r) > 74 { - l := trimUT8StringUpTo(74, r) - fmt.Fprint(w, " ", l, "\r\n") + for len(r) > serialConfig.MaxLength-1 { + l := trimUT8StringUpTo(serialConfig.MaxLength-1, r) + _, err = fmt.Fprint(w, " ", l, serialConfig.NewLine) + if err != nil { + return fmt.Errorf("property %s serialization: %w", bp.IANAToken, err) + } r = r[len(l):] } - fmt.Fprint(w, " ") + _, err = fmt.Fprint(w, " ") + if err != nil { + return fmt.Errorf("property %s serialization: %w", bp.IANAToken, err) + } + } + _, err := fmt.Fprint(w, r, serialConfig.NewLine) + if err != nil { + return fmt.Errorf("property %s serialization: %w", bp.IANAToken, err) + } + return nil +} + +func escapeValueString(v string) string { + changed := 0 + result := "" + for i, r := range v { + switch r { + case ',', '"', ';', ':', '\\', '\'': + result = result + v[changed:i] + "\\" + string(r) + changed = i + 1 + } + } + if changed == 0 { + return v + } + return result + v[changed:] +} + +func quotedValueString(v string) string { + changed := 0 + result := "" + for i, r := range v { + switch r { + case '"', '\\': + result = result + v[changed:i] + "\\" + string(r) + changed = i + 1 + } + } + if changed == 0 { + return `"` + v + `"` } - fmt.Fprint(w, r, "\r\n") + return `"` + result + v[changed:] + `"` } type IANAProperty struct { diff --git a/property_test.go b/property_test.go index 84e2d04..5610c34 100644 --- a/property_test.go +++ b/property_test.go @@ -185,3 +185,71 @@ func Test_parsePropertyParamValue(t *testing.T) { }) } } + +func Test_trimUT8StringUpTo(t *testing.T) { + tests := []struct { + name string + maxLength int + s string + want string + }{ + { + name: "simply break at spaces", + s: "simply break at spaces", + maxLength: 14, + want: "simply break", + }, + { + name: "(Don't) Break after punctuation 1", // See if we can change this. + s: "hi.are.", + maxLength: len("hi.are"), + want: "hi.are", + }, + { + name: "Break after punctuation 2", + s: "Hi how are you?", + maxLength: len("Hi how are you"), + want: "Hi how are", + }, + { + name: "HTML opening tag breaking", + s: "I want a custom linkout for Thunderbird.
This is the GithubIssue.", + maxLength: len("I want a custom linkout for Thunderbird.
This is the Github<"), + want: "I want a custom linkout for Thunderbird.
This is the Github", + }, + { + name: "HTML closing tag breaking", + s: "I want a custom linkout for Thunderbird.
This is the GithubIssue.", + maxLength: len("I want a custom linkout for Thunderbird.
") + 1, + want: "I want a custom linkout for Thunderbird.
", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equalf(t, tt.want, trimUT8StringUpTo(tt.maxLength, tt.s), "trimUT8StringUpTo(%v, %v)", tt.maxLength, tt.s) + }) + } +} + +func TestFixValueStrings(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"hello", "hello"}, + {"hello;world", "hello\\;world"}, + {"path\\to:file", "path\\\\to\\:file"}, + {"name:\"value\"", "name\\:\\\"value\\\""}, + {"key,value", "key\\,value"}, + {";:\\\",", "\\;\\:\\\\\\\"\\,"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := escapeValueString(tt.input) + if result != tt.expected { + t.Errorf("got %q, want %q", result, tt.expected) + } + }) + } +} diff --git a/testdata/issue97/google.ics b/testdata/issue97/google.ics new file mode 100644 index 0000000..9408b3a --- /dev/null +++ b/testdata/issue97/google.ics @@ -0,0 +1,23 @@ +BEGIN:VCALENDAR +PRODID:-//Google Inc//Google Calendar 70.9054//EN +VERSION:2.0 +CALSCALE:GREGORIAN +METHOD:PUBLISH +X-WR-CALNAME:Test +X-WR-TIMEZONE:Europe/Berlin +BEGIN:VEVENT +DTSTART:20240929T124500Z +DTEND:20240929T134500Z +DTSTAMP:20240929T121653Z +UID:al23c5kr943d42u3bqoqrkf455@google.com +CREATED:20240929T121642Z +DESCRIPTION:I want a custom linkout for Thunderbird.
This is the Github + Issue. +LAST-MODIFIED:20240929T121642Z +LOCATION:GitHub +SEQUENCE:0 +STATUS:CONFIRMED +SUMMARY:Test Event +TRANSP:OPAQUE +END:VEVENT +END:VCALENDAR diff --git a/testdata/issue97/thunderbird.ics_disabled b/testdata/issue97/thunderbird.ics_disabled new file mode 100644 index 0000000..88fd37e --- /dev/null +++ b/testdata/issue97/thunderbird.ics_disabled @@ -0,0 +1,37 @@ +BEGIN:VCALENDAR +PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN +VERSION:2.0 +BEGIN:VTIMEZONE +TZID:Europe/Berlin +X-TZINFO:Europe/Berlin[2024a] +BEGIN:STANDARD +TZOFFSETTO:+010000 +TZOFFSETFROM:+005328 +TZNAME:Europe/Berlin(STD) +DTSTART:18930401T000000 +RDATE:18930401T000000 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +CREATED:20240929T120640Z +LAST-MODIFIED:20240929T120731Z +DTSTAMP:20240929T120731Z +UID:d23cef0d-9e58-43c4-9391-5ad8483ca346 +SUMMARY:Test Event +DTSTART;TZID=Europe/Berlin:20240929T144500 +DTEND;TZID=Europe/Berlin:20240929T154500 +TRANSP:OPAQUE +LOCATION:Github +DESCRIPTION;ALTREP="data:text/html,I%20want%20 + a%20custom%20linkout%20for%20 + Thunderbird.%3Cbr%3EThis%20is%20the%20Github%20 + %3Ca%20href%3D%22https%3A%2F + %2Fgithub.com%2Farran4%2Fgolang-ical%2Fissues%2F97%22 + %3EIssue%3C%2Fa%3E.":I + want a custom linkout for Thunderbird.\nThis is the Github Issue. +END:VEVENT +END:VCALENDAR + + + +Disabled due to wordwrapping differences