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.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 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())
}