Initial implementation

This commit is contained in:
2025-12-10 11:10:52 +01:00
parent cff9defc4a
commit 4cb97a25ba
26 changed files with 2714 additions and 1 deletions

144
internal/config/config.go Normal file
View File

@@ -0,0 +1,144 @@
// Package config handles loading and validation of application configuration.
package config
import (
"fmt"
"os"
"path/filepath"
"gopkg.in/ini.v1"
)
// Config holds all application configuration.
type Config struct {
Server ServerConfig
Calendar CalendarConfig
Diary DiaryConfig
CalDAV CalDAVConfig
Photos PhotosConfig
}
// ServerConfig holds HTTP server settings.
type ServerConfig struct {
Port int
}
// CalendarConfig holds calendar ICS file settings.
type CalendarConfig struct {
LocalICS string
}
// DiaryConfig holds diary ICS file settings.
type DiaryConfig struct {
ICSFile string
}
// CalDAVConfig holds CalDAV remote calendar settings.
type CalDAVConfig struct {
Enabled bool
URL string
Username string
Password string
}
// PhotosConfig holds photo storage settings.
type PhotosConfig struct {
RootFolder string
}
// Load reads configuration from the specified INI file.
func Load(path string) (*Config, error) {
cfg, err := ini.Load(path)
if err != nil {
return nil, fmt.Errorf("failed to load config file: %w", err)
}
config := &Config{
Server: ServerConfig{
Port: cfg.Section("server").Key("port").MustInt(8080),
},
Calendar: CalendarConfig{
LocalICS: cfg.Section("calendar").Key("local_ics").String(),
},
Diary: DiaryConfig{
ICSFile: cfg.Section("diary").Key("ics_file").String(),
},
CalDAV: CalDAVConfig{
Enabled: cfg.Section("caldav").Key("enabled").MustBool(false),
URL: cfg.Section("caldav").Key("url").String(),
Username: cfg.Section("caldav").Key("username").String(),
Password: cfg.Section("caldav").Key("password").String(),
},
Photos: PhotosConfig{
RootFolder: cfg.Section("photos").Key("root_folder").String(),
},
}
return config, nil
}
// Validate checks that all required configuration values are set and valid.
func (c *Config) Validate() error {
if c.Server.Port <= 0 || c.Server.Port > 65535 {
return fmt.Errorf("invalid server port: %d", c.Server.Port)
}
// Validate calendar ICS path if specified
if c.Calendar.LocalICS != "" {
if err := validateFileReadable(c.Calendar.LocalICS); err != nil {
return fmt.Errorf("calendar ICS file: %w", err)
}
}
// Validate diary ICS path if specified
if c.Diary.ICSFile != "" {
dir := filepath.Dir(c.Diary.ICSFile)
if err := validateDirExists(dir); err != nil {
return fmt.Errorf("diary ICS directory: %w", err)
}
}
// Validate CalDAV settings if enabled
if c.CalDAV.Enabled {
if c.CalDAV.URL == "" {
return fmt.Errorf("caldav URL is required when caldav is enabled")
}
}
// Validate photos folder if specified
if c.Photos.RootFolder != "" {
if err := validateDirExists(c.Photos.RootFolder); err != nil {
return fmt.Errorf("photos root folder: %w", err)
}
}
return nil
}
func validateFileReadable(path string) error {
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("file does not exist: %s", path)
}
return fmt.Errorf("cannot access file: %w", err)
}
if info.IsDir() {
return fmt.Errorf("path is a directory, not a file: %s", path)
}
return nil
}
func validateDirExists(path string) error {
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("directory does not exist: %s", path)
}
return fmt.Errorf("cannot access directory: %w", err)
}
if !info.IsDir() {
return fmt.Errorf("path is not a directory: %s", path)
}
return nil
}

View File

