Files
chronological/internal/storage/ics.go
2025-12-10 11:10:52 +01:00

342 lines
8.1 KiB
Go

// 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)
}