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, ok := findDiaryContext(root, fsPath) if !ok || depth == 0 { return nil } 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} } // 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, and whether one was found. // depth=0 means fsPath itself is the diary root. func findDiaryContext(root, fsPath string) (int, bool) { current := fsPath for depth := 0; ; depth++ { s := readPageSettings(current) if s != nil && s.Type == "diary" { return depth, true } if current == root { break } parent := filepath.Dir(current) if parent == current { break } current = parent } return 0, false } // 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 { Name string URL string PhotoCount int } type diaryDaySection struct { Heading string URL string Content template.HTML Photos []diaryPhoto } type diaryYearData struct{ Months []diaryMonthSummary } type diaryMonthData struct{ Days []diaryDaySection } type diaryDayData struct{ Photos []diaryPhoto } var diaryYearTmpl = template.Must(template.New("diary-year").Parse( `{{range .Months}}

{{.Name}}{{if .PhotoCount}} ({{.PhotoCount}} photos){{end}}

{{end}}`, )) var diaryMonthTmpl = template.Must(template.New("diary-month").Parse( `{{range .Days}}

{{if .URL}}{{.Heading}}{{else}}{{.Heading}}{{end}}

{{if .Content}}
{{.Content}}
{{end}}{{if .Photos}}
{{range .Photos}}{{.Name}}{{end}}
{{end}}
{{end}}`, )) var diaryDayTmpl = template.Must(template.New("diary-day").Parse( `{{if .Photos}}
{{range .Photos}}{{.Name}}{{end}}
{{end}}`, )) 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{ 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}); 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 := date.Format("Monday, January 2") 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{ Heading: heading, URL: dayURL, 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()) }