@@ -0,0 +1,357 @@
// Package handlers contains HTTP handlers for all routes.
package handlers
import (
"context"
"html/template"
"net/http"
"strconv"
"time"
"github.com/luxick/chronological/internal/models"
"github.com/luxick/chronological/internal/storage"
)
// Handlers holds all HTTP handlers and their dependencies.
type Handlers struct {
templates *template.Template
icsStore *storage.ICSStore
photoStore *storage.PhotoStore
caldavStore *storage.CalDAVStore
}
// New creates a new Handlers instance.
func New(tmpl *template.Template, ics *storage.ICSStore, photos *storage.PhotoStore, caldav *storage.CalDAVStore) *Handlers {
return &Handlers{
templates: tmpl,
icsStore: ics,
photoStore: photos,
caldavStore: caldav,
}
}
// HandleIndex serves the main timeline page.
func (h *Handlers) HandleIndex(w http.ResponseWriter, r *http.Request) {
now := time.Now()
data := h.buildPageData(now.Year(), now.Month())
h.render(w, "index.html", data)
}
// HandleCalendar serves the calendar grid partial for HTMX.
func (h *Handlers) HandleCalendar(w http.ResponseWriter, r *http.Request) {
year, month, err := parseYearMonth(r)
if err != nil {
http.Error(w, "Invalid date", http.StatusBadRequest)
return
}
data := h.buildCalendarData(year, month)
h.render(w, "calendar.html", data)
}
// HandleTimeline serves the timeline partial for HTMX.
func (h *Handlers) HandleTimeline(w http.ResponseWriter, r *http.Request) {
year, month, err := parseYearMonth(r)
if err != nil {
http.Error(w, "Invalid date", http.StatusBadRequest)
return
}
data := h.buildTimelineData(year, month)
h.render(w, "timeline.html", data)
}
// HandleDay serves the day detail view.
func (h *Handlers) HandleDay(w http.ResponseWriter, r *http.Request) {
year, month, err := parseYearMonth(r)
if err != nil {
http.Error(w, "Invalid date", http.StatusBadRequest)
return
}
dayStr := r.PathValue("day")
day, err := strconv.Atoi(dayStr)
if err != nil || day < 1 || day > 31 {
http.Error(w, "Invalid day", http.StatusBadRequest)
return
}
date := time.Date(year, month, day, 0, 0, 0, 0, time.Local)
data := h.buildDayData(date)
h.render(w, "day.html", data)
}
// HandleGetDiary serves a diary entry for a specific date.
func (h *Handlers) HandleGetDiary(w http.ResponseWriter, r *http.Request) {
date, err := parseDate(r.PathValue("date"))
if err != nil {
http.Error(w, "Invalid date", http.StatusBadRequest)
return
}
entry, err := h.icsStore.LoadDiaryEntry(date)
if err != nil {
http.Error(w, "Failed to load diary", http.StatusInternalServerError)
return
}
data := map[string]any{
"Date": date,
"Entry": entry,
}
h.render(w, "diary_form.html", data)
}
// HandleSaveDiary saves or updates a diary entry.
func (h *Handlers) HandleSaveDiary(w http.ResponseWriter, r *http.Request) {
date, err := parseDate(r.PathValue("date"))
if err != nil {
http.Error(w, "Invalid date", http.StatusBadRequest)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Failed to parse form", http.StatusBadRequest)
return
}
text := r.FormValue("text")
entry := &models.DiaryEntry{
Date: date,
Text: text,
}
if err := h.icsStore.SaveDiaryEntry(entry); err != nil {
http.Error(w, "Failed to save diary", http.StatusInternalServerError)
return
}
// Return updated day content
data := h.buildDayData(date)
h.render(w, "day_content.html", data)
}
// HandleDeleteDiary deletes a diary entry.
func (h *Handlers) HandleDeleteDiary(w http.ResponseWriter, r *http.Request) {
date, err := parseDate(r.PathValue("date"))
if err != nil {
http.Error(w, "Invalid date", http.StatusBadRequest)
return
}
if err := h.icsStore.DeleteDiaryEntry(date); err != nil {
http.Error(w, "Failed to delete diary", http.StatusInternalServerError)
return
}
// Return updated day content
data := h.buildDayData(date)
h.render(w, "day_content.html", data)
}
func (h *Handlers) render(w http.ResponseWriter, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := h.templates.ExecuteTemplate(w, name, data); err != nil {
http.Error(w, "Template error", http.StatusInternalServerError)
}
}
func (h *Handlers) buildPageData(year int, month time.Month) map[string]any {
return map[string]any{
"Year": year,
"Month": month,
"Calendar": h.buildCalendarData(year, month),
"Timeline": h.buildTimelineData(year, month),
}
}
func (h *Handlers) buildCalendarData(year int, month time.Month) map[string]any {
// Load content indicators for each day
events, _ := h.icsStore.LoadEventsForMonth(year, month)
diaries, _ := h.icsStore.LoadDiaryEntries()
photos, _ := h.photoStore.LoadPhotosForMonth(year, month)
// Load CalDAV events if available
if h.caldavStore != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
caldavEvents, err := h.caldavStore.LoadEventsForMonth(ctx, year, month)
if err == nil {
events = append(events, caldavEvents...)
}
}
// Build day content map
days := make(map[int]*models.DayContent)
daysInMonth := time.Date(year, month+1, 0, 0, 0, 0, 0, time.Local).Day()
for day := 1; day <= daysInMonth; day++ {
date := time.Date(year, month, day, 0, 0, 0, 0, time.Local)
content := &models.DayContent{Date: date}
// Add events for this day
for _, event := range events {
if event.IsOnDate(date) {
content.Events = append(content.Events, event)
}
}
// Add diary entry for this day
for _, diary := range diaries {
if diary.Date.Truncate(24 * time.Hour).Equal(date.Truncate(24 * time.Hour)) {
content.Diary = diary
break
}
}
// Add photos for this day
for _, photo := range photos {
if photo.Date.Truncate(24 * time.Hour).Equal(date.Truncate(24 * time.Hour)) {
content.Photos = append(content.Photos, photo)
}
}
days[day] = content
}
// Calculate first day offset (0 = Sunday)
firstDay := time.Date(year, month, 1, 0, 0, 0, 0, time.Local)
firstDayWeekday := int(firstDay.Weekday())
// Previous/next month
prevMonth := time.Date(year, month-1, 1, 0, 0, 0, 0, time.Local)
nextMonth := time.Date(year, month+1, 1, 0, 0, 0, 0, time.Local)
return map[string]any{
"Year": year,
"Month": month,
"MonthName": month.String(),
"DaysInMonth": daysInMonth,
"FirstDayWeekday": firstDayWeekday,
"Days": days,
"PrevYear": prevMonth.Year(),
"PrevMonth": int(prevMonth.Month()),
"NextYear": nextMonth.Year(),
"NextMonth": int(nextMonth.Month()),
}
}
func (h *Handlers) buildTimelineData(year int, month time.Month) map[string]any {
events, _ := h.icsStore.LoadEventsForMonth(year, month)
diaries, _ := h.icsStore.LoadDiaryEntries()
photos, _ := h.photoStore.LoadPhotosForMonth(year, month)
// Load CalDAV events if available
if h.caldavStore != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
caldavEvents, err := h.caldavStore.LoadEventsForMonth(ctx, year, month)
if err == nil {
events = append(events, caldavEvents...)
}
}
// Build entries grouped by day
daysInMonth := time.Date(year, month+1, 0, 0, 0, 0, 0, time.Local).Day()
var daysWithContent []*models.DayContent
for day := 1; day <= daysInMonth; day++ {
date := time.Date(year, month, day, 0, 0, 0, 0, time.Local)
content := &models.DayContent{Date: date}
for _, event := range events {
if event.IsOnDate(date) {
content.Events = append(content.Events, event)
}
}
for _, diary := range diaries {
if diary.Date.Truncate(24 * time.Hour).Equal(date.Truncate(24 * time.Hour)) {
content.Diary = diary
break
}
}
for _, photo := range photos {
if photo.Date.Truncate(24 * time.Hour).Equal(date.Truncate(24 * time.Hour)) {
content.Photos = append(content.Photos, photo)
}
}
if content.HasContent() {
daysWithContent = append(daysWithContent, content)
}
}
// Previous/next month
prevMonth := time.Date(year, month-1, 1, 0, 0, 0, 0, time.Local)
nextMonth := time.Date(year, month+1, 1, 0, 0, 0, 0, time.Local)
return map[string]any{
"Year": year,
"Month": month,
"MonthName": month.String(),
"Days": daysWithContent,
"PrevYear": prevMonth.Year(),
"PrevMonth": int(prevMonth.Month()),
"NextYear": nextMonth.Year(),
"NextMonth": int(nextMonth.Month()),
}
}
func (h *Handlers) buildDayData(date time.Time) map[string]any {
content := &models.DayContent{Date: date}
events, _ := h.icsStore.LoadEventsForMonth(date.Year(), date.Month())
for _, event := range events {
if event.IsOnDate(date) {
content.Events = append(content.Events, event)
}
}
// Load CalDAV events if available
if h.caldavStore != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
caldavEvents, err := h.caldavStore.LoadEventsForMonth(ctx, date.Year(), date.Month())
if err == nil {
for _, event := range caldavEvents {
if event.IsOnDate(date) {
content.Events = append(content.Events, event)
}
}
}
}
entry, _ := h.icsStore.LoadDiaryEntry(date)
content.Diary = entry
photos, _ := h.photoStore.LoadPhotosForDate(date)
content.Photos = photos
return map[string]any{
"Date": date,
"Content": content,
}
}
func parseYearMonth(r *http.Request) (int, time.Month, error) {
yearStr := r.PathValue("year")
monthStr := r.PathValue("month")
year, err := strconv.Atoi(yearStr)
if err != nil {
return 0, 0, err
}
monthInt, err := strconv.Atoi(monthStr)
if err != nil || monthInt < 1 || monthInt > 12 {
return 0, 0, err
}
return year, time.Month(monthInt), nil
}
func parseDate(dateStr string) (time.Time, error) {
return time.Parse("2006-01-02", dateStr)
}

