343 lines
8.1 KiB
Go
343 lines
8.1 KiB
Go
// Package storage handles reading and writing data from various sources.
|
|
package storage
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"time"
|
|
|
|
"luxick/chronological/internal/models"
|
|
|
|
"github.com/emersion/go-ical"
|
|
)
|
|
|
|
// 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)
|
|
}
|