Files
photog/generator.go
T
puttaalu 4ddf5adf24 Make the timeline the default view, paginated and configurable
The day-grouped timeline is now the site root (/, /page/N/); the flat
square grid moves to /grid/ (/grid/page/N/). Both are linked from the
profile header, separated by a divider.

Timeline pages pack whole day-groups until they reach the page size,
never splitting a day across a page boundary, so appended pages never
repeat a date heading — the infinite-scroll handler relies on this.

Page size is configurable via -page-size (default 60), threaded through
the Generator and exposed as services.photogallery.pageSize in the
NixOS module.
2026-08-20 00:11:08 +02:00

611 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"encoding/xml"
"html/template"
"image"
imagedraw "image/draw"
"image/jpeg"
_ "image/png"
"io"
"log"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/BurntSushi/toml"
xdraw "golang.org/x/image/draw"
)
const (
thumbSize = 600
mediumMaxSize = 1600
defaultPageSize = 60
)
// PhotoMeta is the structure of a .toml sidecar file.
type PhotoMeta struct {
Caption string `toml:"caption"`
Date string `toml:"date"` // YYYY-MM-DD
}
// Photo is the in-memory representation of one image.
type Photo struct {
Slug string
File string // relative path under public/, e.g. "images/foo.jpg"
Thumb string // relative path under public/, e.g. "thumbs/foo.jpg"
Medium string // relative path under public/, e.g. "medium/foo.jpg"
Caption string
Date time.Time
}
// DayGroup is a run of photos sharing the same calendar day, for the timeline.
type DayGroup struct {
day string // "2006-01-02", grouping key (unexported: not for templates)
Label string // "14 June 2026"
Count int
Photos []Photo
}
type Generator struct {
contentDir string
outputDir string
baseURL string
author string
handle string
bio string
bioAlt string
fontsURL string
serifFamily string
mlFamily string
pageSize int
mu sync.Mutex
tmpl *template.Template
}
func NewGenerator(contentDir, outputDir, baseURL, author, handle, bio, bioAlt, fontsURL, serifFamily, mlFamily string, pageSize int) *Generator {
tmpl := template.Must(template.ParseFS(assets, "templates/*.html"))
if pageSize < 1 {
pageSize = defaultPageSize
}
return &Generator{
contentDir: contentDir,
outputDir: outputDir,
baseURL: baseURL,
author: author,
handle: handle,
bio: bio,
bioAlt: bioAlt,
fontsURL: fontsURL,
serifFamily: serifFamily,
mlFamily: mlFamily,
pageSize: pageSize,
tmpl: tmpl,
}
}
func (g *Generator) initial() string {
for _, r := range g.author {
return strings.ToUpper(string(r))
}
return ""
}
// Build performs a full rebuild of the site. It is safe to call concurrently;
// concurrent calls are serialised by a mutex.
func (g *Generator) Build() error {
g.mu.Lock()
defer g.mu.Unlock()
photos, err := g.loadPhotos()
if err != nil {
return err
}
for _, d := range []string{"images", "thumbs", "medium", "photo", "grid", "static"} {
if err := os.MkdirAll(filepath.Join(g.outputDir, d), 0755); err != nil {
return err
}
}
for i := range photos {
if err := g.processImage(&photos[i]); err != nil {
log.Printf("processImage %s: %v", photos[i].Slug, err)
}
}
if err := g.renderTimelinePages(photos); err != nil {
return err
}
if err := g.renderGridPages(photos); err != nil {
return err
}
for i := range photos {
var newer, older *Photo
if i > 0 {
newer = &photos[i-1]
}
if i+1 < len(photos) {
older = &photos[i+1]
}
if err := g.renderPhotoPage(photos[i], newer, older); err != nil {
return err
}
}
if err := g.renderFeed(photos); err != nil {
return err
}
if err := g.copyStatic(); err != nil {
return err
}
log.Printf("built: %d photos", len(photos))
return nil
}
// loadPhotos scans the content directory and returns photos sorted newest first.
func (g *Generator) loadPhotos() ([]Photo, error) {
entries, err := os.ReadDir(g.contentDir)
if err != nil {
return nil, err
}
var photos []Photo
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
ext := strings.ToLower(filepath.Ext(name))
if ext != ".jpg" && ext != ".jpeg" && ext != ".png" {
continue
}
slug := strings.TrimSuffix(name, filepath.Ext(name))
meta := PhotoMeta{
Caption: slug,
Date: time.Now().Format("2006-01-02"),
}
tomlPath := filepath.Join(g.contentDir, slug+".toml")
if _, err := os.Stat(tomlPath); err == nil {
if _, err := toml.DecodeFile(tomlPath, &meta); err != nil {
log.Printf("parsing %s: %v", tomlPath, err)
}
}
date, err := time.Parse("2006-01-02", meta.Date)
if err != nil {
date = time.Now()
}
photos = append(photos, Photo{
Slug: slug,
File: "images/" + name,
Thumb: "thumbs/" + slug + ".jpg",
Medium: "medium/" + slug + ".jpg",
Caption: meta.Caption,
Date: date,
})
}
sort.Slice(photos, func(i, j int) bool {
return photos[i].Date.After(photos[j].Date)
})
return photos, nil
}
// processImage copies the original to public/images/ and writes a square
// thumbnail to public/thumbs/ plus an aspect-preserved medium image to
// public/medium/. Outputs are skipped when already newer than the source.
func (g *Generator) processImage(p *Photo) error {
src := filepath.Join(g.contentDir, filepath.Base(p.File))
dstImg := filepath.Join(g.outputDir, p.File)
dstThumb := filepath.Join(g.outputDir, p.Thumb)
dstMedium := filepath.Join(g.outputDir, p.Medium)
if !upToDate(src, dstImg) {
if err := copyFile(src, dstImg); err != nil {
return err
}
}
if !upToDate(src, dstThumb) {
if err := generateThumb(src, dstThumb, thumbSize); err != nil {
return err
}
}
if !upToDate(src, dstMedium) {
if err := generateMedium(src, dstMedium, mediumMaxSize); err != nil {
return err
}
}
return nil
}
// upToDate reports whether dst exists and is at least as new as src.
func upToDate(src, dst string) bool {
si, err := os.Stat(src)
if err != nil {
return false
}
di, err := os.Stat(dst)
if err != nil {
return false
}
return !di.ModTime().Before(si.ModTime())
}
// generateThumb reads src, centre-crops it to a square, scales it to size×size,
// and writes a JPEG to dst.
func generateThumb(src, dst string, size int) error {
f, err := os.Open(src)
if err != nil {
return err
}
defer f.Close()
img, _, err := image.Decode(f)
if err != nil {
return err
}
// Centre crop to square.
b := img.Bounds()
w, h := b.Dx(), b.Dy()
crop := w
if h < w {
crop = h
}
x0 := (w - crop) / 2
y0 := (h - crop) / 2
cropped := image.NewRGBA(image.Rect(0, 0, crop, crop))
imagedraw.Draw(cropped, cropped.Bounds(), img, image.Point{x0, y0}, imagedraw.Src)
// Scale to thumbSize×thumbSize using Catmull-Rom for quality.
thumb := image.NewRGBA(image.Rect(0, 0, size, size))
xdraw.CatmullRom.Scale(thumb, thumb.Bounds(), cropped, cropped.Bounds(), xdraw.Over, nil)
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
return jpeg.Encode(out, thumb, &jpeg.Options{Quality: 85})
}
// generateMedium reads src, resizes it so its longest side is at most maxDim
// while preserving aspect ratio, and writes a JPEG to dst.
func generateMedium(src, dst string, maxDim int) error {
f, err := os.Open(src)
if err != nil {
return err
}
defer f.Close()
img, _, err := image.Decode(f)
if err != nil {
return err
}
b := img.Bounds()
w, h := b.Dx(), b.Dy()
nw, nh := w, h
if w > maxDim || h > maxDim {
if w >= h {
nw = maxDim
nh = h * maxDim / w
} else {
nh = maxDim
nw = w * maxDim / h
}
}
resized := image.NewRGBA(image.Rect(0, 0, nw, nh))
xdraw.CatmullRom.Scale(resized, resized.Bounds(), img, b, xdraw.Over, nil)
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
return jpeg.Encode(out, resized, &jpeg.Options{Quality: 88})
}
// renderGridPages writes the paginated square-grid view under public/grid/:
// grid/index.html and grid/page/N/index.html, chunking photos into pageSize
// slices for progressive pagination. The grid is the secondary view; the
// timeline is the site root.
func (g *Generator) renderGridPages(photos []Photo) error {
total := len(photos)
totalPages := (total + g.pageSize - 1) / g.pageSize
if totalPages == 0 {
totalPages = 1
}
gridDir := filepath.Join(g.outputDir, "grid")
for i := 0; i < totalPages; i++ {
start := i * g.pageSize
end := start + g.pageSize
if end > total {
end = total
}
slice := photos[start:end]
dst := filepath.Join(gridDir, "index.html")
if i > 0 {
dir := filepath.Join(gridDir, "page", strconv.Itoa(i+1))
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
dst = filepath.Join(dir, "index.html")
}
prev := ""
next := ""
switch i {
case 0:
// no prev
case 1:
prev = "/grid/"
default:
prev = "/grid/page/" + strconv.Itoa(i) + "/"
}
if i+1 < totalPages {
next = "/grid/page/" + strconv.Itoa(i+2) + "/"
}
data := map[string]any{
"Photos": slice,
"Total": total,
"Page": i + 1,
"TotalPages": totalPages,
"PrevURL": prev,
"NextURL": next,
"BaseURL": g.baseURL,
"Year": time.Now().Year(),
"Author": g.author,
"Handle": g.handle,
"Bio": g.bio,
"BioAlt": g.bioAlt,
"Initial": g.initial(),
"FontsURL": g.fontsURL,
"SerifFamily": g.serifFamily,
"MlFamily": g.mlFamily,
}
if err := g.render("grid.html", dst, data); err != nil {
return err
}
}
return nil
}
// renderPhotoPage writes public/photo/<slug>/index.html.
func (g *Generator) renderPhotoPage(p Photo, newer, older *Photo) error {
dir := filepath.Join(g.outputDir, "photo", p.Slug)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
return g.render("photo.html", filepath.Join(dir, "index.html"), map[string]any{
"Photo": p,
"Newer": newer,
"Older": older,
"BaseURL": g.baseURL,
"Year": time.Now().Year(),
"Author": g.author,
"Handle": g.handle,
"Initial": g.initial(),
"FontsURL": g.fontsURL,
"SerifFamily": g.serifFamily,
"MlFamily": g.mlFamily,
})
}
// groupByDay collapses photos (already sorted newest first) into per-day runs,
// preserving order. Two photos land in the same group iff their dates share a
// calendar day.
func groupByDay(photos []Photo) []DayGroup {
var groups []DayGroup
for _, p := range photos {
day := p.Date.Format("2006-01-02")
if n := len(groups); n > 0 && groups[n-1].day == day {
groups[n-1].Photos = append(groups[n-1].Photos, p)
groups[n-1].Count++
continue
}
groups = append(groups, DayGroup{
day: day,
Label: day, // ISO 2006-01-02
Count: 1,
Photos: []Photo{p},
})
}
return groups
}
// chunkDayGroups packs day groups into pages, filling each page until it holds
// at least target photos, then starting a new one. A single day is never split
// across a page boundary, so page sizes vary slightly around target. Always
// returns at least one page (empty when there are no photos) so the site root
// renders.
func chunkDayGroups(groups []DayGroup, target int) [][]DayGroup {
var pages [][]DayGroup
var cur []DayGroup
count := 0
for _, grp := range groups {
cur = append(cur, grp)
count += grp.Count
if count >= target {
pages = append(pages, cur)
cur, count = nil, 0
}
}
if len(cur) > 0 {
pages = append(pages, cur)
}
if len(pages) == 0 {
pages = [][]DayGroup{nil}
}
return pages
}
// renderTimelinePages writes the day-grouped timeline as the site root:
// index.html and page/N/index.html. Photos are grouped by day, then packed
// into pages of roughly pageSize without splitting a day across pages.
func (g *Generator) renderTimelinePages(photos []Photo) error {
pages := chunkDayGroups(groupByDay(photos), g.pageSize)
totalPages := len(pages)
for i, groups := range pages {
dst := filepath.Join(g.outputDir, "index.html")
if i > 0 {
dir := filepath.Join(g.outputDir, "page", strconv.Itoa(i+1))
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
dst = filepath.Join(dir, "index.html")
}
prev := ""
next := ""
switch i {
case 0:
// no prev
case 1:
prev = "/"
default:
prev = "/page/" + strconv.Itoa(i) + "/"
}
if i+1 < totalPages {
next = "/page/" + strconv.Itoa(i+2) + "/"
}
data := map[string]any{
"Groups": groups,
"Total": len(photos),
"Page": i + 1,
"TotalPages": totalPages,
"PrevURL": prev,
"NextURL": next,
"BaseURL": g.baseURL,
"Year": time.Now().Year(),
"Author": g.author,
"Handle": g.handle,
"Bio": g.bio,
"BioAlt": g.bioAlt,
"Initial": g.initial(),
"FontsURL": g.fontsURL,
"SerifFamily": g.serifFamily,
"MlFamily": g.mlFamily,
}
if err := g.render("timeline.html", dst, data); err != nil {
return err
}
}
return nil
}
func (g *Generator) render(tmpl, dst string, data any) error {
f, err := os.Create(dst)
if err != nil {
return err
}
defer f.Close()
return g.tmpl.ExecuteTemplate(f, tmpl, data)
}
// Atom feed ---------------------------------------------------------------
type atomFeed struct {
XMLName xml.Name `xml:"feed"`
Xmlns string `xml:"xmlns,attr"`
Title string `xml:"title"`
Link atomLink `xml:"link"`
Updated string `xml:"updated"`
ID string `xml:"id"`
Entries []atomEntry `xml:"entry"`
}
type atomLink struct {
Href string `xml:"href,attr"`
Rel string `xml:"rel,attr,omitempty"`
}
type atomEntry struct {
Title string `xml:"title"`
Link atomLink `xml:"link"`
ID string `xml:"id"`
Updated string `xml:"updated"`
Summary string `xml:"summary"`
}
func (g *Generator) renderFeed(photos []Photo) error {
feed := atomFeed{
Xmlns: "http://www.w3.org/2005/Atom",
Title: "Photo Gallery",
Link: atomLink{Href: g.baseURL + "/feed.xml", Rel: "self"},
ID: g.baseURL + "/",
}
if len(photos) > 0 {
feed.Updated = photos[0].Date.Format(time.RFC3339)
}
for _, p := range photos {
url := g.baseURL + "/photo/" + p.Slug + "/"
feed.Entries = append(feed.Entries, atomEntry{
Title: p.Caption,
Link: atomLink{Href: url},
ID: url,
Updated: p.Date.Format(time.RFC3339),
Summary: p.Caption,
})
}
f, err := os.Create(filepath.Join(g.outputDir, "feed.xml"))
if err != nil {
return err
}
defer f.Close()
f.WriteString(xml.Header)
enc := xml.NewEncoder(f)
enc.Indent("", " ")
return enc.Encode(feed)
}
// copyStatic copies embedded static/ assets to public/static/.
func (g *Generator) copyStatic() error {
entries, err := assets.ReadDir("static")
if err != nil {
return err
}
for _, e := range entries {
data, err := assets.ReadFile("static/" + e.Name())
if err != nil {
return err
}
dst := filepath.Join(g.outputDir, "static", e.Name())
if err := os.WriteFile(dst, data, 0644); err != nil {
return err
}
}
return nil
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
}