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