docs/plans/2026-09-25-gallery-v1.org
3152 lines · 85160 bytes
gallery v1 implementation plan
- Global constraints
- File structure
- Task 1: Module scaffold and format registry
- Task 2: EXIF parsing
- Task 3: ImageMagick format and tool check
- Task 4: Org parsing
- Task 5: Metadata cache
- Task 6: Library model and scan
- Task 7: Filesystem watcher
- Task 8: Renderer and derivative cache
- Task 9: Web server, templates and theme
- Task 10: Page script (justified rows, keyboard, swipe)
- Task 11: Command, wiring and local run
- Task 12: Docker image and homelab deploy
- For agentic workers
- REQUIRED SUB-SKILL: use
superpowers:subagent-driven-development (recommended) or
superpowers:executing-plans to implement this plan task by task. Steps use
checkbox (
- [ ]) syntax for tracking. - Goal
- A single Go binary that serves a dark, justified-row photo gallery from a folder tree, generating resized JPEGs on request with ImageMagick.
- Architecture
libraryscansphotos/into an immutable snapshot held in an atomic pointer and rescans on filesystem events.formathides each file type behind one interface backed bymagickandexiftool.renderturns (item, width) into a cached JPEG on disk.webserves templates and images over the stdlib mux.- Tech stack
- Go 1.27,
net/http1.22+ patterns,html/template,github.com/fsnotify/fsnotify,github.com/niklasfasching/go-org,golang.org/x/sync/singleflight, ImageMagick 7 (magick), exiftool, Docker. - Spec
- ../specs/2026-09-25-gallery-design.html
Global constraints
- Module path:
gitbay.org/cmc/gallery. - All prose files are org-mode (
.org). No Markdown anywhere, includingalbum.organdabout.orgcontent files. - Photos and cache are never inside the repository.
- Allowed widths: 480, 960, 1600, 2400. Output is always JPEG, quality 82.
- Reserved album slugs:
img,info,_. - Resize command:
magick <src> -auto-orient -resize <w>x -strip -quality 82 jpg:-. - Image responses:
Cache-Control: public, max-age=31536000, immutable. - Never cropping photos: tiles use
object-fit: contain. - Code comments are terse and factual. No attribution lines in commits.
- Work on branch
v1offmain; open an MR withgitbay mr create --source v1 --target mainat the end. go test ./...andgo vet ./...pass after every task.
File structure
.gitignore go.mod, go.sum cmd/gallery/main.go flags, env fallback, wiring, HTTP server internal/format/format.go Kind, Meta, Format, Registry internal/format/exif.go exiftool JSON → Meta (pure) internal/format/magick.go Magick format, Default(), CheckTools() internal/library/library.go Library, Album, Item, Store, sorting internal/library/org.go parseOrg: keywords + body HTML internal/library/metacache.go MetaCache (meta.json) internal/library/scan.go Scan internal/library/watch.go Watch (fsnotify, debounce) internal/render/render.go Widths, Allowed, Renderer (Path, Get, Prune) internal/web/server.go New, routes, handlers internal/web/funcs.go template helpers internal/web/templates/*.html base, home, album, photo, info, error internal/web/assets/theme.css internal/web/assets/gallery.js Dockerfile, compose.yml README.org
Task 1: Module scaffold and format registry
Files:
- Create:
.gitignore,go.mod,internal/format/format.go - Test:
internal/format/format_test.go
Interfaces:
-
Produces:
type Kind int;const KindImage Kind = 0type Meta struct { Width, Height int; Taken time.Time; Camera, Lens, Aperture, Shutter string; ISO int }type Format interface { Name() string; Match(path string) bool; Metadata(path string) (Meta, error); Resize(src string, width int, dst io.Writer) error; Kind() Kind }type Registry struct;(*Registry).Register(Format);(*Registry).Lookup(path string) Format(nil when unmatched)hasExt(path string, exts []string) bool(package-private)
- Step 1: Branch and initialise the module
Start after the spec MR (spec → main) is merged, so v1 carries the spec
and this plan.
git switch main && git pull && git switch -c v1
go mod init gitbay.org/cmc/gallery
Create .gitignore:
/photos/ /cache/ /gallery .superpowers/
- Step 2: Write the failing test
internal/format/format_test.go
package format
import (
"io"
"path/filepath"
"strings"
"testing"
)
type fake struct{ name, ext string }
func (f fake) Name() string { return f.name }
func (f fake) Match(p string) bool { return strings.EqualFold(filepath.Ext(p), f.ext) }
func (fake) Metadata(string) (Meta, error) { return Meta{}, nil }
func (fake) Resize(string, int, io.Writer) error { return nil }
func (fake) Kind() Kind { return KindImage }
func TestRegistryFirstMatchWins(t *testing.T) {
var r Registry
r.Register(fake{"a", ".jpg"})
r.Register(fake{"b", ".jpg"})
r.Register(fake{"c", ".png"})
if got := r.Lookup("x/IMG.JPG"); got == nil || got.Name() != "a" {
t.Fatalf("jpg: got %v, want a", got)
}
if got := r.Lookup("x.png"); got == nil || got.Name() != "c" {
t.Fatalf("png: got %v, want c", got)
}
if got := r.Lookup("notes.txt"); got != nil {
t.Fatalf("txt: got %v, want nil", got)
}
}
func TestHasExt(t *testing.T) {
exts := []string{".jpg", ".jpeg"}
for p, want := range map[string]bool{
"a.jpg": true, "a.JPEG": true, "a.png": false, "jpg": false, "a.jpg.txt": false,
} {
if got := hasExt(p, exts); got != want {
t.Errorf("hasExt(%q) = %v, want %v", p, got, want)
}
}
}
- Step 3: Run it and confirm it fails
Run: go test ./internal/format/
Expected: FAIL, undefined: Registry, undefined: Meta, undefined: hasExt.
- Step 4: Implement
internal/format/format.go
// Package format describes the file types the gallery can show.
package format
import (
"io"
"path/filepath"
"strings"
"time"
)
type Kind int
const (
KindImage Kind = iota
)
// Meta holds display metadata. Width and Height are after EXIF orientation.
type Meta struct {
Width, Height int
Taken time.Time
Camera, Lens string
Aperture string
Shutter string
ISO int
}
type Format interface {
Name() string
Match(path string) bool
Metadata(path string) (Meta, error)
Resize(src string, width int, dst io.Writer) error
Kind() Kind
}
// Registry resolves a path to the first registered Format that matches it.
type Registry struct {
formats []Format
}
func (r *Registry) Register(f Format) {
r.formats = append(r.formats, f)
}
func (r *Registry) Lookup(path string) Format {
for _, f := range r.formats {
if f.Match(path) {
return f
}
}
return nil
}
func hasExt(path string, exts []string) bool {
ext := strings.ToLower(filepath.Ext(path))
for _, e := range exts {
if ext == e {
return true
}
}
return false
}
- Step 5: Run the tests
Run: go test ./internal/format/ && go vet ./...
Expected: PASS.
- Step 6: Commit
git add .gitignore go.mod internal/format
git commit -m "Add format registry"
Task 2: EXIF parsing
Files:
- Create:
internal/format/exif.go - Test:
internal/format/exif_test.go
Interfaces:
- Consumes:
Meta(Task 1) - Produces:
parseExif(data []byte) (Meta, error)(package-private), input is the output ofexiftool -json -nfor one file. - Step 1: Write the failing test
internal/format/exif_test.go
package format
import (
"testing"
"time"
)
func TestParseExifFull(t *testing.T) {
data := []byte(`[{
"SourceFile": "a.jpg",
"ImageWidth": 6240, "ImageHeight": 4160, "Orientation": 1,
"DateTimeOriginal": "2025:03:14 23:10:05",
"Make": "FUJIFILM", "Model": "X-T5", "LensModel": "XF23mmF2 R WR",
"FNumber": 2, "ExposureTime": 0.008, "ISO": 1600
}]`)
m, err := parseExif(data)
if err != nil {
t.Fatal(err)
}
want := Meta{
Width: 6240, Height: 4160,
Taken: time.Date(2025, 3, 14, 23, 10, 5, 0, time.UTC),
Camera: "FUJIFILM X-T5",
Lens: "XF23mmF2 R WR",
Aperture: "f/2",
Shutter: "1/125",
ISO: 1600,
}
if m != want {
t.Fatalf("got %+v\nwant %+v", m, want)
}
}
func TestParseExifRotatedSwapsDimensions(t *testing.T) {
for _, o := range []string{"5", "6", "7", "8"} {
m, err := parseExif([]byte(`[{"ImageWidth": 300, "ImageHeight": 200, "Orientation": ` + o + `}]`))
if err != nil {
t.Fatal(err)
}
if m.Width != 200 || m.Height != 300 {
t.Errorf("orientation %s: got %dx%d, want 200x300", o, m.Width, m.Height)
}
}
}
func TestParseExifFallbacks(t *testing.T) {
m, err := parseExif([]byte(`[{
"ImageWidth": 10, "ImageHeight": 10,
"CreateDate": "2024:01:02 03:04:05+01:00",
"Make": "Canon", "Model": "Canon EOS R6",
"FNumber": 5.6, "ExposureTime": 2
}]`))
if err != nil {
t.Fatal(err)
}
if want := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC); !m.Taken.Equal(want) {
t.Errorf("Taken = %v, want %v", m.Taken, want)
}
if m.Camera != "Canon EOS R6" {
t.Errorf("Camera = %q", m.Camera)
}
if m.Aperture != "f/5.6" || m.Shutter != "2s" {
t.Errorf("Aperture %q Shutter %q", m.Aperture, m.Shutter)
}
}
func TestParseExifMinimal(t *testing.T) {
m, err := parseExif([]byte(`[{"ImageWidth": 120, "ImageHeight": 80}]`))
if err != nil {
t.Fatal(err)
}
if (m != Meta{Width: 120, Height: 80}) {
t.Fatalf("got %+v", m)
}
}
func TestParseExifErrors(t *testing.T) {
for _, in := range []string{`not json`, `[]`, `[{}, {}]`, `[{"ImageWidth": 10}]`} {
if _, err := parseExif([]byte(in)); err == nil {
t.Errorf("%s: want error", in)
}
}
}
- Step 2: Run it and confirm it fails
Run: go test ./internal/format/ -run Exif
Expected: FAIL, undefined: parseExif.
- Step 3: Implement
internal/format/exif.go
package format
import (
"encoding/json"
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
)
type exifRecord struct {
ImageWidth int
ImageHeight int
Orientation int
DateTimeOriginal string
CreateDate string
Make string
Model string
LensModel string
FNumber float64
ExposureTime float64
ISO int
}
// parseExif reads `exiftool -json -n` output for a single file.
func parseExif(data []byte) (Meta, error) {
var recs []exifRecord
if err := json.Unmarshal(data, &recs); err != nil {
return Meta{}, fmt.Errorf("exiftool output: %w", err)
}
if len(recs) != 1 {
return Meta{}, fmt.Errorf("exiftool output: %d records, want 1", len(recs))
}
e := recs[0]
if e.ImageWidth == 0 || e.ImageHeight == 0 {
return Meta{}, errors.New("exiftool output: no image dimensions")
}
m := Meta{Width: e.ImageWidth, Height: e.ImageHeight, Lens: e.LensModel, ISO: e.ISO}
if e.Orientation >= 5 && e.Orientation <= 8 {
m.Width, m.Height = m.Height, m.Width
}
m.Taken = exifTime(e.DateTimeOriginal)
if m.Taken.IsZero() {
m.Taken = exifTime(e.CreateDate)
}
m.Camera = camera(e.Make, e.Model)
if e.FNumber > 0 {
m.Aperture = "f/" + strconv.FormatFloat(e.FNumber, 'f', -1, 64)
}
m.Shutter = shutter(e.ExposureTime)
return m, nil
}
// exifTime parses "YYYY:MM:DD HH:MM:SS", ignoring any trailing subseconds or zone.
func exifTime(s string) time.Time {
if len(s) < 19 {
return time.Time{}
}
t, err := time.Parse("2006:01:02 15:04:05", s[:19])
if err != nil {
return time.Time{}
}
return t
}
func camera(maker, model string) string {
maker, model = strings.TrimSpace(maker), strings.TrimSpace(model)
if maker == "" {
return model
}
if model == "" {
return maker
}
brand := strings.ToLower(strings.Fields(maker)[0])
if strings.Contains(strings.ToLower(model), brand) {
return model
}
return maker + " " + model
}
func shutter(sec float64) string {
switch {
case sec <= 0:
return ""
case sec >= 1:
return strconv.FormatFloat(sec, 'f', -1, 64) + "s"
default:
return "1/" + strconv.Itoa(int(math.Round(1/sec)))
}
}
- Step 4: Run the tests
Run: go test ./internal/format/ && go vet ./...
Expected: PASS.
- Step 5: Commit
git add internal/format
git commit -m "Parse exiftool metadata"
Task 3: ImageMagick format and tool check
Files:
- Create:
internal/format/magick.go - Test:
internal/format/magick_test.go
Interfaces:
- Consumes:
Format,Registry,hasExt(Task 1);parseExif(Task 2) -
Produces:
type Magick struct;NewMagick(name string, exts ...string) *MagickDefault() *Registryregisteringjpeg(.jpg,.jpeg) thenpng(.png)CheckTools() error, error text<tool> not found on PATH
- Step 1: Write the failing test
internal/format/magick_test.go
These run the real tools and skip when either is missing.
package format
import (
"bytes"
"fmt"
"image/jpeg"
"os/exec"
"path/filepath"
"testing"
"time"
)
func requireTools(t *testing.T) {
t.Helper()
if err := CheckTools(); err != nil {
t.Skip(err)
}
}
func makeImage(t *testing.T, name string, w, h int) string {
t.Helper()
p := filepath.Join(t.TempDir(), name)
run(t, "magick", "-size", fmt.Sprintf("%dx%d", w, h), "xc:gray", p)
return p
}
func run(t *testing.T, name string, args ...string) {
t.Helper()
if out, err := exec.Command(name, args...).CombinedOutput(); err != nil {
t.Fatalf("%s %v: %v\n%s", name, args, err, out)
}
}
func resizedSize(t *testing.T, f Format, src string, w int) (int, int) {
t.Helper()
var buf bytes.Buffer
if err := f.Resize(src, w, &buf); err != nil {
t.Fatal(err)
}
cfg, err := jpeg.DecodeConfig(&buf)
if err != nil {
t.Fatalf("output is not JPEG: %v", err)
}
return cfg.Width, cfg.Height
}
func TestMagickJPEG(t *testing.T) {
requireTools(t)
p := makeImage(t, "a.jpg", 300, 200)
run(t, "exiftool", "-overwrite_original", "-DateTimeOriginal=2025:03:14 23:10:05", "-Model=TestCam", p)
f := NewMagick("jpeg", ".jpg", ".jpeg")
m, err := f.Metadata(p)
if err != nil {
t.Fatal(err)
}
if m.Width != 300 || m.Height != 200 || m.Camera != "TestCam" {
t.Fatalf("got %+v", m)
}
if want := time.Date(2025, 3, 14, 23, 10, 5, 0, time.UTC); !m.Taken.Equal(want) {
t.Fatalf("Taken = %v", m.Taken)
}
if w, h := resizedSize(t, f, p, 150); w != 150 || h != 100 {
t.Fatalf("resized to %dx%d, want 150x100", w, h)
}
}
func TestMagickOrientation(t *testing.T) {
requireTools(t)
p := makeImage(t, "r.jpg", 300, 200)
run(t, "exiftool", "-overwrite_original", "-n", "-Orientation=6", p)
f := NewMagick("jpeg", ".jpg")
m, err := f.Metadata(p)
if err != nil {
t.Fatal(err)
}
if m.Width != 200 || m.Height != 300 {
t.Fatalf("metadata %dx%d, want 200x300", m.Width, m.Height)
}
if w, h := resizedSize(t, f, p, 100); w != 100 || h != 150 {
t.Fatalf("resized to %dx%d, want 100x150", w, h)
}
}
func TestMagickPNG(t *testing.T) {
requireTools(t)
p := makeImage(t, "a.png", 120, 80)
f := NewMagick("png", ".png")
m, err := f.Metadata(p)
if err != nil {
t.Fatal(err)
}
if m.Width != 120 || m.Height != 80 {
t.Fatalf("got %+v", m)
}
if w, h := resizedSize(t, f, p, 60); w != 60 || h != 40 {
t.Fatalf("resized to %dx%d, want 60x40", w, h)
}
}
func TestMagickResizeError(t *testing.T) {
requireTools(t)
var buf bytes.Buffer
if err := NewMagick("jpeg", ".jpg").Resize(filepath.Join(t.TempDir(), "missing.jpg"), 100, &buf); err == nil {
t.Fatal("want error")
}
}
func TestDefaultRegistry(t *testing.T) {
r := Default()
for path, want := range map[string]string{"a.JPG": "jpeg", "a.jpeg": "jpeg", "a.png": "png"} {
if f := r.Lookup(path); f == nil || f.Name() != want {
t.Errorf("%s: got %v, want %s", path, f, want)
}
}
if r.Lookup("a.heic") != nil {
t.Error("heic should not match in v1")
}
}
- Step 2: Run it and confirm it fails
Run: go test ./internal/format/
Expected: FAIL, undefined: CheckTools, undefined: NewMagick, undefined: Default.
- Step 3: Implement
internal/format/magick.go
package format
import (
"bytes"
"fmt"
"io"
"os/exec"
"strconv"
"strings"
)
// Magick reads metadata with exiftool and resizes with ImageMagick.
type Magick struct {
name string
exts []string
}
func NewMagick(name string, exts ...string) *Magick {
return &Magick{name: name, exts: exts}
}
func (m *Magick) Name() string { return m.name }
func (m *Magick) Match(path string) bool { return hasExt(path, m.exts) }
func (m *Magick) Kind() Kind { return KindImage }
func (m *Magick) Metadata(path string) (Meta, error) {
out, err := exec.Command("exiftool", "-json", "-n",
"-ImageWidth", "-ImageHeight", "-Orientation",
"-DateTimeOriginal", "-CreateDate",
"-Make", "-Model", "-LensModel",
"-FNumber", "-ExposureTime", "-ISO",
path).Output()
if err != nil {
return Meta{}, fmt.Errorf("exiftool %s: %w", path, err)
}
return parseExif(out)
}
func (m *Magick) Resize(src string, width int, dst io.Writer) error {
cmd := exec.Command("magick", src,
"-auto-orient", "-resize", strconv.Itoa(width)+"x",
"-strip", "-quality", "82", "jpg:-")
var stderr bytes.Buffer
cmd.Stdout = dst
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("magick %s: %w: %s", src, err, strings.TrimSpace(stderr.String()))
}
return nil
}
// Default returns the formats supported in v1.
func Default() *Registry {
r := &Registry{}
r.Register(NewMagick("jpeg", ".jpg", ".jpeg"))
r.Register(NewMagick("png", ".png"))
return r
}
// CheckTools reports the first required external tool missing from PATH.
func CheckTools() error {
for _, tool := range []string{"magick", "exiftool"} {
if _, err := exec.LookPath(tool); err != nil {
return fmt.Errorf("%s not found on PATH", tool)
}
}
return nil
}
- Step 4: Run the tests
Run: go test -v ./internal/format/ && go vet ./...
Expected: PASS, with the TestMagick* tests reported as run (not skipped) on
a machine with magick and exiftool.
- Step 5: Commit
git add internal/format
git commit -m "Add ImageMagick-backed JPEG and PNG formats"
Task 4: Org parsing
Files:
- Create:
internal/library/org.go - Test:
internal/library/org_test.go
Interfaces:
- Produces:
parseOrg(src []byte, path string) (map[string]string, template.HTML, error)(package-private). Keys are lower-cased keyword names from#+KEY: valuelines; the remaining text is rendered to HTML with go-org; empty body gives"". - Step 1: Add the dependency
go get github.com/niklasfasching/go-org@latest
- Step 2: Write the failing test
internal/library/org_test.go
package library
import (
"strings"
"testing"
)
func TestParseOrg(t *testing.T) {
src := "#+title: Tokyo: nights\n#+COVER: a.jpg\n\n#+begin_quote\nA /quiet/ week.\n#+end_quote\n"
kw, html, err := parseOrg([]byte(src), "album.org")
if err != nil {
t.Fatal(err)
}
if len(kw) != 2 || kw["title"] != "Tokyo: nights" || kw["cover"] != "a.jpg" {
t.Fatalf("keywords = %v", kw)
}
for _, want := range []string{"<blockquote>", "<em>quiet</em>"} {
if !strings.Contains(string(html), want) {
t.Errorf("html missing %q:\n%s", want, html)
}
}
}
func TestParseOrgKeywordsOnly(t *testing.T) {
kw, html, err := parseOrg([]byte("#+title: x\n\n"), "album.org")
if err != nil {
t.Fatal(err)
}
if kw["title"] != "x" || html != "" {
t.Fatalf("kw=%v html=%q", kw, html)
}
}
- Step 3: Run it and confirm it fails
Run: go test ./internal/library/
Expected: FAIL, undefined: parseOrg.
- Step 4: Implement
internal/library/org.go
package library
import (
"html/template"
"strings"
"github.com/niklasfasching/go-org/org"
)
// parseOrg splits "#+KEY: value" lines out as lower-cased keywords and
// renders the rest of the document to HTML.
func parseOrg(src []byte, path string) (map[string]string, template.HTML, error) {
kw := map[string]string{}
var body []string
for _, line := range strings.Split(string(src), "\n") {
t := strings.TrimSpace(line)
if rest, ok := strings.CutPrefix(t, "#+"); ok {
if k, v, ok := strings.Cut(rest, ":"); ok && k != "" && !strings.ContainsAny(k, " \t") {
kw[strings.ToLower(k)] = strings.TrimSpace(v)
continue
}
}
body = append(body, line)
}
text := strings.TrimSpace(strings.Join(body, "\n"))
if text == "" {
return kw, "", nil
}
out, err := org.New().Parse(strings.NewReader(text), path).Write(org.NewHTMLWriter())
if err != nil {
return nil, "", err
}
return kw, template.HTML(out), nil
}
- Step 5: Run the tests
Run: go mod tidy && go test ./internal/library/ && go vet ./...
Expected: PASS.
- Step 6: Commit
git add go.mod go.sum internal/library
git commit -m "Parse org keywords and body"
Task 5: Metadata cache
Files:
- Create:
internal/library/metacache.go - Test:
internal/library/metacache_test.go
Interfaces:
- Consumes:
format.Meta(Task 1) -
Produces:
type MetaCache struct;OpenMetaCache(path string) (*MetaCache, error)(*MetaCache).get(rel string, fi fs.FileInfo) (format.Meta, bool)(*MetaCache).put(rel string, fi fs.FileInfo, m format.Meta)(*MetaCache).save(keep map[string]bool) errordrops entries whose key is not inkeep, then writes atomically.
- Step 1: Write the failing test
internal/library/metacache_test.go
package library
import (
"io/fs"
"os"
"path/filepath"
"testing"
"time"
"gitbay.org/cmc/gallery/internal/format"
)
func statFile(t *testing.T, body string) (string, fs.FileInfo) {
t.Helper()
p := filepath.Join(t.TempDir(), "a.jpg")
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
fi, err := os.Stat(p)
if err != nil {
t.Fatal(err)
}
return p, fi
}
func TestMetaCacheRoundTrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "sub", "meta.json")
c, err := OpenMetaCache(path)
if err != nil {
t.Fatal(err)
}
_, fi := statFile(t, "x")
m := format.Meta{Width: 3, Height: 2, Camera: "X-T5", Taken: time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC)}
c.put("tokyo/a.jpg", fi, m)
c.put("gone/b.jpg", fi, m)
if err := c.save(map[string]bool{"tokyo/a.jpg": true}); err != nil {
t.Fatal(err)
}
c2, err := OpenMetaCache(path)
if err != nil {
t.Fatal(err)
}
got, ok := c2.get("tokyo/a.jpg", fi)
if !ok || !got.Taken.Equal(m.Taken) || got.Camera != "X-T5" || got.Width != 3 {
t.Fatalf("get = %+v, %v", got, ok)
}
if _, ok := c2.get("gone/b.jpg", fi); ok {
t.Fatal("entry outside keep survived save")
}
}
func TestMetaCacheMissOnChange(t *testing.T) {
c, _ := OpenMetaCache(filepath.Join(t.TempDir(), "meta.json"))
p, fi := statFile(t, "x")
c.put("a.jpg", fi, format.Meta{Width: 1, Height: 1})
if err := os.WriteFile(p, []byte("longer"), 0o644); err != nil {
t.Fatal(err)
}
fi2, _ := os.Stat(p)
if _, ok := c.get("a.jpg", fi2); ok {
t.Fatal("size change should miss")
}
later := fi.ModTime().Add(time.Hour)
os.WriteFile(p, []byte("x"), 0o644)
os.Chtimes(p, later, later)
fi3, _ := os.Stat(p)
if _, ok := c.get("a.jpg", fi3); ok {
t.Fatal("mtime change should miss")
}
}
func TestMetaCacheCorruptStartsEmpty(t *testing.T) {
path := filepath.Join(t.TempDir(), "meta.json")
os.WriteFile(path, []byte("{nope"), 0o644)
c, err := OpenMetaCache(path)
if err != nil {
t.Fatal(err)
}
if len(c.entries) != 0 {
t.Fatalf("entries = %v", c.entries)
}
}
- Step 2: Run it and confirm it fails
Run: go test ./internal/library/ -run MetaCache
Expected: FAIL, undefined: OpenMetaCache.
- Step 3: Implement
internal/library/metacache.go
package library
import (
"encoding/json"
"errors"
"io/fs"
"log"
"os"
"path/filepath"
"gitbay.org/cmc/gallery/internal/format"
)
type metaEntry struct {
ModTime int64 `json:"mtime"`
Size int64 `json:"size"`
Meta format.Meta `json:"meta"`
}
// MetaCache persists extracted metadata keyed by album-relative path.
type MetaCache struct {
path string
entries map[string]metaEntry
}
func OpenMetaCache(path string) (*MetaCache, error) {
c := &MetaCache{path: path, entries: map[string]metaEntry{}}
data, err := os.ReadFile(path)
if errors.Is(err, fs.ErrNotExist) {
return c, nil
}
if err != nil {
return nil, err
}
if err := json.Unmarshal(data, &c.entries); err != nil {
log.Printf("meta cache %s unreadable, starting empty: %v", path, err)
c.entries = map[string]metaEntry{}
}
return c, nil
}
func (c *MetaCache) get(rel string, fi fs.FileInfo) (format.Meta, bool) {
e, ok := c.entries[rel]
if !ok || e.ModTime != fi.ModTime().UnixNano() || e.Size != fi.Size() {
return format.Meta{}, false
}
return e.Meta, true
}
func (c *MetaCache) put(rel string, fi fs.FileInfo, m format.Meta) {
c.entries[rel] = metaEntry{ModTime: fi.ModTime().UnixNano(), Size: fi.Size(), Meta: m}
}
func (c *MetaCache) save(keep map[string]bool) error {
for k := range c.entries {
if !keep[k] {
delete(c.entries, k)
}
}
data, err := json.Marshal(c.entries)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil {
return err
}
tmp := c.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
return os.Rename(tmp, c.path)
}
- Step 4: Run the tests
Run: go test ./internal/library/ && go vet ./...
Expected: PASS.
- Step 5: Commit
git add internal/library
git commit -m "Add metadata cache"
Task 6: Library model and scan
Files:
- Create:
internal/library/library.go,internal/library/scan.go - Test:
internal/library/scan_test.go
Interfaces:
- Consumes:
format.Registry,format.Format,format.Meta(Tasks 1-3);parseOrg(Task 4);MetaCache(Task 5) -
Produces:
type Library struct { Albums []*Album; About template.HTML };(*Library).Album(slug string) *Album(nil if absent)type Album struct { Slug, Title, Date string; Description template.HTML; Order int; HasOrder bool; Cover *Item; Items []*Item }(*Album).Item(name string) (int, *Item)(-1, nilif absent);(*Album).Year() stringtype Item struct { Album, Name, Path string; ModTime time.Time; Size int64; Meta format.Meta; Format format.Format }type Store struct;(*Store).Load() *Library;(*Store).Set(*Library)Scan(root string, reg *format.Registry, cache *MetaCache) (*Library, error)
- Step 1: Write the failing test
internal/library/scan_test.go
package library
import (
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitbay.org/cmc/gallery/internal/format"
)
type fakeFormat struct {
meta map[string]format.Meta // by base name
fail map[string]bool
calls int
}
func (f *fakeFormat) Name() string { return "fake" }
func (f *fakeFormat) Match(p string) bool { return strings.EqualFold(filepath.Ext(p), ".jpg") }
func (f *fakeFormat) Kind() format.Kind { return format.KindImage }
func (f *fakeFormat) Resize(string, int, io.Writer) error { return nil }
func (f *fakeFormat) Metadata(p string) (format.Meta, error) {
f.calls++
name := filepath.Base(p)
if f.fail[name] {
return format.Meta{}, errors.New("bad file")
}
if m, ok := f.meta[name]; ok {
return m, nil
}
return format.Meta{Width: 300, Height: 200}, nil
}
func day(n int) time.Time { return time.Date(2025, 3, n, 0, 0, 0, 0, time.UTC) }
func writeTree(t *testing.T, files map[string]string) string {
t.Helper()
root := t.TempDir()
for rel, body := range files {
p := filepath.Join(root, rel)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
return root
}
func scanWith(t *testing.T, root string, f *fakeFormat, cache *MetaCache) *Library {
t.Helper()
if cache == nil {
var err error
if cache, err = OpenMetaCache(filepath.Join(t.TempDir(), "meta.json")); err != nil {
t.Fatal(err)
}
}
reg := &format.Registry{}
reg.Register(f)
lib, err := Scan(root, reg, cache)
if err != nil {
t.Fatal(err)
}
return lib
}
func names(items []*Item) string {
var s []string
for _, it := range items {
s = append(s, it.Name)
}
return strings.Join(s, ",")
}
func slugs(as []*Album) string {
var s []string
for _, a := range as {
s = append(s, a.Slug)
}
return strings.Join(s, ",")
}
func TestScanSkipsAndSorts(t *testing.T) {
root := writeTree(t, map[string]string{
"tokyo/b.jpg": "x",
"tokyo/a.jpg": "x",
"tokyo/.hidden.jpg": "x",
"tokyo/notes.txt": "x",
"tokyo/sub/c.jpg": "x",
".git/x.jpg": "x",
"img/x.jpg": "x",
"info/x.jpg": "x",
"_/x.jpg": "x",
"empty/notes.txt": "x",
"loose.jpg": "x",
})
f := &fakeFormat{meta: map[string]format.Meta{
"a.jpg": {Width: 300, Height: 200, Taken: day(2)},
"b.jpg": {Width: 300, Height: 200, Taken: day(1)},
}}
lib := scanWith(t, root, f, nil)
if got := slugs(lib.Albums); got != "tokyo" {
t.Fatalf("albums = %s", got)
}
a := lib.Album("tokyo")
if got := names(a.Items); got != "b.jpg,a.jpg" {
t.Fatalf("items = %s", got)
}
if a.Title != "tokyo" || a.Cover != a.Items[0] || a.Year() != "2025" {
t.Fatalf("title %q cover %v year %q", a.Title, a.Cover, a.Year())
}
if i, it := a.Item("a.jpg"); i != 1 || it.Path != filepath.Join(root, "tokyo", "a.jpg") {
t.Fatalf("Item(a.jpg) = %d, %+v", i, it)
}
if i, it := a.Item("nope.jpg"); i != -1 || it != nil {
t.Fatal("Item(nope.jpg) should be absent")
}
if lib.Album("nope") != nil {
t.Fatal("Album(nope) should be nil")
}
}
func TestScanAlbumOrg(t *testing.T) {
root := writeTree(t, map[string]string{
"tokyo/album.org": "#+title: Tokyo\n#+date: 2024-11\n#+cover: b.jpg\n#+sort: name\n\nA /quiet/ week.\n",
"tokyo/b.jpg": "x",
"tokyo/a.jpg": "x",
})
f := &fakeFormat{meta: map[string]format.Meta{
"a.jpg": {Width: 1, Height: 1, Taken: day(2)},
"b.jpg": {Width: 1, Height: 1, Taken: day(1)},
}}
a := scanWith(t, root, f, nil).Album("tokyo")
if a.Title != "Tokyo" || a.Date != "2024-11" || a.Year() != "2024" {
t.Fatalf("title %q date %q year %q", a.Title, a.Date, a.Year())
}
if got := names(a.Items); got != "a.jpg,b.jpg" {
t.Fatalf("sort name: items = %s", got)
}
if a.Cover.Name != "b.jpg" {
t.Fatalf("cover = %s", a.Cover.Name)
}
if !strings.Contains(string(a.Description), "<em>quiet</em>") {
t.Fatalf("description = %s", a.Description)
}
}
func TestScanAlbumOrdering(t *testing.T) {
root := writeTree(t, map[string]string{
"a/album.org": "#+order: 2\n",
"a/x.jpg": "x",
"b/album.org": "#+order: 1\n",
"b/x.jpg": "x",
"c/album.org": "#+date: 2024\n",
"c/x.jpg": "x",
"d/album.org": "#+date: 2025-06\n",
"d/x.jpg": "x",
"e/x.jpg": "x", // no album.org; newest item taken is day(1) of 2025-03
})
f := &fakeFormat{meta: map[string]format.Meta{"x.jpg": {Width: 1, Height: 1, Taken: day(1)}}}
if got := slugs(scanWith(t, root, f, nil).Albums); got != "b,a,d,e,c" {
t.Fatalf("albums = %s", got)
}
}
func TestScanTakenFallsBackToModTime(t *testing.T) {
root := writeTree(t, map[string]string{"tokyo/a.jpg": "x"})
mt := time.Date(2023, 5, 6, 7, 8, 9, 0, time.UTC)
os.Chtimes(filepath.Join(root, "tokyo", "a.jpg"), mt, mt)
a := scanWith(t, root, &fakeFormat{}, nil).Album("tokyo")
if !a.Items[0].Meta.Taken.Equal(mt) {
t.Fatalf("Taken = %v, want %v", a.Items[0].Meta.Taken, mt)
}
}
func TestScanSkipsFailedMetadata(t *testing.T) {
root := writeTree(t, map[string]string{"tokyo/a.jpg": "x", "tokyo/bad.jpg": "x"})
a := scanWith(t, root, &fakeFormat{fail: map[string]bool{"bad.jpg": true}}, nil).Album("tokyo")
if got := names(a.Items); got != "a.jpg" {
t.Fatalf("items = %s", got)
}
}
func TestScanUsesMetaCache(t *testing.T) {
root := writeTree(t, map[string]string{"tokyo/a.jpg": "x", "tokyo/b.jpg": "x"})
cache, _ := OpenMetaCache(filepath.Join(t.TempDir(), "meta.json"))
f := &fakeFormat{}
scanWith(t, root, f, cache)
scanWith(t, root, f, cache)
if f.calls != 2 {
t.Fatalf("calls after two scans = %d, want 2", f.calls)
}
later := time.Now().Add(time.Hour)
os.Chtimes(filepath.Join(root, "tokyo", "a.jpg"), later, later)
scanWith(t, root, f, cache)
if f.calls != 3 {
t.Fatalf("calls after touch = %d, want 3", f.calls)
}
}
func TestScanAbout(t *testing.T) {
root := writeTree(t, map[string]string{"about.org": "Hello /there/.\n", "tokyo/a.jpg": "x"})
lib := scanWith(t, root, &fakeFormat{}, nil)
if !strings.Contains(string(lib.About), "<em>there</em>") {
t.Fatalf("About = %s", lib.About)
}
root2 := writeTree(t, map[string]string{"tokyo/a.jpg": "x"})
if lib := scanWith(t, root2, &fakeFormat{}, nil); lib.About != "" {
t.Fatalf("About without file = %q", lib.About)
}
}
func TestStore(t *testing.T) {
var s Store
if s.Load() != nil {
t.Fatal("empty store should load nil")
}
lib := &Library{}
s.Set(lib)
if s.Load() != lib {
t.Fatal("Load after Set")
}
}
- Step 2: Run it and confirm it fails
Run: go test ./internal/library/
Expected: FAIL, undefined: Scan, undefined: Library, undefined: Store.
- Step 3: Implement
internal/library/library.go
// Package library turns the photo folder tree into albums and items.
package library
import (
"html/template"
"sort"
"strconv"
"sync/atomic"
"time"
"gitbay.org/cmc/gallery/internal/format"
)
type Library struct {
Albums []*Album
About template.HTML
bySlug map[string]*Album
}
func (l *Library) Album(slug string) *Album {
return l.bySlug[slug]
}
type Album struct {
Slug string
Title string
Date string
Description template.HTML
Order int
HasOrder bool
Cover *Item
Items []*Item
byName map[string]int
}
func (a *Album) Item(name string) (int, *Item) {
i, ok := a.byName[name]
if !ok {
return -1, nil
}
return i, a.Items[i]
}
// Year is the album's #+date year, else the cover's taken year.
func (a *Album) Year() string {
if len(a.Date) >= 4 {
return a.Date[:4]
}
if a.Cover != nil && !a.Cover.Meta.Taken.IsZero() {
return strconv.Itoa(a.Cover.Meta.Taken.Year())
}
return ""
}
func (a *Album) sortTime() time.Time {
for _, layout := range []string{"2006-01-02", "2006-01", "2006"} {
if t, err := time.Parse(layout, a.Date); err == nil {
return t
}
}
var newest time.Time
for _, it := range a.Items {
if it.Meta.Taken.After(newest) {
newest = it.Meta.Taken
}
}
return newest
}
type Item struct {
Album string
Name string
Path string
ModTime time.Time
Size int64
Meta format.Meta
Format format.Format
}
// Store holds the current Library snapshot.
type Store struct {
p atomic.Pointer[Library]
}
func (s *Store) Load() *Library { return s.p.Load() }
func (s *Store) Set(l *Library) { s.p.Store(l) }
// sortAlbums puts albums with #+order first (ascending), then the rest newest first.
func sortAlbums(as []*Album) {
sort.SliceStable(as, func(i, j int) bool {
a, b := as[i], as[j]
if a.HasOrder != b.HasOrder {
return a.HasOrder
}
if a.HasOrder && a.Order != b.Order {
return a.Order < b.Order
}
if !a.HasOrder {
if ta, tb := a.sortTime(), b.sortTime(); !ta.Equal(tb) {
return ta.After(tb)
}
}
return a.Slug < b.Slug
})
}
func sortItems(items []*Item, by string) {
sort.SliceStable(items, func(i, j int) bool {
a, b := items[i], items[j]
if by == "taken" && !a.Meta.Taken.Equal(b.Meta.Taken) {
return a.Meta.Taken.Before(b.Meta.Taken)
}
return a.Name < b.Name
})
}
- Step 4: Implement
internal/library/scan.go
package library
import (
"log"
"os"
"path/filepath"
"strconv"
"strings"
"gitbay.org/cmc/gallery/internal/format"
)
// Slugs that would collide with fixed routes.
var reserved = map[string]bool{"img": true, "info": true, "_": true}
// Scan reads every album under root. Unreadable albums and files are logged and skipped.
func Scan(root string, reg *format.Registry, cache *MetaCache) (*Library, error) {
entries, err := os.ReadDir(root)
if err != nil {
return nil, err
}
lib := &Library{bySlug: map[string]*Album{}}
keep := map[string]bool{}
for _, e := range entries {
name := e.Name()
if !e.IsDir() || strings.HasPrefix(name, ".") {
continue
}
if reserved[name] {
log.Printf("skipping album %q: reserved name", name)
continue
}
a, err := scanAlbum(root, name, reg, cache, keep)
if err != nil {
log.Printf("album %s: %v", name, err)
continue
}
if len(a.Items) == 0 {
continue
}
lib.Albums = append(lib.Albums, a)
lib.bySlug[name] = a
}
sortAlbums(lib.Albums)
if src, err := os.ReadFile(filepath.Join(root, "about.org")); err == nil {
if _, html, err := parseOrg(src, "about.org"); err != nil {
log.Printf("about.org: %v", err)
} else {
lib.About = html
}
}
if err := cache.save(keep); err != nil {
log.Printf("meta cache: %v", err)
}
return lib, nil
}
func scanAlbum(root, slug string, reg *format.Registry, cache *MetaCache, keep map[string]bool) (*Album, error) {
dir := filepath.Join(root, slug)
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
a := &Album{Slug: slug, Title: slug, byName: map[string]int{}}
sortBy, cover := "taken", ""
if src, err := os.ReadFile(filepath.Join(dir, "album.org")); err == nil {
kw, desc, err := parseOrg(src, filepath.Join(slug, "album.org"))
if err != nil {
log.Printf("%s/album.org: %v", slug, err)
} else {
if v := kw["title"]; v != "" {
a.Title = v
}
a.Date, a.Description, cover = kw["date"], desc, kw["cover"]
if v := kw["order"]; v != "" {
if n, err := strconv.Atoi(v); err == nil {
a.Order, a.HasOrder = n, true
} else {
log.Printf("%s/album.org: bad order %q", slug, v)
}
}
switch v := kw["sort"]; v {
case "", "taken":
case "name":
sortBy = v
default:
log.Printf("%s/album.org: bad sort %q", slug, v)
}
}
}
for _, e := range entries {
name := e.Name()
if e.IsDir() || strings.HasPrefix(name, ".") {
continue
}
path := filepath.Join(dir, name)
f := reg.Lookup(path)
if f == nil {
continue
}
fi, err := e.Info()
if err != nil {
log.Printf("%s/%s: %v", slug, name, err)
continue
}
rel := slug + "/" + name
m, ok := cache.get(rel, fi)
if !ok {
if m, err = f.Metadata(path); err != nil {
log.Printf("%s: %v", rel, err)
continue
}
cache.put(rel, fi, m)
}
keep[rel] = true
if m.Taken.IsZero() {
m.Taken = fi.ModTime()
}
a.Items = append(a.Items, &Item{
Album: slug, Name: name, Path: path,
ModTime: fi.ModTime(), Size: fi.Size(),
Meta: m, Format: f,
})
}
sortItems(a.Items, sortBy)
for i, it := range a.Items {
a.byName[it.Name] = i
}
if i, ok := a.byName[cover]; ok {
a.Cover = a.Items[i]
} else if len(a.Items) > 0 {
if cover != "" {
log.Printf("%s/album.org: cover %q not found", slug, cover)
}
a.Cover = a.Items[0]
}
return a, nil
}
- Step 5: Run the tests
Run: go test ./internal/library/ && go vet ./...
Expected: PASS.
- Step 6: Commit
git add internal/library
git commit -m "Scan photo folders into albums"
Task 7: Filesystem watcher
Files:
- Create:
internal/library/watch.go - Test:
internal/library/watch_test.go
Interfaces:
- Produces:
Watch(ctx context.Context, root string, debounce time.Duration, onChange func()) error. Watchesrootand its non-hidden subdirectories, adds newly created directories, and callsonChangeonce per quiet period of lengthdebounce. Returns after setup; stops whenctxis done. - Step 1: Add the dependency
go get github.com/fsnotify/fsnotify@latest
- Step 2: Write the failing test
internal/library/watch_test.go
package library
import (
"context"
"os"
"path/filepath"
"testing"
"time"
)
func expectCall(t *testing.T, ch <-chan struct{}, what string) {
t.Helper()
select {
case <-ch:
case <-time.After(3 * time.Second):
t.Fatalf("no onChange after %s", what)
}
}
func TestWatchDebouncesAndFollowsNewDirs(t *testing.T) {
root := writeTree(t, map[string]string{"tokyo/a.jpg": "x"})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
calls := make(chan struct{}, 10)
if err := Watch(ctx, root, 100*time.Millisecond, func() { calls <- struct{}{} }); err != nil {
t.Fatal(err)
}
for _, n := range []string{"b.jpg", "c.jpg", "d.jpg"} {
os.WriteFile(filepath.Join(root, "tokyo", n), []byte("x"), 0o644)
}
expectCall(t, calls, "writes in existing album")
select {
case <-calls:
t.Fatal("burst of writes produced more than one call")
case <-time.After(300 * time.Millisecond):
}
os.Mkdir(filepath.Join(root, "iceland"), 0o755)
expectCall(t, calls, "new album dir")
time.Sleep(150 * time.Millisecond) // let the watcher add the new dir
os.WriteFile(filepath.Join(root, "iceland", "a.jpg"), []byte("x"), 0o644)
expectCall(t, calls, "write in new album")
}
- Step 3: Run it and confirm it fails
Run: go test ./internal/library/ -run Watch
Expected: FAIL, undefined: Watch.
- Step 4: Implement
internal/library/watch.go
package library
import (
"context"
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/fsnotify/fsnotify"
)
// Watch calls onChange after filesystem activity under root has been quiet for debounce.
func Watch(ctx context.Context, root string, debounce time.Duration, onChange func()) error {
w, err := fsnotify.NewWatcher()
if err != nil {
return err
}
if err := addTree(w, root); err != nil {
w.Close()
return err
}
go func() {
defer w.Close()
timer := time.NewTimer(debounce)
timer.Stop()
for {
select {
case <-ctx.Done():
return
case ev, ok := <-w.Events:
if !ok {
return
}
if ev.Has(fsnotify.Create) {
if fi, err := os.Stat(ev.Name); err == nil && fi.IsDir() {
if err := w.Add(ev.Name); err != nil {
log.Printf("watch %s: %v", ev.Name, err)
}
}
}
timer.Reset(debounce)
case err, ok := <-w.Errors:
if !ok {
return
}
log.Printf("watch: %v", err)
case <-timer.C:
onChange()
}
}
}()
return nil
}
func addTree(w *fsnotify.Watcher, root string) error {
if err := w.Add(root); err != nil {
return err
}
entries, err := os.ReadDir(root)
if err != nil {
return err
}
for _, e := range entries {
if e.IsDir() && !strings.HasPrefix(e.Name(), ".") {
if err := w.Add(filepath.Join(root, e.Name())); err != nil {
return err
}
}
}
return nil
}
timer.Reset without draining is correct because go.mod declares Go ≥ 1.23
(unbuffered timer channels).
- Step 5: Run the tests
Run: go mod tidy && go test -race ./internal/library/ && go vet ./...
Expected: PASS.
- Step 6: Commit
git add go.mod go.sum internal/library
git commit -m "Rescan on filesystem changes"
Task 8: Renderer and derivative cache
Files:
- Create:
internal/render/render.go - Test:
internal/render/render_test.go
Interfaces:
- Consumes:
library.Item,library.Library(Task 6);format.Format(Task 1) -
Produces:
var Widths = []int{480, 960, 1600, 2400};Allowed(w int) boolNew(cacheDir string, concurrency int) *Renderer(images under<cacheDir>/img)(*Renderer).Path(it *library.Item, w int) string(*Renderer).Get(it *library.Item, w int) (string, error)returns the path of a finished file(*Renderer).Prune(lib *library.Library) error
- Step 1: Add the dependency
go get golang.org/x/sync@latest
- Step 2: Write the failing test
internal/render/render_test.go
package render
import (
"errors"
"io"
"os"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
"gitbay.org/cmc/gallery/internal/format"
"gitbay.org/cmc/gallery/internal/library"
)
type fakeFormat struct {
calls atomic.Int32
width atomic.Int32
fail bool
}
func (f *fakeFormat) Name() string { return "fake" }
func (f *fakeFormat) Match(string) bool { return true }
func (f *fakeFormat) Kind() format.Kind { return format.KindImage }
func (f *fakeFormat) Metadata(string) (format.Meta, error) { return format.Meta{}, nil }
func (f *fakeFormat) Resize(_ string, w int, dst io.Writer) error {
f.calls.Add(1)
f.width.Store(int32(w))
time.Sleep(20 * time.Millisecond)
io.WriteString(dst, "partial")
if f.fail {
return errors.New("magick failed")
}
io.WriteString(dst, "-done")
return nil
}
func item(f format.Format) *library.Item {
return &library.Item{
Album: "tokyo", Name: "a.jpg", Path: "/src/a.jpg",
ModTime: time.Unix(100, 0), Size: 42,
Meta: format.Meta{Width: 3000, Height: 2000},
Format: f,
}
}
func TestAllowed(t *testing.T) {
for _, w := range Widths {
if !Allowed(w) {
t.Errorf("%d should be allowed", w)
}
}
for _, w := range []int{0, 1, 961, 4000} {
if Allowed(w) {
t.Errorf("%d should not be allowed", w)
}
}
}
func TestGetGeneratesOnce(t *testing.T) {
f := &fakeFormat{}
r := New(t.TempDir(), 4)
it := item(f)
var wg sync.WaitGroup
for range 10 {
wg.Add(1)
go func() {
defer wg.Done()
if _, err := r.Get(it, 960); err != nil {
t.Error(err)
}
}()
}
wg.Wait()
p, err := r.Get(it, 960)
if err != nil {
t.Fatal(err)
}
if n := f.calls.Load(); n != 1 {
t.Fatalf("Resize calls = %d, want 1", n)
}
if b, _ := os.ReadFile(p); string(b) != "partial-done" {
t.Fatalf("content = %q", b)
}
if p != r.Path(it, 960) {
t.Fatalf("Get path %s != Path %s", p, r.Path(it, 960))
}
}
func TestGetRejectsWidth(t *testing.T) {
if _, err := New(t.TempDir(), 1).Get(item(&fakeFormat{}), 961); err == nil {
t.Fatal("want error")
}
}
func TestGetClampsToSource(t *testing.T) {
f := &fakeFormat{}
it := item(f)
it.Meta.Width = 700
if _, err := New(t.TempDir(), 1).Get(it, 960); err != nil {
t.Fatal(err)
}
if w := f.width.Load(); w != 700 {
t.Fatalf("resized to %d, want 700", w)
}
}
func TestGetFailureLeavesNothing(t *testing.T) {
f := &fakeFormat{fail: true}
r := New(t.TempDir(), 1)
it := item(f)
if _, err := r.Get(it, 480); err == nil {
t.Fatal("want error")
}
entries, _ := os.ReadDir(filepath.Dir(r.Path(it, 480)))
if len(entries) != 0 {
t.Fatalf("left behind: %v", entries)
}
r.Get(it, 480)
if n := f.calls.Load(); n != 2 {
t.Fatalf("failure was cached: calls = %d", n)
}
}
func TestPathChangesWithSource(t *testing.T) {
r := New("/cache", 1)
a := item(nil)
b := item(nil)
b.ModTime = time.Unix(200, 0)
if r.Path(a, 480) == r.Path(b, 480) {
t.Fatal("path must change with mtime")
}
want := filepath.Join("/cache", "img", "tokyo", "a.jpg", "100000000000-42", "480.jpg")
if got := r.Path(a, 480); got != want {
t.Fatalf("Path = %s, want %s", got, want)
}
}
func TestPrune(t *testing.T) {
f := &fakeFormat{}
r := New(t.TempDir(), 1)
cur := item(f)
old := item(f)
old.ModTime = time.Unix(50, 0)
gone := item(f)
gone.Name = "gone.jpg"
for _, it := range []*library.Item{cur, old, gone} {
if _, err := r.Get(it, 480); err != nil {
t.Fatal(err)
}
}
lib := &library.Library{Albums: []*library.Album{{Slug: "tokyo", Items: []*library.Item{cur}}}}
if err := r.Prune(lib); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(r.Path(cur, 480)); err != nil {
t.Fatalf("current derivative removed: %v", err)
}
for _, it := range []*library.Item{old, gone} {
if _, err := os.Stat(filepath.Dir(r.Path(it, 480))); !os.IsNotExist(err) {
t.Fatalf("stale dir kept: %s", filepath.Dir(r.Path(it, 480)))
}
}
}
- Step 3: Run it and confirm it fails
Run: go test ./internal/render/
Expected: FAIL, undefined: New, undefined: Widths.
- Step 4: Implement
internal/render/render.go
// Package render produces and caches resized copies of library items.
package render
import (
"fmt"
"os"
"path/filepath"
"slices"
"strconv"
"golang.org/x/sync/singleflight"
"gitbay.org/cmc/gallery/internal/library"
)
var Widths = []int{480, 960, 1600, 2400}
func Allowed(w int) bool { return slices.Contains(Widths, w) }
type Renderer struct {
root string
sem chan struct{}
group singleflight.Group
}
func New(cacheDir string, concurrency int) *Renderer {
return &Renderer{
root: filepath.Join(cacheDir, "img"),
sem: make(chan struct{}, concurrency),
}
}
func (r *Renderer) dir(it *library.Item) string {
return filepath.Join(r.root, it.Album, it.Name, fmt.Sprintf("%d-%d", it.ModTime.UnixNano(), it.Size))
}
func (r *Renderer) Path(it *library.Item, w int) string {
return filepath.Join(r.dir(it), strconv.Itoa(w)+".jpg")
}
// Get returns the cached derivative for it at width w, generating it if needed.
func (r *Renderer) Get(it *library.Item, w int) (string, error) {
if !Allowed(w) {
return "", fmt.Errorf("width %d not allowed", w)
}
p := r.Path(it, w)
if _, err := os.Stat(p); err == nil {
return p, nil
}
_, err, _ := r.group.Do(p, func() (any, error) {
if _, err := os.Stat(p); err == nil {
return nil, nil
}
r.sem <- struct{}{}
defer func() { <-r.sem }()
return nil, r.generate(it, w, p)
})
if err != nil {
return "", err
}
return p, nil
}
func (r *Renderer) generate(it *library.Item, w int, dst string) error {
if it.Meta.Width > 0 && w > it.Meta.Width {
w = it.Meta.Width
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(dst), ".tmp-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if err := it.Format.Resize(it.Path, w, tmp); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), dst)
}
// Prune removes derivative directories for items no longer in lib or whose source changed.
func (r *Renderer) Prune(lib *library.Library) error {
keep := map[string]bool{}
for _, a := range lib.Albums {
for _, it := range a.Items {
keep[r.dir(it)] = true
}
}
versions, err := filepath.Glob(filepath.Join(r.root, "*", "*", "*"))
if err != nil {
return err
}
for _, v := range versions {
if !keep[v] {
if err := os.RemoveAll(v); err != nil {
return err
}
}
}
return nil
}
- Step 5: Run the tests
Run: go mod tidy && go test -race ./internal/render/ && go vet ./...
Expected: PASS.
- Step 6: Commit
git add go.mod go.sum internal/render
git commit -m "Add derivative renderer with disk cache"
Task 9: Web server, templates and theme
Files:
- Create:
internal/web/server.go,internal/web/funcs.go,internal/web/templates/{base,home,album,photo,info,error}.html,internal/web/assets/theme.css - Test:
internal/web/server_test.go
Interfaces:
- Consumes:
library.Store,library.Library,library.Album,library.Item,library.Scan,library.OpenMetaCache(Tasks 5-6);render.Renderer,render.Widths,render.Allowed,render.New(Task 8);format.Registry,format.Meta(Task 1) -
Produces:
type Options struct { Title string; ThemePath string; Templates fs.FS }New(store *library.Store, rend *render.Renderer, opt Options) (http.Handler, error)- Routes:
GET /,GET /info/,GET /_/theme.css,GET /img/{album}/{file}/{width},GET /{album}/,GET /{album}/{file}/, everything else 404.
- Step 1: Write the failing test
internal/web/server_test.go
package web
import (
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitbay.org/cmc/gallery/internal/format"
"gitbay.org/cmc/gallery/internal/library"
"gitbay.org/cmc/gallery/internal/render"
)
type fakeFormat struct{ fail bool }
func (fakeFormat) Name() string { return "fake" }
func (fakeFormat) Match(p string) bool { return strings.HasSuffix(p, ".jpg") }
func (fakeFormat) Kind() format.Kind { return format.KindImage }
func (fakeFormat) Metadata(string) (format.Meta, error) {
return format.Meta{
Width: 3000, Height: 2000,
Taken: time.Date(2025, 3, 14, 0, 0, 0, 0, time.UTC),
Camera: "X-T5", Aperture: "f/2", Shutter: "1/125", ISO: 1600,
}, nil
}
func (f fakeFormat) Resize(_ string, _ int, dst io.Writer) error {
if f.fail {
return errors.New("magick failed")
}
_, err := io.WriteString(dst, "JPEGDATA")
return err
}
type setup struct {
fail bool
about bool
opt Options
}
func newServer(t *testing.T, s setup) http.Handler {
t.Helper()
root := t.TempDir()
files := map[string]string{
"tokyo/a.jpg": "x",
"tokyo/b.jpg": "x",
"tokyo/album.org": "#+title: Tokyo\n#+date: 2025-03\n",
}
if s.about {
files["about.org"] = "Hello /there/.\n"
}
for rel, body := range files {
p := filepath.Join(root, rel)
os.MkdirAll(filepath.Dir(p), 0o755)
os.WriteFile(p, []byte(body), 0o644)
}
reg := &format.Registry{}
reg.Register(fakeFormat{fail: s.fail})
cache, err := library.OpenMetaCache(filepath.Join(t.TempDir(), "meta.json"))
if err != nil {
t.Fatal(err)
}
lib, err := library.Scan(root, reg, cache)
if err != nil {
t.Fatal(err)
}
store := &library.Store{}
store.Set(lib)
if s.opt.Title == "" {
s.opt.Title = "gallery"
}
h, err := New(store, render.New(t.TempDir(), 2), s.opt)
if err != nil {
t.Fatal(err)
}
return h
}
func get(h http.Handler, path string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
return rec
}
func TestPages(t *testing.T) {
h := newServer(t, setup{about: true})
for _, tc := range []struct {
path string
code int
want []string
reject []string
}{
{"/", 200, []string{`href="/tokyo/"`, `data-ar="1.5000"`, "Tokyo", "2025", `href="/info/"`}, nil},
{"/tokyo/", 200, []string{`href="/tokyo/a.jpg/"`, `href="/tokyo/b.jpg/"`, `/img/tokyo/a.jpg/960.jpg?v=`, `480w`, `class="on"`}, nil},
{"/tokyo/a.jpg/", 200, []string{"a.jpg — 1 / 2", "X-T5 · f/2 · 1/125 · ISO 1600", `rel="next" href="/tokyo/b.jpg/"`, `id="up" href="/tokyo/"`}, []string{`rel="prev"`}},
{"/tokyo/b.jpg/", 200, []string{`rel="prev" href="/tokyo/a.jpg/"`, "2 / 2"}, []string{`rel="next"`}},
{"/info/", 200, []string{"<em>there</em>"}, nil},
{"/nope/", 404, []string{"404"}, nil},
{"/tokyo/nope.jpg/", 404, []string{"404"}, nil},
{"/tokyo/a.jpg/extra/", 404, nil, nil},
} {
rec := get(h, tc.path)
if rec.Code != tc.code {
t.Errorf("%s: code %d, want %d", tc.path, rec.Code, tc.code)
continue
}
body := rec.Body.String()
for _, w := range tc.want {
if !strings.Contains(body, w) {
t.Errorf("%s: missing %q", tc.path, w)
}
}
for _, r := range tc.reject {
if strings.Contains(body, r) {
t.Errorf("%s: unexpected %q", tc.path, r)
}
}
}
}
func TestInfoHiddenWithoutAbout(t *testing.T) {
h := newServer(t, setup{})
if strings.Contains(get(h, "/").Body.String(), `href="/info/"`) {
t.Error("nav links Info without about.org")
}
if code := get(h, "/info/").Code; code != 404 {
t.Errorf("/info/ = %d, want 404", code)
}
}
func TestImages(t *testing.T) {
h := newServer(t, setup{})
rec := get(h, "/img/tokyo/a.jpg/960.jpg?v=1")
if rec.Code != 200 || rec.Body.String() != "JPEGDATA" {
t.Fatalf("code %d body %q", rec.Code, rec.Body.String())
}
if cc := rec.Header().Get("Cache-Control"); cc != "public, max-age=31536000, immutable" {
t.Errorf("Cache-Control = %q", cc)
}
if ct := rec.Header().Get("Content-Type"); ct != "image/jpeg" {
t.Errorf("Content-Type = %q", ct)
}
for path, code := range map[string]int{
"/img/tokyo/a.jpg/961.jpg": 400,
"/img/tokyo/a.jpg/960.png": 400,
"/img/tokyo/a.jpg/big.jpg": 400,
"/img/nope/a.jpg/960.jpg": 404,
"/img/tokyo/nope.jpg/960.jpg": 404,
} {
if got := get(h, path).Code; got != code {
t.Errorf("%s: %d, want %d", path, got, code)
}
}
// The mux may 404 or redirect these; either way no file is served.
for _, path := range []string{"/img/%2e%2e/a.jpg/960.jpg", "/img/tokyo/..%2fa.jpg/960.jpg"} {
if rec := get(h, path); rec.Code == 200 {
t.Errorf("%s: served %q", path, rec.Body.String())
}
}
}
func TestImageRenderFailure(t *testing.T) {
h := newServer(t, setup{fail: true})
if code := get(h, "/img/tokyo/a.jpg/480.jpg").Code; code != 500 {
t.Fatalf("code %d, want 500", code)
}
}
func TestTheme(t *testing.T) {
rec := get(newServer(t, setup{}), "/_/theme.css")
if rec.Code != 200 || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/css") || !strings.Contains(rec.Body.String(), "--bg") {
t.Fatalf("code %d type %q", rec.Code, rec.Header().Get("Content-Type"))
}
custom := filepath.Join(t.TempDir(), "theme.css")
os.WriteFile(custom, []byte(":root{--bg:red}"), 0o644)
rec = get(newServer(t, setup{opt: Options{ThemePath: custom}}), "/_/theme.css")
if rec.Body.String() != ":root{--bg:red}" {
t.Fatalf("custom theme not served: %q", rec.Body.String())
}
}
func TestCustomTemplates(t *testing.T) {
dir := t.TempDir()
for _, name := range []string{"base", "home", "album", "photo", "info", "error"} {
body := `{{define "content"}}custom-` + name + `{{end}}`
if name == "base" {
body = `{{template "content" .}}`
}
os.WriteFile(filepath.Join(dir, name+".html"), []byte(body), 0o644)
}
h := newServer(t, setup{opt: Options{Templates: os.DirFS(dir)}})
if body := get(h, "/").Body.String(); body != "custom-home" {
t.Fatalf("body = %q", body)
}
}
- Step 2: Run it and confirm it fails
Run: go test ./internal/web/
Expected: FAIL, undefined: New, undefined: Options.
- Step 3: Implement
internal/web/funcs.go
package web
import (
"fmt"
"html/template"
"net/url"
"strconv"
"strings"
"gitbay.org/cmc/gallery/internal/library"
"gitbay.org/cmc/gallery/internal/render"
)
var funcs = template.FuncMap{
"albumURL": albumURL,
"photoURL": photoURL,
"imgURL": imgURL,
"srcset": srcset,
"ar": aspect,
"exif": exifLine,
"add": func(a, b int) int { return a + b },
}
func albumURL(a *library.Album) string {
return "/" + url.PathEscape(a.Slug) + "/"
}
func photoURL(it *library.Item) string {
return "/" + url.PathEscape(it.Album) + "/" + url.PathEscape(it.Name) + "/"
}
func imgURL(it *library.Item, w int) string {
return fmt.Sprintf("/img/%s/%s/%d.jpg?v=%d", url.PathEscape(it.Album), url.PathEscape(it.Name), w, it.ModTime.Unix())
}
// srcset lists allowed widths up to the first one that reaches the source width.
func srcset(it *library.Item) string {
var parts []string
for _, w := range render.Widths {
actual := w
if it.Meta.Width > 0 && w >= it.Meta.Width {
actual = it.Meta.Width
}
parts = append(parts, fmt.Sprintf("%s %dw", imgURL(it, w), actual))
if actual != w {
break
}
}
return strings.Join(parts, ", ")
}
func aspect(it *library.Item) string {
if it.Meta.Height == 0 {
return "1"
}
return strconv.FormatFloat(float64(it.Meta.Width)/float64(it.Meta.Height), 'f', 4, 64)
}
func exifLine(it *library.Item) string {
m := it.Meta
var parts []string
for _, s := range []string{m.Camera, m.Lens, m.Aperture, m.Shutter} {
if s != "" {
parts = append(parts, s)
}
}
if m.ISO > 0 {
parts = append(parts, "ISO "+strconv.Itoa(m.ISO))
}
return strings.Join(parts, " · ")
}
- Step 4: Implement
internal/web/server.go
// Package web serves gallery pages and images.
package web
import (
"bytes"
"embed"
"fmt"
"html/template"
"io/fs"
"log"
"net/http"
"os"
"strconv"
"strings"
"gitbay.org/cmc/gallery/internal/library"
"gitbay.org/cmc/gallery/internal/render"
)
//go:embed templates/*.html
var embeddedTemplates embed.FS
//go:embed assets/theme.css
var embeddedTheme []byte
type Options struct {
Title string
ThemePath string // replaces the embedded theme.css
Templates fs.FS // replaces the embedded templates; same file names
}
var pageNames = []string{"home", "album", "photo", "info", "error"}
type server struct {
store *library.Store
rend *render.Renderer
title string
theme []byte
pages map[string]*template.Template
}
type page struct {
Title string
Lib *library.Library
Current string
Album *library.Album
Item *library.Item
Index int
Prev *library.Item
Next *library.Item
Code int
Message string
}
func New(store *library.Store, rend *render.Renderer, opt Options) (http.Handler, error) {
s := &server{store: store, rend: rend, title: opt.Title, theme: embeddedTheme, pages: map[string]*template.Template{}}
if opt.ThemePath != "" {
b, err := os.ReadFile(opt.ThemePath)
if err != nil {
return nil, err
}
s.theme = b
}
tfs := opt.Templates
if tfs == nil {
sub, err := fs.Sub(embeddedTemplates, "templates")
if err != nil {
return nil, err
}
tfs = sub
}
for _, name := range pageNames {
t, err := template.New("base.html").Funcs(funcs).ParseFS(tfs, "base.html", name+".html")
if err != nil {
return nil, fmt.Errorf("template %s: %w", name, err)
}
s.pages[name] = t
}
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", s.home)
mux.HandleFunc("GET /info/{$}", s.info)
mux.HandleFunc("GET /_/theme.css", s.themeCSS)
mux.HandleFunc("GET /img/{album}/{file}/{width}", s.image)
mux.HandleFunc("GET /{album}/{$}", s.album)
mux.HandleFunc("GET /{album}/{file}/{$}", s.photo)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { s.error(w, http.StatusNotFound) })
return mux, nil
}
func (s *server) render(w http.ResponseWriter, name string, status int, p page) {
p.Title = s.title
if p.Lib == nil {
p.Lib = s.store.Load()
}
var buf bytes.Buffer
if err := s.pages[name].Execute(&buf, p); err != nil {
log.Printf("render %s: %v", name, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
buf.WriteTo(w)
}
func (s *server) error(w http.ResponseWriter, code int) {
s.render(w, "error", code, page{Code: code, Message: http.StatusText(code)})
}
func (s *server) home(w http.ResponseWriter, r *http.Request) {
s.render(w, "home", http.StatusOK, page{})
}
func (s *server) info(w http.ResponseWriter, r *http.Request) {
lib := s.store.Load()
if lib.About == "" {
s.error(w, http.StatusNotFound)
return
}
s.render(w, "info", http.StatusOK, page{Lib: lib, Current: "info"})
}
func (s *server) album(w http.ResponseWriter, r *http.Request) {
lib := s.store.Load()
a := lib.Album(r.PathValue("album"))
if a == nil {
s.error(w, http.StatusNotFound)
return
}
s.render(w, "album", http.StatusOK, page{Lib: lib, Current: a.Slug, Album: a})
}
func (s *server) photo(w http.ResponseWriter, r *http.Request) {
lib := s.store.Load()
a := lib.Album(r.PathValue("album"))
if a == nil {
s.error(w, http.StatusNotFound)
return
}
i, it := a.Item(r.PathValue("file"))
if it == nil {
s.error(w, http.StatusNotFound)
return
}
p := page{Lib: lib, Current: a.Slug, Album: a, Item: it, Index: i}
if i > 0 {
p.Prev = a.Items[i-1]
}
if i < len(a.Items)-1 {
p.Next = a.Items[i+1]
}
s.render(w, "photo", http.StatusOK, p)
}
func (s *server) image(w http.ResponseWriter, r *http.Request) {
lib := s.store.Load()
a := lib.Album(r.PathValue("album"))
if a == nil {
http.NotFound(w, r)
return
}
_, it := a.Item(r.PathValue("file"))
if it == nil {
http.NotFound(w, r)
return
}
ws, ok := strings.CutSuffix(r.PathValue("width"), ".jpg")
n, err := strconv.Atoi(ws)
if !ok || err != nil || !render.Allowed(n) {
http.Error(w, "bad width", http.StatusBadRequest)
return
}
p, err := s.rend.Get(it, n)
if err != nil {
log.Printf("render %s/%s@%d: %v", it.Album, it.Name, n, err)
http.Error(w, "render failed", http.StatusInternalServerError)
return
}
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
w.Header().Set("Content-Type", "image/jpeg")
http.ServeFile(w, r, p)
}
func (s *server) themeCSS(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/css; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Write(s.theme)
}
- Step 5: Create the templates
internal/web/templates/base.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{block "title" .}}{{.Title}}{{end}}</title>
<link rel="stylesheet" href="/_/theme.css">
</head>
<body class="{{block "bodyclass" .}}{{end}}">
<nav class="site">
<a class="brand" href="/">{{.Title}}</a>
{{- range .Lib.Albums}}
<a href="{{albumURL .}}"{{if eq .Slug $.Current}} class="on"{{end}}>{{.Title}}</a>
{{- end}}
{{- if .Lib.About}}
<a href="/info/"{{if eq .Current "info"}} class="on"{{end}}>Info</a>
{{- end}}
</nav>
<main>{{template "content" .}}</main>
</body>
</html>
internal/web/templates/home.html:
{{define "content"}}
<div class="rows captioned">
{{- range $a := .Lib.Albums}}{{$c := $a.Cover}}
<a class="tile" href="{{albumURL $a}}" data-ar="{{ar $c}}" style="--ar:{{ar $c}}">
<img src="{{imgURL $c 960}}" srcset="{{srcset $c}}" sizes="(max-width: 600px) 100vw, 50vw" width="{{$c.Meta.Width}}" height="{{$c.Meta.Height}}" loading="lazy" alt="{{$a.Title}}">
<span class="label">{{$a.Title}}{{with $a.Year}} · {{.}}{{end}}</span>
</a>
{{- end}}
</div>
{{end}}
internal/web/templates/album.html:
{{define "title"}}{{.Album.Title}} · {{.Title}}{{end}}
{{define "content"}}
<header class="title">
<h1>{{.Album.Title}}</h1>
<span>{{with .Album.Year}}{{.}} · {{end}}{{len .Album.Items}}</span>
</header>
{{with .Album.Description}}<div class="desc">{{.}}</div>{{end}}
<div class="rows">
{{- range .Album.Items}}
<a class="tile" href="{{photoURL .}}" data-ar="{{ar .}}" style="--ar:{{ar .}}">
<img src="{{imgURL . 960}}" srcset="{{srcset .}}" sizes="(max-width: 600px) 100vw, 33vw" width="{{.Meta.Width}}" height="{{.Meta.Height}}" loading="lazy" alt="">
</a>
{{- end}}
</div>
{{end}}
internal/web/templates/photo.html:
{{define "title"}}{{.Item.Name}} · {{.Album.Title}} · {{.Title}}{{end}}
{{define "bodyclass"}}photo{{end}}
{{define "content"}}
<figure class="stage">
{{with .Prev}}<a class="prev" rel="prev" href="{{photoURL .}}" aria-label="Previous">‹</a>{{end}}
<img src="{{imgURL .Item 1600}}" srcset="{{srcset .Item}}" sizes="100vw" width="{{.Item.Meta.Width}}" height="{{.Item.Meta.Height}}" alt="">
{{with .Next}}<a class="next" rel="next" href="{{photoURL .}}" aria-label="Next">›</a>{{end}}
</figure>
<footer class="meta">
<span>{{.Item.Name}} — {{add .Index 1}} / {{len .Album.Items}}</span>
<span>{{exif .Item}}</span>
</footer>
<a id="up" href="{{albumURL .Album}}" hidden></a>
{{with .Prev}}<link rel="preload" as="image" href="{{imgURL . 1600}}">{{end}}
{{with .Next}}<link rel="preload" as="image" href="{{imgURL . 1600}}">{{end}}
{{end}}
internal/web/templates/info.html:
{{define "title"}}Info · {{.Title}}{{end}}
{{define "content"}}<article class="info">{{.Lib.About}}</article>{{end}}
internal/web/templates/error.html:
{{define "title"}}{{.Code}} · {{.Title}}{{end}}
{{define "content"}}<p class="error">{{.Code}} — {{.Message}}</p>{{end}}
- Step 6: Create
internal/web/assets/theme.css
:root {
--bg: #0a0a0a;
--fg: #dddddd;
--muted: #777777;
--accent: #ffffff;
--gap: 4px;
--row-height: 320px;
--font: "Helvetica Neue", Arial, sans-serif;
--tracking: 0.25em;
}
html { box-sizing: border-box; } *, *::before, *::after { box-sizing: inherit; }
html, body { margin: 0; background: var(--bg); color: var(--fg); font: 13px/1.5 var(--font); }
a { color: inherit; text-decoration: none; }
nav.site { display: flex; flex-wrap: wrap; gap: 8px 22px; padding: 18px 20px; font-size: 10px; letter-spacing: var(--tracking); text-transform: uppercase; color: var(--muted); }
nav.site .brand { color: var(--accent); font-weight: 600; margin-right: auto; }
nav.site a:hover, nav.site a.on { color: var(--accent); }
.title { display: flex; justify-content: space-between; align-items: baseline; padding: 16px 20px 12px; font-size: 10px; letter-spacing: var(--tracking); text-transform: uppercase; color: var(--muted); }
.title h1 { margin: 0; font: inherit; color: var(--fg); }
.desc { padding: 0 20px 16px; max-width: 60ch; color: var(--muted); }
.rows { display: flex; flex-wrap: wrap; gap: var(--gap); padding: 0 var(--gap) var(--gap); }
.tile { display: block; position: relative; height: var(--row-height); width: calc(var(--row-height) * var(--ar)); max-width: 100%; }
.tile img { display: block; width: 100%; height: 100%; object-fit: contain; }
.captioned { row-gap: 36px; padding-top: 4px; padding-bottom: 40px; }
.captioned .label { position: absolute; top: 100%; left: 0; padding-top: 8px; font-size: 10px; letter-spacing: var(--tracking); text-transform: uppercase; color: var(--muted); white-space: nowrap; }
body.photo { display: flex; flex-direction: column; min-height: 100vh; }
body.photo main { flex: 1; display: flex; flex-direction: column; }
.stage { flex: 1; margin: 0; display: flex; align-items: center; justify-content: center; position: relative; padding: 0 48px; }
.stage img { max-width: 100%; max-height: calc(100vh - 120px); width: auto; height: auto; }
.stage .prev, .stage .next { position: absolute; top: 0; bottom: 0; width: 48px; display: flex; align-items: center; justify-content: center; font-size: 24px; color: var(--muted); }
.stage .prev { left: 0; }
.stage .next { right: 0; }
.stage .prev:hover, .stage .next:hover { color: var(--accent); }
.meta { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 16px; padding: 16px 20px 20px; font-size: 9px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--muted); }
.meta span:first-child { color: var(--fg); }
.info { padding: 0 20px 40px; max-width: 60ch; }
.error { padding: 40px 20px; font-size: 10px; letter-spacing: var(--tracking); text-transform: uppercase; color: var(--muted); }
@media (max-width: 600px) {
:root { --row-height: 200px; }
.stage { padding: 0; }
.stage .prev, .stage .next { display: none; }
}
- Step 7: Run the tests
Run: go test ./internal/web/ && go vet ./...
Expected: PASS. If TestPages fails on an HTML-escaped string (for example
· or — rendered as an entity), fix the template output, not the test.
- Step 8: Commit
git add internal/web
git commit -m "Add web handlers, templates and theme"
Task 10: Page script (justified rows, keyboard, swipe)
Files:
- Create:
internal/web/assets/gallery.js - Modify:
internal/web/server.go(embed and route),internal/web/templates/base.html(script tag) - Test:
internal/web/server_test.go(addTestScript)
Interfaces:
- Consumes: markup from Task 9:
.rowscontainers of.tileelements withdata-ar;a[rel=prev],a[rel=next],#upon the photo page;--row-heightandgapfromtheme.css. - Produces: route
GET /_/gallery.js. - Step 1: Add the failing test to
internal/web/server_test.go
func TestScript(t *testing.T) {
h := newServer(t, setup{})
rec := get(h, "/_/gallery.js")
if rec.Code != 200 || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/javascript") {
t.Fatalf("code %d type %q", rec.Code, rec.Header().Get("Content-Type"))
}
if !strings.Contains(get(h, "/tokyo/").Body.String(), `<script src="/_/gallery.js" defer></script>`) {
t.Fatal("album page missing script tag")
}
}
- Step 2: Run it and confirm it fails
Run: go test ./internal/web/ -run Script
Expected: FAIL, code 404.
- Step 3: Create
internal/web/assets/gallery.js
(() => {
// Justified rows: fill each row to the container width at roughly --row-height.
const layout = (el) => {
const cs = getComputedStyle(el);
const gap = parseFloat(cs.columnGap) || 0;
const target = parseFloat(cs.getPropertyValue('--row-height')) || 320;
const width = el.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
let row = [], sum = 0;
const flush = (last) => {
let h = (width - gap * (row.length - 1)) / sum;
if (last && h > target) h = target;
for (const t of row) {
t.style.width = Math.floor(h * t.dataset.ar) + 'px';
t.style.height = Math.floor(h) + 'px';
}
row = [];
sum = 0;
};
for (const t of el.children) {
row.push(t);
sum += +t.dataset.ar;
if ((width - gap * (row.length - 1)) / sum <= target) flush(false);
}
if (row.length) flush(true);
};
const layoutAll = () => document.querySelectorAll('.rows').forEach(layout);
let timer;
addEventListener('resize', () => { clearTimeout(timer); timer = setTimeout(layoutAll, 100); });
layoutAll();
const follow = (sel) => { const a = document.querySelector(sel); if (a) location.href = a.href; };
addEventListener('keydown', (e) => {
if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
if (e.key === 'ArrowLeft') follow('a[rel=prev]');
else if (e.key === 'ArrowRight') follow('a[rel=next]');
else if (e.key === 'Escape') follow('#up');
});
let x0 = null;
addEventListener('touchstart', (e) => { x0 = e.touches[0].clientX; }, { passive: true });
addEventListener('touchend', (e) => {
if (x0 === null) return;
const dx = e.changedTouches[0].clientX - x0;
x0 = null;
if (Math.abs(dx) > 50) follow(dx > 0 ? 'a[rel=prev]' : 'a[rel=next]');
}, { passive: true });
})();
- Step 4: Serve it
In internal/web/server.go, below embeddedTheme:
//go:embed assets/gallery.js
var embeddedScript []byte
Register the route after /_/theme.css:
mux.HandleFunc("GET /_/gallery.js", s.script)
Add the handler after themeCSS:
func (s *server) script(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Write(embeddedScript)
}
In internal/web/templates/base.html, replace
<link rel="stylesheet" href="/_/theme.css">
with
<link rel="stylesheet" href="/_/theme.css">
<script src="/_/gallery.js" defer></script>
- Step 5: Run the tests
Run: go test ./internal/web/ && go vet ./...
Expected: PASS.
- Step 6: Commit
git add internal/web
git commit -m "Add justified row layout and photo navigation script"
Task 11: Command, wiring and local run
Files:
- Create:
cmd/gallery/main.go - Modify:
README.org
Interfaces:
- Consumes:
format.CheckTools,format.Default(Task 3);library.OpenMetaCache,library.Scan,library.Store,library.Watch(Tasks 5-7);render.New,(*Renderer).Prune(Task 8);web.New,web.Options(Task 9) - Produces: the
gallerybinary with flags-photos -cache -addr -theme -templates -titleandGALLERY_*environment fallbacks. - Step 1: Implement
cmd/gallery/main.go
package main
import (
"context"
"errors"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"runtime"
"syscall"
"time"
"gitbay.org/cmc/gallery/internal/format"
"gitbay.org/cmc/gallery/internal/library"
"gitbay.org/cmc/gallery/internal/render"
"gitbay.org/cmc/gallery/internal/web"
)
func env(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func main() {
photos := flag.String("photos", env("GALLERY_PHOTOS", ""), "photo root (required)")
cache := flag.String("cache", env("GALLERY_CACHE", ""), "cache directory (required)")
addr := flag.String("addr", env("GALLERY_ADDR", ":8080"), "listen address")
theme := flag.String("theme", env("GALLERY_THEME", ""), "replacement theme.css")
templates := flag.String("templates", env("GALLERY_TEMPLATES", ""), "replacement template directory")
title := flag.String("title", env("GALLERY_TITLE", "gallery"), "site name")
flag.Parse()
if *photos == "" || *cache == "" {
fmt.Fprintln(os.Stderr, "gallery: -photos and -cache are required")
flag.Usage()
os.Exit(2)
}
if err := format.CheckTools(); err != nil {
log.Fatal(err)
}
reg := format.Default()
meta, err := library.OpenMetaCache(filepath.Join(*cache, "meta.json"))
if err != nil {
log.Fatal(err)
}
rend := render.New(*cache, runtime.NumCPU())
store := &library.Store{}
rescan := func() error {
lib, err := library.Scan(*photos, reg, meta)
if err != nil {
return err
}
store.Set(lib)
if err := rend.Prune(lib); err != nil {
log.Printf("prune: %v", err)
}
log.Printf("library: %d albums", len(lib.Albums))
return nil
}
if err := rescan(); err != nil {
log.Fatal(err)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
onChange := func() {
if err := rescan(); err != nil {
log.Printf("rescan: %v", err)
}
}
if err := library.Watch(ctx, *photos, 500*time.Millisecond, onChange); err != nil {
log.Fatal(err)
}
opt := web.Options{Title: *title, ThemePath: *theme}
if *templates != "" {
opt.Templates = os.DirFS(*templates)
}
h, err := web.New(store, rend, opt)
if err != nil {
log.Fatal(err)
}
srv := &http.Server{Addr: *addr, Handler: h, ReadHeaderTimeout: 10 * time.Second}
go func() {
<-ctx.Done()
sctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
srv.Shutdown(sctx)
}()
log.Printf("listening on %s", *addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
}
- Step 2: Build and run against sample photos
go build -o gallery ./cmd/gallery
mkdir -p photos/tokyo photos/iceland
for i in 1 2 3 4 5; do magick -size $((400 + i * 150))x600 plasma: photos/tokyo/t$i.jpg; done
for i in 1 2 3; do magick -size 900x$((400 + i * 120)) gradient:navy-orange photos/iceland/i$i.png; done
printf '#+title: Tokyo\n#+date: 2025-03\n\nA /quiet/ week.\n' > photos/tokyo/album.org
printf 'Photographs by me.\n' > photos/about.org
./gallery -photos ./photos -cache ./cache -addr 127.0.0.1:8080 &
Expected log: library: 2 albums and listening on 127.0.0.1:8080.
- Step 3: Check the endpoints
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/tokyo/
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/tokyo/t1.jpg/
curl -s -D - -o /tmp/g.jpg http://127.0.0.1:8080/img/tokyo/t1.jpg/480.jpg | grep -i cache-control
magick identify /tmp/g.jpg
Expected: 200 three times; Cache-Control: public, max-age=31536000,
immutable; identify reports JPEG 480x....
- Step 4: Check the watcher
magick -size 800x500 plasma: photos/tokyo/t6.jpg
sleep 1
curl -s http://127.0.0.1:8080/tokyo/ | grep -c 'href="/tokyo/t6.jpg/"'
Expected: 1, and a second library: 2 albums log line.
- Step 5: Check the pages in a browser
Open http://127.0.0.1:8080/, /tokyo/ and /tokyo/t1.jpg/, then:
- Rows fill the width edge to edge with 4 px gaps; last row is not stretched; nothing is cropped; resizing the window re-flows rows.
- On the photo page,
←→move between photos,Escreturns to the album, the first photo has no previous arrow, the last has no next. - At 375 px wide, rows are shorter and the arrows are hidden.
Stop the server (kill %1).
- Step 6: Document usage in
README.org
Replace the file with:
#+title: gallery
Self-hosted photo gallery. Albums are folders; resized copies are generated on
request with ImageMagick and cached on disk.
* Requirements
Go 1.27, ImageMagick 7 (=magick=), exiftool.
* Photos
#+begin_example
photos/
about.org optional; rendered as the Info page
tokyo/
album.org optional
DSCF0412.jpg
...
#+end_example
=album.org= keywords, all optional:
| Keyword | Meaning | Default |
|------------+-------------------------------------------+--------------------|
| =#+title= | album title | folder name |
| =#+date= | =YYYY=, =YYYY-MM= or =YYYY-MM-DD= | newest photo |
| =#+cover= | file name of the cover photo | first photo |
| =#+order= | integer; ordered albums come first | unset |
| =#+sort= | =taken= or =name= | =taken= |
Text below the keywords is shown as the album description.
* Run
#+begin_src sh
go build -o gallery ./cmd/gallery
./gallery -photos ./photos -cache ./cache -addr :8080
#+end_src
| Flag | Env | Default |
|--------------+---------------------+-----------|
| =-photos= | =GALLERY_PHOTOS= | required |
| =-cache= | =GALLERY_CACHE= | required |
| =-addr= | =GALLERY_ADDR= | =:8080= |
| =-theme= | =GALLERY_THEME= | embedded |
| =-templates= | =GALLERY_TEMPLATES= | embedded |
| =-title= | =GALLERY_TITLE= | =gallery= |
=-theme= replaces [[file:internal/web/assets/theme.css]]. =-templates= points
at a directory with the same file names as [[file:internal/web/templates]].
See [[file:docs/specs/2026-09-25-gallery-design.org][the design spec]].
(The commas before #+ and * at line start are org escapes inside this
plan; the real README.org has none.)
- Step 7: Run the full suite and commit
go test -race ./... && go vet ./...
git add cmd README.org
git commit -m "Add gallery command"
Task 12: Docker image and homelab deploy
Files:
- Create:
Dockerfile,compose.yml,.dockerignore - Modify:
README.org(Deploy section)
Interfaces:
- Consumes:
cmd/gallery(Task 11) - Produces: container
galleryon homelab at127.0.0.1:8003. - Step 1: Create
Dockerfile
FROM golang:1-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /gallery ./cmd/gallery
FROM alpine:3
RUN apk add --no-cache imagemagick imagemagick-jpeg imagemagick-heic imagemagick-webp exiftool
COPY --from=build /gallery /usr/local/bin/gallery
ENV GALLERY_PHOTOS=/photos GALLERY_CACHE=/cache GALLERY_ADDR=:8080
EXPOSE 8080
ENTRYPOINT ["gallery"]
- Step 2: Create
.dockerignore
.git photos cache gallery .superpowers
- Step 3: Create
compose.yml
services:
gallery:
build: .
container_name: gallery
restart: unless-stopped
ports:
- "127.0.0.1:8003:8080"
volumes:
- ${GALLERY_PHOTOS_DIR:-./photos}:/photos:ro
- ./cache:/cache
environment:
GALLERY_TITLE: ${GALLERY_TITLE:-gallery}
- Step 4: Build and check the image locally
docker build -t gallery .
docker run --rm --entrypoint magick gallery -list format | grep -E '^ +(JPEG|PNG|HEIC|WEBP)'
docker run --rm --entrypoint exiftool gallery -ver
Expected: rows for JPEG, PNG, HEIC and WEBP with rw mode, and an exiftool
version. If PNG is missing, add imagemagick-png to the apk add line (the
Alpine split varies by release) and rebuild.
docker compose up -d --build
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8003/
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8003/img/tokyo/t1.jpg/960.jpg
docker compose down
Expected: 200 twice (using the sample photos/ from Task 11).
- Step 5: Add a Deploy section to
README.org
Append:
* Deploy
#+begin_src sh
git clone https://gitbay.org/cmc/gallery.git ~/docker/gallery
cd ~/docker/gallery
GALLERY_PHOTOS_DIR=/path/to/photos docker compose up -d --build
#+end_src
The container listens on =127.0.0.1:8003=; put a reverse proxy in front of it.
Photos are mounted read-only; derivatives and =meta.json= live in =./cache=.
- Step 6: Commit, push, open the MR
go test -race ./... && go vet ./...
git add Dockerfile .dockerignore compose.yml README.org
git commit -m "Add Docker image and compose file"
git push -u origin v1
gitbay mr create --source v1 --target main --title "gallery v1" --body "Folder-based albums, on-demand ImageMagick resize with disk cache, format registry (JPEG, PNG), dark justified-row theme, Docker/compose deploy."
- Step 7: Deploy to homelab (after the MR is merged)
ssh homelab 'git clone https://gitbay.org/cmc/gallery.git ~/docker/gallery'
Then, on homelab, create ~/docker/gallery/.env with
GALLERY_PHOTOS_DIR=<photo directory> (ask the user for the path), and run:
ssh homelab 'cd ~/docker/gallery && docker compose up -d --build && sleep 3 && curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8003/'
Expected: 200. The nginx site for the public hostname is out of scope for
this plan; it needs the hostname from the user.