14
internal/models/diary.go Normal file
View File

@@ -0,0 +1,14 @@
package models
import "time"
// DiaryEntry represents a journal entry for a specific day.
type DiaryEntry struct {
Date time.Time
Text string
}
// DateString returns the date formatted as YYYY-MM-DD.
func (d *DiaryEntry) DateString() string {
return d.Date.Format("2006-01-02")
}

37
internal/models/event.go Normal file
View File

@@ -0,0 +1,37 @@
// Package models defines the core data structures for the application.
package models
import "time"
// Event represents a calendar event.
type Event struct {
ID string
Title string
Description string
Start time.Time
End time.Time
AllDay bool
Location string
Source EventSource
}
// EventSource indicates where an event originated from.
type EventSource string
const (
SourceLocal EventSource = "local"
SourceCalDAV EventSource = "caldav"
)
// IsOnDate checks if the event occurs on the given date.
func (e *Event) IsOnDate(date time.Time) bool {
eventDate := e.Start.Truncate(24 * time.Hour)
checkDate := date.Truncate(24 * time.Hour)
if e.AllDay {
endDate := e.End.Truncate(24 * time.Hour)
return !checkDate.Before(eventDate) && checkDate.Before(endDate)
}
return eventDate.Equal(checkDate)
}

30
internal/models/photo.go Normal file
View File

