Files
photog/generator.go
foxandClaude Fable 5.1 3f719359de feat(manage): add /manage pages to edit and delete photos, behind -manage
/manage lists every photo; /manage/edit/{slug} rewrites the photo's
.toml sidecar (caption, date), /manage/delete/{slug} asks for
confirmation and then removes the image, its sidecar and everything
generated from them. Builds now drop day pages of days without photos
and pager pages past the last one, so a delete or a date change leaves
nothing behind.

The pages are off by default: the routes are only registered with
-manage, so an install that does not opt in has no such code path and
answers 404. NixOS gets services.photogallery.manage.enable, which
also adds the nginx location only when on. Like /upload, the pages
carry no auth of their own and are not linked from the public site.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 08:17:17 +02:00

846 lines
22 KiB
Go
Raw Permalink 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 (
"bytes"
"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
// The two paginated views. The one chosen as -default-view is published
// at the site root, the other under /<name>/.
viewTimeline = "timeline"
viewGrid = "grid"
// Open Graph preview image, shared to WhatsApp/Telegram/etc. Kept to a
// 1200px longest side and squeezed under ogTargetBytes: WhatsApp is widely
// reported to drop the preview entirely once the image passes ~300 KB, so
// the medium rendition (which can exceed that) is not reused here.
ogMaxSize = 1200
ogTargetBytes = 250 * 1024
)
// ogQualityLadder is walked from best to worst until the encoded OG image
// fits under ogTargetBytes. The last entry is used as-is even if still over.
var ogQualityLadder = []int{85, 78, 70, 62}
// 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"
OG string // relative path under public/, e.g. "og/foo.jpg"
OGWidth int // pixel dimensions of the OG image, for og:image:width/height
OGHeight int
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
defaultView string
mu sync.Mutex
tmpl *template.Template
}
func NewGenerator(contentDir, outputDir, baseURL, author, handle, bio, bioAlt, fontsURL, serifFamily, mlFamily string, pageSize int, defaultView string) *Generator {
tmpl := template.Must(template.ParseFS(assets, "templates/*.html"))
if pageSize < 1 {
pageSize = defaultPageSize
}
if defaultView != viewGrid {
defaultView = viewTimeline
}
return &Generator{
contentDir: contentDir,
outputDir: outputDir,
baseURL: baseURL,
author: author,
handle: handle,
bio: bio,
bioAlt: bioAlt,
fontsURL: fontsURL,
serifFamily: serifFamily,
mlFamily: mlFamily,
pageSize: pageSize,
defaultView: defaultView,
tmpl: tmpl,
}
}
func (g *Generator) initial() string {
for _, r := range g.author {
return strings.ToUpper(string(r))
}
return ""
}
// viewBase returns the URL prefix a view is published under: "" for the
// default view, which owns the site root, and "/<view>" for the other one.
func (g *Generator) viewBase(view string) string {
if view == g.defaultView {
return ""
}
return "/" + view
}
// pageURL returns the URL of the 1-based page n of a view rooted at base.
func pageURL(base string, n int) string {
if n == 1 {
return base + "/"
}
return base + "/page/" + strconv.Itoa(n) + "/"
}
// 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", "og", "photo", "day", "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)
}
}
// The default view lives at the site root, so a directory named after it
// can only be left over from a run with the other -default-view. Drop it,
// or the old pages would stay reachable and go stale.
if err := os.RemoveAll(filepath.Join(g.outputDir, g.defaultView)); err != nil {
return err
}
if err := g.renderTimelinePages(photos); err != nil {
return err
}
if err := g.renderGridPages(photos); err != nil {
return err
}
if err := g.renderDayPages(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
}
// Delete removes a photo for good: its image and sidecar in the content
// directory, then everything generated from them. Pages that list the photo
// are put right by the next Build. It returns os.ErrNotExist when no photo
// has that slug.
func (g *Generator) Delete(slug string) error {
g.mu.Lock()
defer g.mu.Unlock()
photos, err := g.loadPhotos()
if err != nil {
return err
}
found := false
for _, p := range photos {
if p.Slug != slug {
continue
}
found = true
// The source goes first: if it cannot be removed, the photo stays
// fully intact rather than half deleted.
if err := os.Remove(filepath.Join(g.contentDir, filepath.Base(p.File))); err != nil {
return err
}
for _, rel := range []string{p.File, p.Thumb, p.Medium, p.OG} {
err := os.Remove(filepath.Join(g.outputDir, filepath.FromSlash(rel)))
if err != nil && !os.IsNotExist(err) {
return err
}
}
}
if !found {
return os.ErrNotExist
}
// slug is safe to join from here on: it matched a directory entry.
if err := os.Remove(filepath.Join(g.contentDir, slug+".toml")); err != nil && !os.IsNotExist(err) {
return err
}
return os.RemoveAll(filepath.Join(g.outputDir, "photo", slug))
}
// 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",
OG: "og/" + 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)
dstOG := filepath.Join(g.outputDir, p.OG)
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
}
}
if !upToDate(src, dstOG) {
if err := generateOG(src, dstOG, ogMaxSize); err != nil {
return err
}
}
// og:image:width/height need the OG image's real dimensions, and they must
// be known even when the file above was skipped as up to date. DecodeConfig
// reads only the JPEG header, so this is cheap.
if cfg, err := decodeConfigFile(dstOG); err == nil {
p.OGWidth = cfg.Width
p.OGHeight = cfg.Height
} else {
log.Printf("og dimensions %s: %v", p.Slug, err)
}
return nil
}
// decodeConfigFile reads just the image header of path and returns its config
// (dimensions, colour model) without decoding the pixels.
func decodeConfigFile(path string) (image.Config, error) {
f, err := os.Open(path)
if err != nil {
return image.Config{}, err
}
defer f.Close()
cfg, _, err := image.DecodeConfig(f)
return cfg, err
}
// 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})
}
// generateOG reads src, resizes it so its longest side is at most maxDim, and
// writes a JPEG small enough for social link previews. It walks ogQualityLadder
// from best to worst, stopping at the first quality whose output is under
// ogTargetBytes, and falls back to the lowest quality if none fits.
func generateOG(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)
var buf bytes.Buffer
for i, q := range ogQualityLadder {
buf.Reset()
if err := jpeg.Encode(&buf, resized, &jpeg.Options{Quality: q}); err != nil {
return err
}
if buf.Len() <= ogTargetBytes || i == len(ogQualityLadder)-1 {
break
}
}
return os.WriteFile(dst, buf.Bytes(), 0644)
}
// renderGridPages writes the paginated square-grid view: index.html and
// page/N/index.html under the grid's base (the site root when the grid is the
// default view, /grid/ otherwise), chunking photos into pageSize slices for
// progressive pagination.
func (g *Generator) renderGridPages(photos []Photo) error {
total := len(photos)
totalPages := (total + g.pageSize - 1) / g.pageSize
if totalPages == 0 {
totalPages = 1
}
base := g.viewBase(viewGrid)
for i := 0; i < totalPages; i++ {
start := i * g.pageSize
end := start + g.pageSize
if end > total {
end = total
}
slice := photos[start:end]
dir := filepath.Join(g.outputDir, filepath.FromSlash(pageURL(base, i+1)))
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
dst := filepath.Join(dir, "index.html")
prev := ""
next := ""
if i > 0 {
prev = pageURL(base, i)
}
if i+1 < totalPages {
next = pageURL(base, i+2)
}
data := map[string]any{
"Photos": slice,
"TimelineURL": pageURL(g.viewBase(viewTimeline), 1),
"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 g.prunePages(base, totalPages)
}
// 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: index.html and
// page/N/index.html under the timeline's base (the site root when the
// timeline is the default view). 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)
base := g.viewBase(viewTimeline)
for i, groups := range pages {
dir := filepath.Join(g.outputDir, filepath.FromSlash(pageURL(base, i+1)))
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
dst := filepath.Join(dir, "index.html")
prev := ""
next := ""
if i > 0 {
prev = pageURL(base, i)
}
if i+1 < totalPages {
next = pageURL(base, i+2)
}
data := map[string]any{
"Groups": groups,
"GridURL": pageURL(g.viewBase(viewGrid), 1),
"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 g.prunePages(base, totalPages)
}
// prunePages removes the pager pages of a view past its last one. They are
// left behind when the photo count shrinks or the views swap places, and
// would keep showing photos that are gone.
func (g *Generator) prunePages(base string, totalPages int) error {
// Page 1 is the view's own root, never a leftover: start past it even
// if a caller reports no pages at all.
for n := max(totalPages, 1) + 1; ; n++ {
dir := filepath.Join(g.outputDir, filepath.FromSlash(pageURL(base, n)))
if _, err := os.Stat(dir); err != nil {
return nil
}
if err := os.RemoveAll(dir); err != nil {
return err
}
}
}
// renderDayPages writes one page per calendar day under public/day/<ISO>/,
// holding just that day's photos. Timeline date headings link here. Pages of
// days that no longer have photos, after a delete or a date change, are
// removed.
func (g *Generator) renderDayPages(photos []Photo) error {
groups := groupByDay(photos)
live := make(map[string]bool, len(groups))
for _, grp := range groups {
live[grp.day] = true
}
dayDir := filepath.Join(g.outputDir, "day")
entries, err := os.ReadDir(dayDir)
if err != nil {
return err
}
for _, e := range entries {
if e.IsDir() && !live[e.Name()] {
if err := os.RemoveAll(filepath.Join(dayDir, e.Name())); err != nil {
return err
}
}
}
for _, grp := range groups {
dir := filepath.Join(g.outputDir, "day", grp.day)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
err := g.render("day.html", filepath.Join(dir, "index.html"), map[string]any{
"Date": grp.Label,
"TimelineURL": pageURL(g.viewBase(viewTimeline), 1),
"Count": grp.Count,
"Photos": grp.Photos,
"Total": len(photos),
"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 != 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
}
// Also serve the favicon from the site root, where browsers and
// crawlers request /favicon.ico by default without a <link> tag.
if e.Name() == "favicon.ico" {
if err := os.WriteFile(filepath.Join(g.outputDir, e.Name()), 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
}