// Package storage handles reading and writing data from various sources. package storage import ( "fmt" "os" "time" "github.com/emersion/go-ical" "github.com/luxick/chronological/internal/models" ) // ICSStore handles reading and writing ICS calendar files. type ICSStore struct { calendarPath string diaryPath string } // NewICSStore creates a new ICS store with the given file paths. func NewICSStore(calendarPath, diaryPath string) *ICSStore { return &ICSStore{ calendarPath: calendarPath, diaryPath: diaryPath, } } // LoadEvents loads all events from the calendar ICS file. func (s *ICSStore) LoadEvents() ([]*models.Event, error) { if s.calendarPath == "" { return nil, nil } file, err := os.Open(s.calendarPath) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, fmt.Errorf("failed to open calendar file: %w", err) } defer file.Close() dec := ical.NewDecoder(file) cal, err := dec.Decode() if err != nil { return nil, fmt.Errorf("failed to decode calendar: %w", err) } return parseEventsFromCalendar(cal, models.SourceLocal) } // LoadEventsForMonth loads events that occur within the specified month. func (s *ICSStore) LoadEventsForMonth(year int, month time.Month) ([]*models.Event, error) { allEvents, err := s.LoadEvents() if err != nil { return nil, err } start := time.Date(year, month, 1, 0, 0, 0, 0, time.Local) end := start.AddDate(0, 1, 0) var monthEvents []*models.Event for _, event := range allEvents { if eventInRange(event, start, end) { monthEvents = append(monthEvents, event) } } return monthEvents, nil } // LoadDiaryEntries loads all diary entries from the diary ICS file. func (s *ICSStore) LoadDiaryEntries() ([]*models.DiaryEntry, error) { if s.diaryPath == "" { return nil, nil } file, err := os.Open(s.diaryPath) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, fmt.Errorf("failed to open diary file: %w", err) } defer file.Close() dec := ical.NewDecoder(file) cal, err := dec.Decode() if err != nil { return nil, fmt.Errorf("failed to decode diary calendar: %w", err) } return parseDiaryFromCalendar(cal) } // LoadDiaryEntry loads a single diary entry for a specific date. func (s *ICSStore) LoadDiaryEntry(date time.Time) (*models.DiaryEntry, error) { entries, err := s.LoadDiaryEntries() if err != nil { return nil, err } targetDate := date.Truncate(24 * time.Hour) for _, entry := range entries { if entry.Date.Truncate(24 * time.Hour).Equal(targetDate) { return entry, nil } } return nil, nil } // SaveDiaryEntry saves or updates a diary entry for a specific date. func (s *ICSStore) SaveDiaryEntry(entry *models.DiaryEntry) error { if s.diaryPath == "" { return fmt.Errorf("diary path not configured") } var cal *ical.Calendar // Try to load existing calendar file, err := os.Open(s.diaryPath) if err == nil { dec := ical.NewDecoder(file) cal, err = dec.Decode() file.Close() if err != nil { cal = nil } } // Create new calendar if none exists if cal == nil { cal = ical.NewCalendar() cal.Props.SetText(ical.PropVersion, "2.0") cal.Props.SetText(ical.PropProductID, "-//Chronological//Diary//EN") } targetDate := entry.Date.Truncate(24 * time.Hour) found := false // Update existing entry if found for _, child := range cal.Children { if child.Name != ical.CompEvent { continue } event := ical.Event{Component: child} summary, _ := event.Props.Text(ical.PropSummary) if summary != "Chronolog" { continue } dtstart, err := event.Props.DateTime(ical.PropDateTimeStart, time.Local) if err != nil { continue } if dtstart.Truncate(24 * time.Hour).Equal(targetDate) { event.Props.SetText(ical.PropDescription, entry.Text) found = true break } } // Create new entry if not found if !found { event := ical.NewEvent() event.Props.SetText(ical.PropUID, fmt.Sprintf("chronolog-%s@chronological", entry.DateString())) event.Props.SetDateTime(ical.PropDateTimeStamp, time.Now()) event.Props.SetDate(ical.PropDateTimeStart, targetDate) event.Props.SetDate(ical.PropDateTimeEnd, targetDate.AddDate(0, 0, 1)) event.Props.SetText(ical.PropSummary, "Chronolog") event.Props.SetText(ical.PropDescription, entry.Text) cal.Children = append(cal.Children, event.Component) } // Write calendar back to file outFile, err := os.Create(s.diaryPath) if err != nil { return fmt.Errorf("failed to create diary file: %w", err) } defer outFile.Close() enc := ical.NewEncoder(outFile) if err := enc.Encode(cal); err != nil { return fmt.Errorf("failed to encode diary calendar: %w", err) } return nil } // DeleteDiaryEntry removes a diary entry for a specific date. func (s *ICSStore) DeleteDiaryEntry(date time.Time) error { if s.diaryPath == "" { return fmt.Errorf("diary path not configured") } file, err := os.Open(s.diaryPath) if err != nil { if os.IsNotExist(err) { return nil } return fmt.Errorf("failed to open diary file: %w", err) } dec := ical.NewDecoder(file) cal, err := dec.Decode() file.Close() if err != nil { return fmt.Errorf("failed to decode diary calendar: %w", err) } targetDate := date.Truncate(24 * time.Hour) var newChildren []*ical.Component for _, child := range cal.Children { keep := true if child.Name == ical.CompEvent { event := ical.Event{Component: child} summary, _ := event.Props.Text(ical.PropSummary) if summary == "Chronolog" { dtstart, err := event.Props.DateTime(ical.PropDateTimeStart, time.Local) if err == nil && dtstart.Truncate(24*time.Hour).Equal(targetDate) { keep = false } } } if keep { newChildren = append(newChildren, child) } } cal.Children = newChildren outFile, err := os.Create(s.diaryPath) if err != nil { return fmt.Errorf("failed to create diary file: %w", err) } defer outFile.Close() enc := ical.NewEncoder(outFile) if err := enc.Encode(cal); err != nil { return fmt.Errorf("failed to encode diary calendar: %w", err) } return nil } func parseEventsFromCalendar(cal *ical.Calendar, source models.EventSource) ([]*models.Event, error) { var events []*models.Event for _, child := range cal.Children { if child.Name != ical.CompEvent { continue } event := ical.Event{Component: child} uid, _ := event.Props.Text(ical.PropUID) summary, _ := event.Props.Text(ical.PropSummary) description, _ := event.Props.Text(ical.PropDescription) location, _ := event.Props.Text(ical.PropLocation) dtstart, err := event.Props.DateTime(ical.PropDateTimeStart, time.Local) if err != nil { // Try date-only format for all-day events dtstart, err = event.Props.DateTime(ical.PropDateTimeStart, time.Local) if err != nil { continue } } dtend, err := event.Props.DateTime(ical.PropDateTimeEnd, time.Local) if err != nil { dtend = dtstart.Add(time.Hour) } // Check if all-day event (date-only, no time component) allDay := isAllDayEvent(&event) events = append(events, &models.Event{ ID: uid, Title: summary, Description: description, Start: dtstart, End: dtend, AllDay: allDay, Location: location, Source: source, }) } return events, nil } func parseDiaryFromCalendar(cal *ical.Calendar) ([]*models.DiaryEntry, error) { var entries []*models.DiaryEntry for _, child := range cal.Children { if child.Name != ical.CompEvent { continue } event := ical.Event{Component: child} summary, _ := event.Props.Text(ical.PropSummary) if summary != "Chronolog" { continue } dtstart, err := event.Props.DateTime(ical.PropDateTimeStart, time.Local) if err != nil { continue } description, _ := event.Props.Text(ical.PropDescription) entries = append(entries, &models.DiaryEntry{ Date: dtstart.Truncate(24 * time.Hour), Text: description, }) } return entries, nil } func isAllDayEvent(event *ical.Event) bool { prop := event.Props.Get(ical.PropDateTimeStart) if prop == nil { return false } // All-day events have VALUE=DATE parameter valueParam := prop.Params.Get(ical.ParamValue) return valueParam == "DATE" } func eventInRange(event *models.Event, start, end time.Time) bool { return event.Start.Before(end) && event.End.After(start) }