@@ -0,0 +1,30 @@
package models
import (
"path/filepath"
"time"
)
// Photo represents a photo with its metadata.
type Photo struct {
Date time.Time
Year string
Filename string
Description string
FullPath string
}
// DateString returns the date formatted as YYYY-MM-DD.
func (p *Photo) DateString() string {
return p.Date.Format("2006-01-02")
}
// URLPath returns the URL path to access this photo.
func (p *Photo) URLPath() string {
return filepath.Join("/photos", p.Year, p.Filename)
}
// ThumbURLPath returns the URL path to access this photo's thumbnail.
func (p *Photo) ThumbURLPath() string {
return filepath.Join("/photos/thumb", p.Year, p.Filename)
}

104
internal/models/timeline.go Normal file
View File

@@ -0,0 +1,104 @@
package models
import (
"sort"
"time"
)
// TimelineEntry represents any item that can appear in the timeline.
type TimelineEntry struct {
Date time.Time
Type EntryType
Event *Event
Diary *DiaryEntry
Photo *Photo
}
// EntryType indicates what kind of entry this is.
type EntryType string
const (
EntryTypeEvent EntryType = "event"
EntryTypeDiary EntryType = "diary"
EntryTypePhoto EntryType = "photo"
)
// DayContent aggregates all content for a specific day.
type DayContent struct {
Date time.Time
Events []*Event
Diary *DiaryEntry
Photos []*Photo
}
// HasContent returns true if there is any content for this day.
func (d *DayContent) HasContent() bool {
return len(d.Events) > 0 || d.Diary != nil || len(d.Photos) > 0
}
// HasEvents returns true if there are events on this day.
func (d *DayContent) HasEvents() bool {
return len(d.Events) > 0
}
// HasDiary returns true if there is a diary entry for this day.
func (d *DayContent) HasDiary() bool {
return d.Diary != nil
}
// HasPhotos returns true if there are photos for this day.
func (d *DayContent) HasPhotos() bool {
return len(d.Photos) > 0
}
// ToTimelineEntries converts day content to a sorted slice of timeline entries.
func (d *DayContent) ToTimelineEntries() []TimelineEntry {
var entries []TimelineEntry
for _, event := range d.Events {
entries = append(entries, TimelineEntry{
Date: event.Start,
Type: EntryTypeEvent,
Event: event,
})
}
if d.Diary != nil {
entries = append(entries, TimelineEntry{
Date: d.Diary.Date,
Type: EntryTypeDiary,
Diary: d.Diary,
})
}
for _, photo := range d.Photos {
entries = append(entries, TimelineEntry{
Date: photo.Date,
Type: EntryTypePhoto,
Photo: photo,
})
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].Date.Before(entries[j].Date)
})
return entries
}
// MonthData holds all data for a specific month.
type MonthData struct {
Year int
Month time.Month
Days map[int]*DayContent
}
// GetDay returns the day content for a specific day number.
func (m *MonthData) GetDay(day int) *DayContent {
if content, ok := m.Days[day]; ok {
return content
}
return &DayContent{
Date: time.Date(m.Year, m.Month, day, 0, 0, 0, 0, time.Local),
}
}

