docs/plans/2026-09-25-gallery-v1.org

main
gallery/docs/plans/2026-09-25-gallery-v1.org rendered · source · history · blame · raw

3152 lines · 85160 bytes

gallery v1 implementation plan

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
library scans photos/ into an immutable snapshot held in an atomic pointer and rescans on filesystem events. format hides each file type behind one interface backed by magick and exiftool. render turns (item, width) into a cached JPEG on disk. web serves templates and images over the stdlib mux.
Tech stack
Go 1.27, net/http 1.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, including album.org and about.org content 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 v1 off main; open an MR with gitbay mr create --source v1 --target main at the end.
  • go test ./... and go 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 = 0
    • type 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 of exiftool -json -n for 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) *Magick
    • Default() *Registry registering jpeg (.jpg, .jpeg) then png (.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: value lines; 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) error drops entries whose key is not in keep, 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, nil if absent); (*Album).Year() string
    • type 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. Watches root and its non-hidden subdirectories, adds newly created directories, and calls onChange once per quiet period of length debounce. Returns after setup; stops when ctx is 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) bool
    • New(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 (add TestScript)

Interfaces:

  • Consumes: markup from Task 9: .rows containers of .tile elements with data-ar; a[rel=prev], a[rel=next], #up on the photo page; --row-height and gap from theme.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 gallery binary with flags -photos -cache -addr -theme -templates -title and GALLERY_* 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, Esc returns 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 gallery on homelab at 127.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.