package main import ( "bytes" "fmt" "html/template" "log" "net/url" "os" "path" "path/filepath" "sort" "strconv" "strings" "time" ) func init() { pageTypeHandlers = append(pageTypeHandlers, &diaryHandler{}) } type diaryHandler struct{} func (d *diaryHandler) handle(root, fsPath, urlPath string) *specialPage { depth, diaryRootFS, diaryRootURL, ok := findDiaryContext(root, fsPath, urlPath) if !ok { return nil } widget := computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath, depth) if depth == 0 { return &specialPage{Widget: widget} } var content template.HTML switch depth { case 1: content = renderDiaryYear(fsPath, urlPath) case 2: content = renderDiaryMonth(fsPath, urlPath) case 3: content = renderDiaryDay(fsPath, urlPath) } return &specialPage{Content: content, SuppressListing: true, Widget: widget} } // findDiaryContext walks up from fsPath toward root looking for a // .page-settings file with type=diary. Returns the depth of fsPath // relative to the diary root, the diary root fs path, its URL, and // whether a diary root was found. depth=0 means fsPath itself is the root. func findDiaryContext(root, fsPath, urlPath string) (depth int, diaryRootFS, diaryRootURL string, ok bool) { currentFS := fsPath currentURL := urlPath for d := 0; ; d++ { s := readPageSettings(currentFS) if s != nil && s.Type == "diary" { return d, currentFS, currentURL, true } if currentFS == root { break } parent := filepath.Dir(currentFS) if parent == currentFS { break } currentFS = parent currentURL = parentURL(currentURL) } return 0, "", "", false } type calDay struct { Num int URL string HasEntry bool IsToday bool IsCurrent bool } type calYear struct { Num int URL string IsCurrent bool } type calMonth struct { Num int Name string URL string IsCurrent bool } type calendarData struct { DisplayYear int DisplayMonth int MonthName string DiaryURL string YearURL string MonthURL string PrevMonURL string NextMonURL string Weeks [][]calDay Years []calYear AllMonths []calMonth } var diaryCalTmpl = template.Must(template.ParseFS(assets, "assets/diary/diary-calendar.html")) func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int) template.HTML { today := time.Now() var displayYear, displayMonth, currentDay int switch depth { case 0: displayYear = today.Year() displayMonth = int(today.Month()) case 1: y, err := strconv.Atoi(filepath.Base(fsPath)) if err != nil { return "" } displayYear = y if y == today.Year() { displayMonth = int(today.Month()) } else { displayMonth = 1 } case 2: m, err := strconv.Atoi(filepath.Base(fsPath)) if err != nil || m < 1 || m > 12 { return "" } y, err := strconv.Atoi(filepath.Base(filepath.Dir(fsPath))) if err != nil { return "" } displayYear = y displayMonth = m case 3: d, err := strconv.Atoi(filepath.Base(fsPath)) if err != nil || d < 1 || d > 31 { return "" } monthFS := filepath.Dir(fsPath) m, err := strconv.Atoi(filepath.Base(monthFS)) if err != nil || m < 1 || m > 12 { return "" } y, err := strconv.Atoi(filepath.Base(filepath.Dir(monthFS))) if err != nil { return "" } displayYear = y displayMonth = m currentDay = d default: return "" } // Which days in the display month have diary subfolders? monthFSPath := filepath.Join(diaryRootFS, fmt.Sprintf("%d", displayYear), fmt.Sprintf("%02d", displayMonth)) dayEntries, _ := os.ReadDir(monthFSPath) hasDayEntry := map[int]bool{} for _, e := range dayEntries { if !e.IsDir() { continue } d, err := strconv.Atoi(e.Name()) if err != nil || d < 1 || d > 31 { continue } hasDayEntry[d] = true } // Build calendar grid with Monday as first column. firstDay := time.Date(displayYear, time.Month(displayMonth), 1, 0, 0, 0, 0, time.UTC) startOffset := int(firstDay.Weekday()+6) % 7 daysInMonth := time.Date(displayYear, time.Month(displayMonth)+1, 0, 0, 0, 0, 0, time.UTC).Day() monthURLBase := path.Join(diaryRootURL, fmt.Sprintf("%d", displayYear), fmt.Sprintf("%02d", displayMonth)) + "/" var weeks [][]calDay week := make([]calDay, 7) col := startOffset for d := 1; d <= daysInMonth; d++ { dayURL := path.Join(monthURLBase, fmt.Sprintf("%02d", d)) + "/" cell := calDay{Num: d, HasEntry: hasDayEntry[d]} if cell.HasEntry { cell.URL = dayURL } else { cell.URL = dayURL + "?edit" } cell.IsCurrent = d == currentDay cell.IsToday = d == today.Day() && time.Month(displayMonth) == today.Month() && displayYear == today.Year() week[col] = cell col++ if col == 7 { weeks = append(weeks, week) week = make([]calDay, 7) col = 0 } } if col > 0 { weeks = append(weeks, week) } prev := time.Date(displayYear, time.Month(displayMonth)-1, 1, 0, 0, 0, 0, time.UTC) next := time.Date(displayYear, time.Month(displayMonth)+1, 1, 0, 0, 0, 0, time.UTC) prevMonURL := path.Join(diaryRootURL, fmt.Sprintf("%d", prev.Year()), fmt.Sprintf("%02d", int(prev.Month()))) + "/" nextMonURL := path.Join(diaryRootURL, fmt.Sprintf("%d", next.Year()), fmt.Sprintf("%02d", int(next.Month()))) + "/" yearURL := path.Join(diaryRootURL, fmt.Sprintf("%d", displayYear)) + "/" // Collect all year subdirectories in diary root (descending). yearEntries, _ := os.ReadDir(diaryRootFS) var years []calYear yearSet := map[int]bool{} for _, e := range yearEntries { if !e.IsDir() { continue } y, err := strconv.Atoi(e.Name()) if err != nil { continue } yearSet[y] = true years = append(years, calYear{ Num: y, URL: path.Join(diaryRootURL, e.Name()) + "/", IsCurrent: y == displayYear, }) } if !yearSet[displayYear] { years = append(years, calYear{ Num: displayYear, URL: yearURL, IsCurrent: true, }) } sort.Slice(years, func(i, j int) bool { return years[i].Num > years[j].Num }) // All 12 months, ascending, linked to current display year. allMonths := make([]calMonth, 12) for m := 1; m <= 12; m++ { allMonths[m-1] = calMonth{ Num: m, Name: germanMonths[time.Month(m)], URL: path.Join(diaryRootURL, fmt.Sprintf("%d", displayYear), fmt.Sprintf("%02d", m)) + "/", IsCurrent: m == displayMonth, } } data := calendarData{ DisplayYear: displayYear, DisplayMonth: displayMonth, MonthName: germanMonths[time.Month(displayMonth)], DiaryURL: diaryRootURL, YearURL: yearURL, MonthURL: monthURLBase, PrevMonURL: prevMonURL, NextMonURL: nextMonURL, Weeks: weeks, Years: years, AllMonths: allMonths, } var buf bytes.Buffer if err := diaryCalTmpl.Execute(&buf, data); err != nil { log.Printf("diary calendar template: %v", err) return "" } return template.HTML(buf.String()) } // diaryPhoto is a photo file whose name starts with a YYYY-MM-DD date prefix. type diaryPhoto struct { Date time.Time Name string URL string } type diaryMonthSummary struct { ID string Name string URL string PhotoCount int } type diaryDaySection struct { ID string Heading string URL string EditURL string Content template.HTML Photos []diaryPhoto } type diaryYearData struct { Months []diaryMonthSummary Year int } type diaryMonthData struct{ Days []diaryDaySection } type diaryDayData struct{ Photos []diaryPhoto } var diaryYearTmpl = template.Must(template.ParseFS(assets, "assets/diary/diary-year.html")) var diaryMonthTmpl = template.Must(template.ParseFS(assets, "assets/diary/diary-month.html")) var diaryDayTmpl = template.Must(template.ParseFS(assets, "assets/diary/diary-day.html")) var germanWeekdays = map[time.Weekday]string{ time.Sunday: "Sonntag", time.Monday: "Montag", time.Tuesday: "Dienstag", time.Wednesday: "Mittwoch", time.Thursday: "Donnerstag", time.Friday: "Freitag", time.Saturday: "Samstag", } var germanMonths = map[time.Month]string{ time.January: "Januar", time.February: "Februar", time.March: "März", time.April: "April", time.May: "Mai", time.June: "Juni", time.July: "Juli", time.August: "August", time.September: "September", time.October: "Oktober", time.November: "November", time.December: "Dezember", } func formatGermanDate(t time.Time) string { return fmt.Sprintf("%s, %d. %s %d", germanWeekdays[t.Weekday()], t.Day(), germanMonths[t.Month()], t.Year(), ) } var photoExts = map[string]bool{ ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, } // yearPhotos returns all photos in yearFsPath whose filename starts with // a YYYY-MM-DD date prefix. func yearPhotos(yearFsPath, yearURLPath string) []diaryPhoto { entries, err := os.ReadDir(yearFsPath) if err != nil { return nil } var photos []diaryPhoto for _, e := range entries { if e.IsDir() { continue } name := e.Name() if !photoExts[strings.ToLower(filepath.Ext(name))] { continue } if len(name) < 10 { continue } t, err := time.Parse("2006-01-02", name[:10]) if err != nil { continue } photos = append(photos, diaryPhoto{ Date: t, Name: name, URL: path.Join(yearURLPath, url.PathEscape(name)), }) } return photos } // renderDiaryYear renders month sections with photo counts for a year folder. func renderDiaryYear(fsPath, urlPath string) template.HTML { year, err := strconv.Atoi(filepath.Base(fsPath)) if err != nil { return "" } photos := yearPhotos(fsPath, urlPath) entries, err := os.ReadDir(fsPath) if err != nil { return "" } var months []diaryMonthSummary for _, e := range entries { if !e.IsDir() { continue } monthNum, err := strconv.Atoi(e.Name()) if err != nil || monthNum < 1 || monthNum > 12 { continue } count := 0 for _, p := range photos { if p.Date.Year() == year && int(p.Date.Month()) == monthNum { count++ } } monthDate := time.Date(year, time.Month(monthNum), 1, 0, 0, 0, 0, time.UTC) months = append(months, diaryMonthSummary{ ID: monthDate.Format("2006-01"), Name: monthDate.Format("January 2006"), URL: path.Join(urlPath, e.Name()) + "/", PhotoCount: count, }) } var buf bytes.Buffer if err := diaryYearTmpl.Execute(&buf, diaryYearData{Months: months, Year: year}); err != nil { log.Printf("diary year template: %v", err) return "" } return template.HTML(buf.String()) } // renderDiaryMonth renders a section per day, each with its markdown content // and photos sourced from the parent year folder. func renderDiaryMonth(fsPath, urlPath string) template.HTML { yearFsPath := filepath.Dir(fsPath) yearURLPath := parentURL(urlPath) year, err := strconv.Atoi(filepath.Base(yearFsPath)) if err != nil { return "" } monthNum, err := strconv.Atoi(filepath.Base(fsPath)) if err != nil || monthNum < 1 || monthNum > 12 { return "" } allPhotos := yearPhotos(yearFsPath, yearURLPath) var monthPhotos []diaryPhoto for _, p := range allPhotos { if p.Date.Year() == year && int(p.Date.Month()) == monthNum { monthPhotos = append(monthPhotos, p) } } // Collect day numbers from subdirectories and from photo filenames. daySet := map[int]bool{} dayDirs := map[int]string{} // day number → actual directory name entries, _ := os.ReadDir(fsPath) for _, e := range entries { if !e.IsDir() { continue } d, err := strconv.Atoi(e.Name()) if err != nil || d < 1 || d > 31 { continue } daySet[d] = true dayDirs[d] = e.Name() } for _, p := range monthPhotos { daySet[p.Date.Day()] = true } days := make([]int, 0, len(daySet)) for d := range daySet { days = append(days, d) } sort.Ints(days) var sections []diaryDaySection for _, dayNum := range days { date := time.Date(year, time.Month(monthNum), dayNum, 0, 0, 0, 0, time.UTC) heading := formatGermanDate(date) dayURL := path.Join(urlPath, fmt.Sprintf("%02d", dayNum)) + "/" var content template.HTML if dirName, ok := dayDirs[dayNum]; ok { dayURL = path.Join(urlPath, dirName) + "/" dayFsPath := filepath.Join(fsPath, dirName) if raw, err := os.ReadFile(filepath.Join(dayFsPath, "index.md")); err == nil && len(raw) > 0 { if h := extractFirstHeading(raw); h != "" { heading = h raw = stripFirstHeading(raw) } content = renderMarkdown(raw) } } var photos []diaryPhoto for _, p := range monthPhotos { if p.Date.Day() == dayNum { photos = append(photos, p) } } sections = append(sections, diaryDaySection{ ID: date.Format("2006-01-02"), Heading: heading, URL: dayURL, EditURL: dayURL + "?edit", Content: content, Photos: photos, }) } var buf bytes.Buffer if err := diaryMonthTmpl.Execute(&buf, diaryMonthData{Days: sections}); err != nil { log.Printf("diary month template: %v", err) return "" } return template.HTML(buf.String()) } // renderDiaryDay renders the photo grid for a single day, sourcing photos // from the grandparent year folder. func renderDiaryDay(fsPath, urlPath string) template.HTML { monthFsPath := filepath.Dir(fsPath) yearFsPath := filepath.Dir(monthFsPath) yearURLPath := parentURL(parentURL(urlPath)) year, err := strconv.Atoi(filepath.Base(yearFsPath)) if err != nil { return "" } monthNum, err := strconv.Atoi(filepath.Base(monthFsPath)) if err != nil { return "" } dayNum, err := strconv.Atoi(filepath.Base(fsPath)) if err != nil { return "" } allPhotos := yearPhotos(yearFsPath, yearURLPath) var photos []diaryPhoto for _, p := range allPhotos { if p.Date.Year() == year && int(p.Date.Month()) == monthNum && p.Date.Day() == dayNum { photos = append(photos, p) } } if len(photos) == 0 { return "" } var buf bytes.Buffer if err := diaryDayTmpl.Execute(&buf, diaryDayData{Photos: photos}); err != nil { log.Printf("diary day template: %v", err) return "" } return template.HTML(buf.String()) }