217
internal/server/server.go Normal file
View File

@@ -0,0 +1,217 @@
// Package server sets up and runs the HTTP server.
package server
import (
"context"
"embed"
"fmt"
"html/template"
"io/fs"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/luxick/chronological/internal/config"
"github.com/luxick/chronological/internal/handlers"
"github.com/luxick/chronological/internal/storage"
)
// TemplatesFS and StaticFS are set from main package where embed works
var TemplatesFS embed.FS
var StaticFS embed.FS
// Server represents the HTTP server and its dependencies.
type Server struct {
config *config.Config
httpServer *http.Server
templates *template.Template
icsStore *storage.ICSStore
photoStore *storage.PhotoStore
caldavStore *storage.CalDAVStore
}
// New creates a new server with the given configuration.
func New(cfg *config.Config) (*Server, error) {
s := &Server{
config: cfg,
icsStore: storage.NewICSStore(cfg.Calendar.LocalICS, cfg.Diary.ICSFile),
photoStore: storage.NewPhotoStore(cfg.Photos.RootFolder),
}
// Initialize CalDAV store if enabled
if cfg.CalDAV.Enabled {
s.caldavStore = storage.NewCalDAVStore(cfg.CalDAV.URL, cfg.CalDAV.Username, cfg.CalDAV.Password)
}
// Parse templates
if err := s.parseTemplates(); err != nil {
return nil, fmt.Errorf("failed to parse templates: %w", err)
}
return s, nil
}
func (s *Server) parseTemplates() error {
funcMap := template.FuncMap{
"formatDate": formatDate,
"formatTime": formatTime,
"formatDateTime": formatDateTime,
"formatMonth": formatMonth,
"daysInMonth": daysInMonth,
"weekday": weekday,
"add": add,
"sub": sub,
"seq": seq,
"isToday": isToday,
"isSameMonth": isSameMonth,
}
// Get the templates subdirectory
templatesDir, err := fs.Sub(TemplatesFS, "templates")
if err != nil {
return fmt.Errorf("failed to get templates subdirectory: %w", err)
}
tmpl, err := template.New("").Funcs(funcMap).ParseFS(templatesDir,
"layouts/*.html",
"partials/*.html",
"pages/*.html",
)
if err != nil {
return fmt.Errorf("failed to parse templates: %w", err)
}
s.templates = tmpl
return nil
}
// Run starts the HTTP server and blocks until shutdown.
func (s *Server) Run() error {
// Connect to CalDAV if enabled
if s.caldavStore != nil {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := s.caldavStore.Connect(ctx); err != nil {
log.Printf("Warning: failed to connect to CalDAV server: %v", err)
}
}
// Create handlers
h := handlers.New(s.templates, s.icsStore, s.photoStore, s.caldavStore)
// Set up routes
mux := http.NewServeMux()
// Static files
staticDir, err := fs.Sub(StaticFS, "static")
if err != nil {
return fmt.Errorf("failed to get static subdirectory: %w", err)
}
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticDir))))
// Photo files (served from configured directory)
if s.config.Photos.RootFolder != "" {
mux.Handle("GET /photos/", http.StripPrefix("/photos/", http.FileServer(http.Dir(s.config.Photos.RootFolder))))
}
// Page routes
mux.HandleFunc("GET /", h.HandleIndex)
mux.HandleFunc("GET /calendar/{year}/{month}", h.HandleCalendar)
mux.HandleFunc("GET /timeline/{year}/{month}", h.HandleTimeline)
mux.HandleFunc("GET /day/{year}/{month}/{day}", h.HandleDay)
// Diary routes
mux.HandleFunc("GET /diary/{date}", h.HandleGetDiary)
mux.HandleFunc("POST /diary/{date}", h.HandleSaveDiary)
mux.HandleFunc("DELETE /diary/{date}", h.HandleDeleteDiary)
// Create HTTP server
s.httpServer = &http.Server{
Addr: fmt.Sprintf(":%d", s.config.Server.Port),
Handler: mux,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
// Start server in goroutine
go func() {
log.Printf("Starting server on http://localhost:%d", s.config.Server.Port)
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server error: %v", err)
}
}()
// Wait for shutdown signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := s.httpServer.Shutdown(ctx); err != nil {
return fmt.Errorf("server shutdown error: %w", err)
}
log.Println("Server stopped")
return nil
}
// Template helper functions
func formatDate(t time.Time) string {
return t.Format("2006-01-02")
}
func formatTime(t time.Time) string {
return t.Format("15:04")
}
func formatDateTime(t time.Time) string {
return t.Format("2006-01-02 15:04")
}
func formatMonth(t time.Time) string {
return t.Format("January 2006")
}
func daysInMonth(year int, month time.Month) int {
return time.Date(year, month+1, 0, 0, 0, 0, 0, time.Local).Day()
}
func weekday(year int, month time.Month, day int) int {
return int(time.Date(year, month, day, 0, 0, 0, 0, time.Local).Weekday())
}
func add(a, b int) int {
return a + b
}
func sub(a, b int) int {
return a - b
}
func seq(start, end int) []int {
s := make([]int, end-start+1)
for i := range s {
s[i] = start + i
}
return s
}
func isToday(year int, month time.Month, day int) bool {
now := time.Now()
return now.Year() == year && now.Month() == month && now.Day() == day
}
func isSameMonth(year int, month time.Month) bool {
now := time.Now()
return now.Year() == year && now.Month() == month
}

185
internal/storage/caldav.go Normal file
View File

@@ -0,0 +1,185 @@
package storage
import (
"context"
"fmt"
"net/http"
"time"
"github.com/emersion/go-ical"
"github.com/emersion/go-webdav/caldav"
"github.com/luxick/chronological/internal/models"
)
// CalDAVStore handles fetching events from a CalDAV server.
type CalDAVStore struct {
url string
username string
password string
client *caldav.Client
}
// NewCalDAVStore creates a new CalDAV store with the given credentials.
func NewCalDAVStore(url, username, password string) *CalDAVStore {
return &CalDAVStore{
url: url,
username: username,
password: password,
}
}
// Connect establishes a connection to the CalDAV server.
func (s *CalDAVStore) Connect(ctx context.Context) error {
httpClient := &http.Client{
Transport: &basicAuthTransport{
username: s.username,
password: s.password,
},
}
client, err := caldav.NewClient(httpClient, s.url)
if err != nil {
return fmt.Errorf("failed to create caldav client: %w", err)
}
s.client = client
return nil
}
// LoadEvents fetches events from the CalDAV server for a date range.
func (s *CalDAVStore) LoadEvents(ctx context.Context, start, end time.Time) ([]*models.Event, error) {
if s.client == nil {
return nil, fmt.Errorf("caldav client not connected")
}
principal, err := s.client.FindCurrentUserPrincipal(ctx)
if err != nil {
return nil, fmt.Errorf("failed to find user principal: %w", err)
}
homeSet, err := s.client.FindCalendarHomeSet(ctx, principal)
if err != nil {
return nil, fmt.Errorf("failed to find calendar home set: %w", err)
}
calendars, err := s.client.FindCalendars(ctx, homeSet)
if err != nil {
return nil, fmt.Errorf("failed to find calendars: %w", err)
}
var allEvents []*models.Event
for _, cal := range calendars {
query := &caldav.CalendarQuery{
CompRequest: caldav.CalendarCompRequest{
Name: "VCALENDAR",
Comps: []caldav.CalendarCompRequest{{
Name: "VEVENT",
Props: []string{
"SUMMARY",
"DESCRIPTION",
"DTSTART",
"DTEND",
"LOCATION",
"UID",
},
}},
},
CompFilter: caldav.CompFilter{
Name: "VCALENDAR",
Comps: []caldav.CompFilter{{
Name: "VEVENT",
Start: start,
End: end,
}},
},
}
objects, err := s.client.QueryCalendar(ctx, cal.Path, query)
if err != nil {
continue // Skip calendars that fail
}
events, err := parseCalDAVObjects(objects)
if err != nil {
continue
}
allEvents = append(allEvents, events...)
}
return allEvents, nil
}
// LoadEventsForMonth fetches events for a specific month.
func (s *CalDAVStore) LoadEventsForMonth(ctx context.Context, year int, month time.Month) ([]*models.Event, error) {
start := time.Date(year, month, 1, 0, 0, 0, 0, time.Local)
end := start.AddDate(0, 1, 0)
return s.LoadEvents(ctx, start, end)
}
func parseCalDAVObjects(objects []caldav.CalendarObject) ([]*models.Event, error) {
var events []*models.Event
for _, obj := range objects {
cal := obj.Data
if cal == nil {
continue
}
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 {
continue
}
dtend, err := event.Props.DateTime(ical.PropDateTimeEnd, time.Local)
if err != nil {
dtend = dtstart.Add(time.Hour)
}
// Check if all-day event
prop := event.Props.Get(ical.PropDateTimeStart)
allDay := false
if prop != nil {
valueParam := prop.Params.Get(ical.ParamValue)
allDay = valueParam == "DATE"
}
events = append(events, &models.Event{
ID: uid,
Title: summary,
Description: description,
Start: dtstart,
End: dtend,
AllDay: allDay,
Location: location,
Source: models.SourceCalDAV,
})
}
}
return events, nil
}
// basicAuthTransport adds basic auth to HTTP requests.
type basicAuthTransport struct {
username string
password string
}
func (t *basicAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req.SetBasicAuth(t.username, t.password)
return http.DefaultTransport.RoundTrip(req)
}

341
internal/storage/ics.go Normal file
View File

@@ -0,0 +1,341 @@
// 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)
}

167
internal/storage/photos.go Normal file
View File

@@ -0,0 +1,167 @@
package storage
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/luxick/chronological/internal/models"
)
// PhotoStore handles scanning and serving photos from the filesystem.
type PhotoStore struct {
rootFolder string
}
// NewPhotoStore creates a new photo store with the given root folder.
func NewPhotoStore(rootFolder string) *PhotoStore {
return &PhotoStore{
rootFolder: rootFolder,
}
}
// Supported image extensions
var supportedExtensions = map[string]bool{
".jpg": true,
".jpeg": true,
".png": true,
".gif": true,
".webp": true,
}
// Filename pattern: YYYY-MM-DD_description.ext
var filenamePattern = regexp.MustCompile(`^(\d{4})-(\d{2})-(\d{2})_(.+)\.(\w+)$`)
// LoadAllPhotos scans and loads all photos from the configured directory.
func (s *PhotoStore) LoadAllPhotos() ([]*models.Photo, error) {
if s.rootFolder == "" {
return nil, nil
}
var photos []*models.Photo
// Walk through year folders
entries, err := os.ReadDir(s.rootFolder)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("failed to read photo root folder: %w", err)
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
yearFolder := entry.Name()
yearPath := filepath.Join(s.rootFolder, yearFolder)
yearPhotos, err := s.loadPhotosFromYear(yearFolder, yearPath)
if err != nil {
continue // Skip problematic folders
}
photos = append(photos, yearPhotos...)
}
return photos, nil
}
// LoadPhotosForMonth loads photos for a specific month.
func (s *PhotoStore) LoadPhotosForMonth(year int, month time.Month) ([]*models.Photo, error) {
allPhotos, err := s.LoadAllPhotos()
if err != nil {
return nil, err
}
var monthPhotos []*models.Photo
for _, photo := range allPhotos {
if photo.Date.Year() == year && photo.Date.Month() == month {
monthPhotos = append(monthPhotos, photo)
}
}
return monthPhotos, nil
}
// LoadPhotosForDate loads photos for a specific date.
func (s *PhotoStore) LoadPhotosForDate(date time.Time) ([]*models.Photo, error) {
allPhotos, err := s.LoadAllPhotos()
if err != nil {
return nil, err
}
targetDate := date.Truncate(24 * time.Hour)
var datePhotos []*models.Photo
for _, photo := range allPhotos {
if photo.Date.Truncate(24 * time.Hour).Equal(targetDate) {
datePhotos = append(datePhotos, photo)
}
}
return datePhotos, nil
}
// GetPhotoPath returns the full filesystem path for a photo.
func (s *PhotoStore) GetPhotoPath(year, filename string) string {
return filepath.Join(s.rootFolder, year, filename)
}
func (s *PhotoStore) loadPhotosFromYear(yearFolder, yearPath string) ([]*models.Photo, error) {
var photos []*models.Photo
entries, err := os.ReadDir(yearPath)
if err != nil {
return nil, err
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
filename := entry.Name()
ext := strings.ToLower(filepath.Ext(filename))
if !supportedExtensions[ext] {
continue
}
photo := parsePhotoFilename(yearFolder, filename, filepath.Join(yearPath, filename))
if photo != nil {
photos = append(photos, photo)
}
}
return photos, nil
}
func parsePhotoFilename(yearFolder, filename, fullPath string) *models.Photo {
matches := filenamePattern.FindStringSubmatch(filename)
if matches == nil {
return nil
}
dateStr := fmt.Sprintf("%s-%s-%s", matches[1], matches[2], matches[3])
date, err := time.Parse("2006-01-02", dateStr)
if err != nil {
return nil
}
description := matches[4]
// Convert underscores and camelCase to spaces for display
description = strings.ReplaceAll(description, "_", " ")
return &models.Photo{
Date: date,
Year: yearFolder,
Filename: filename,
Description: description,
FullPath: fullPath,
}
}