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

   1#+title: gallery v1 implementation plan
   2#+date: 2026-09-25
   3
   4- For agentic workers :: REQUIRED SUB-SKILL: use
   5  superpowers:subagent-driven-development (recommended) or
   6  superpowers:executing-plans to implement this plan task by task. Steps use
   7  checkbox (=- [ ]=) syntax for tracking.
   8- Goal :: A single Go binary that serves a dark, justified-row photo gallery
   9  from a folder tree, generating resized JPEGs on request with ImageMagick.
  10- Architecture :: =library= scans =photos/= into an immutable snapshot held in
  11  an atomic pointer and rescans on filesystem events. =format= hides each file
  12  type behind one interface backed by =magick= and =exiftool=. =render= turns
  13  (item, width) into a cached JPEG on disk. =web= serves templates and images
  14  over the stdlib mux.
  15- Tech stack :: Go 1.27, =net/http= 1.22+ patterns, =html/template=,
  16  =github.com/fsnotify/fsnotify=, =github.com/niklasfasching/go-org=,
  17  =golang.org/x/sync/singleflight=, ImageMagick 7 (=magick=), exiftool, Docker.
  18- Spec :: [[file:../specs/2026-09-25-gallery-design.org]]
  19
  20* Global constraints
  21
  22- Module path: =gitbay.org/cmc/gallery=.
  23- All prose files are org-mode (=.org=). No Markdown anywhere, including
  24  =album.org= and =about.org= content files.
  25- Photos and cache are never inside the repository.
  26- Allowed widths: 480, 960, 1600, 2400. Output is always JPEG, quality 82.
  27- Reserved album slugs: =img=, =info=, =_=.
  28- Resize command: =magick <src> -auto-orient -resize <w>x -strip -quality 82 jpg:-=.
  29- Image responses: =Cache-Control: public, max-age=31536000, immutable=.
  30- Never cropping photos: tiles use =object-fit: contain=.
  31- Code comments are terse and factual. No attribution lines in commits.
  32- Work on branch =v1= off =main=; open an MR with
  33  =gitbay mr create --source v1 --target main= at the end.
  34- =go test ./...= and =go vet ./...= pass after every task.
  35
  36* File structure
  37
  38#+begin_example
  39.gitignore
  40go.mod, go.sum
  41cmd/gallery/main.go            flags, env fallback, wiring, HTTP server
  42internal/format/format.go      Kind, Meta, Format, Registry
  43internal/format/exif.go        exiftool JSON → Meta (pure)
  44internal/format/magick.go      Magick format, Default(), CheckTools()
  45internal/library/library.go    Library, Album, Item, Store, sorting
  46internal/library/org.go        parseOrg: keywords + body HTML
  47internal/library/metacache.go  MetaCache (meta.json)
  48internal/library/scan.go       Scan
  49internal/library/watch.go      Watch (fsnotify, debounce)
  50internal/render/render.go      Widths, Allowed, Renderer (Path, Get, Prune)
  51internal/web/server.go         New, routes, handlers
  52internal/web/funcs.go          template helpers
  53internal/web/templates/*.html  base, home, album, photo, info, error
  54internal/web/assets/theme.css
  55internal/web/assets/gallery.js
  56Dockerfile, compose.yml
  57README.org
  58#+end_example
  59
  60* Task 1: Module scaffold and format registry
  61
  62Files:
  63- Create: =.gitignore=, =go.mod=, =internal/format/format.go=
  64- Test: =internal/format/format_test.go=
  65
  66Interfaces:
  67- Produces:
  68  - =type Kind int=; =const KindImage Kind = 0=
  69  - =type Meta struct { Width, Height int; Taken time.Time; Camera, Lens, Aperture, Shutter string; ISO int }=
  70  - =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 }=
  71  - =type Registry struct=; =(*Registry).Register(Format)=; =(*Registry).Lookup(path string) Format= (nil when unmatched)
  72  - =hasExt(path string, exts []string) bool= (package-private)
  73
  74- [ ] Step 1: Branch and initialise the module
  75
  76Start after the spec MR (=spec= → =main=) is merged, so =v1= carries the spec
  77and this plan.
  78
  79#+begin_src sh
  80git switch main && git pull && git switch -c v1
  81go mod init gitbay.org/cmc/gallery
  82#+end_src
  83
  84Create =.gitignore=:
  85
  86#+begin_example
  87/photos/
  88/cache/
  89/gallery
  90.superpowers/
  91#+end_example
  92
  93- [ ] Step 2: Write the failing test =internal/format/format_test.go=
  94
  95#+begin_src go
  96package format
  97
  98import (
  99	"io"
 100	"path/filepath"
 101	"strings"
 102	"testing"
 103)
 104
 105type fake struct{ name, ext string }
 106
 107func (f fake) Name() string                      { return f.name }
 108func (f fake) Match(p string) bool               { return strings.EqualFold(filepath.Ext(p), f.ext) }
 109func (fake) Metadata(string) (Meta, error)       { return Meta{}, nil }
 110func (fake) Resize(string, int, io.Writer) error { return nil }
 111func (fake) Kind() Kind                          { return KindImage }
 112
 113func TestRegistryFirstMatchWins(t *testing.T) {
 114	var r Registry
 115	r.Register(fake{"a", ".jpg"})
 116	r.Register(fake{"b", ".jpg"})
 117	r.Register(fake{"c", ".png"})
 118
 119	if got := r.Lookup("x/IMG.JPG"); got == nil || got.Name() != "a" {
 120		t.Fatalf("jpg: got %v, want a", got)
 121	}
 122	if got := r.Lookup("x.png"); got == nil || got.Name() != "c" {
 123		t.Fatalf("png: got %v, want c", got)
 124	}
 125	if got := r.Lookup("notes.txt"); got != nil {
 126		t.Fatalf("txt: got %v, want nil", got)
 127	}
 128}
 129
 130func TestHasExt(t *testing.T) {
 131	exts := []string{".jpg", ".jpeg"}
 132	for p, want := range map[string]bool{
 133		"a.jpg": true, "a.JPEG": true, "a.png": false, "jpg": false, "a.jpg.txt": false,
 134	} {
 135		if got := hasExt(p, exts); got != want {
 136			t.Errorf("hasExt(%q) = %v, want %v", p, got, want)
 137		}
 138	}
 139}
 140#+end_src
 141
 142- [ ] Step 3: Run it and confirm it fails
 143
 144Run: =go test ./internal/format/=
 145Expected: FAIL, =undefined: Registry=, =undefined: Meta=, =undefined: hasExt=.
 146
 147- [ ] Step 4: Implement =internal/format/format.go=
 148
 149#+begin_src go
 150// Package format describes the file types the gallery can show.
 151package format
 152
 153import (
 154	"io"
 155	"path/filepath"
 156	"strings"
 157	"time"
 158)
 159
 160type Kind int
 161
 162const (
 163	KindImage Kind = iota
 164)
 165
 166// Meta holds display metadata. Width and Height are after EXIF orientation.
 167type Meta struct {
 168	Width, Height int
 169	Taken         time.Time
 170	Camera, Lens  string
 171	Aperture      string
 172	Shutter       string
 173	ISO           int
 174}
 175
 176type Format interface {
 177	Name() string
 178	Match(path string) bool
 179	Metadata(path string) (Meta, error)
 180	Resize(src string, width int, dst io.Writer) error
 181	Kind() Kind
 182}
 183
 184// Registry resolves a path to the first registered Format that matches it.
 185type Registry struct {
 186	formats []Format
 187}
 188
 189func (r *Registry) Register(f Format) {
 190	r.formats = append(r.formats, f)
 191}
 192
 193func (r *Registry) Lookup(path string) Format {
 194	for _, f := range r.formats {
 195		if f.Match(path) {
 196			return f
 197		}
 198	}
 199	return nil
 200}
 201
 202func hasExt(path string, exts []string) bool {
 203	ext := strings.ToLower(filepath.Ext(path))
 204	for _, e := range exts {
 205		if ext == e {
 206			return true
 207		}
 208	}
 209	return false
 210}
 211#+end_src
 212
 213- [ ] Step 5: Run the tests
 214
 215Run: =go test ./internal/format/ && go vet ./...=
 216Expected: PASS.
 217
 218- [ ] Step 6: Commit
 219
 220#+begin_src sh
 221git add .gitignore go.mod internal/format
 222git commit -m "Add format registry"
 223#+end_src
 224
 225* Task 2: EXIF parsing
 226
 227Files:
 228- Create: =internal/format/exif.go=
 229- Test: =internal/format/exif_test.go=
 230
 231Interfaces:
 232- Consumes: =Meta= (Task 1)
 233- Produces: =parseExif(data []byte) (Meta, error)= (package-private), input is
 234  the output of =exiftool -json -n= for one file.
 235
 236- [ ] Step 1: Write the failing test =internal/format/exif_test.go=
 237
 238#+begin_src go
 239package format
 240
 241import (
 242	"testing"
 243	"time"
 244)
 245
 246func TestParseExifFull(t *testing.T) {
 247	data := []byte(`[{
 248		"SourceFile": "a.jpg",
 249		"ImageWidth": 6240, "ImageHeight": 4160, "Orientation": 1,
 250		"DateTimeOriginal": "2025:03:14 23:10:05",
 251		"Make": "FUJIFILM", "Model": "X-T5", "LensModel": "XF23mmF2 R WR",
 252		"FNumber": 2, "ExposureTime": 0.008, "ISO": 1600
 253	}]`)
 254	m, err := parseExif(data)
 255	if err != nil {
 256		t.Fatal(err)
 257	}
 258	want := Meta{
 259		Width: 6240, Height: 4160,
 260		Taken:    time.Date(2025, 3, 14, 23, 10, 5, 0, time.UTC),
 261		Camera:   "FUJIFILM X-T5",
 262		Lens:     "XF23mmF2 R WR",
 263		Aperture: "f/2",
 264		Shutter:  "1/125",
 265		ISO:      1600,
 266	}
 267	if m != want {
 268		t.Fatalf("got %+v\nwant %+v", m, want)
 269	}
 270}
 271
 272func TestParseExifRotatedSwapsDimensions(t *testing.T) {
 273	for _, o := range []string{"5", "6", "7", "8"} {
 274		m, err := parseExif([]byte(`[{"ImageWidth": 300, "ImageHeight": 200, "Orientation": ` + o + `}]`))
 275		if err != nil {
 276			t.Fatal(err)
 277		}
 278		if m.Width != 200 || m.Height != 300 {
 279			t.Errorf("orientation %s: got %dx%d, want 200x300", o, m.Width, m.Height)
 280		}
 281	}
 282}
 283
 284func TestParseExifFallbacks(t *testing.T) {
 285	m, err := parseExif([]byte(`[{
 286		"ImageWidth": 10, "ImageHeight": 10,
 287		"CreateDate": "2024:01:02 03:04:05+01:00",
 288		"Make": "Canon", "Model": "Canon EOS R6",
 289		"FNumber": 5.6, "ExposureTime": 2
 290	}]`))
 291	if err != nil {
 292		t.Fatal(err)
 293	}
 294	if want := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC); !m.Taken.Equal(want) {
 295		t.Errorf("Taken = %v, want %v", m.Taken, want)
 296	}
 297	if m.Camera != "Canon EOS R6" {
 298		t.Errorf("Camera = %q", m.Camera)
 299	}
 300	if m.Aperture != "f/5.6" || m.Shutter != "2s" {
 301		t.Errorf("Aperture %q Shutter %q", m.Aperture, m.Shutter)
 302	}
 303}
 304
 305func TestParseExifMinimal(t *testing.T) {
 306	m, err := parseExif([]byte(`[{"ImageWidth": 120, "ImageHeight": 80}]`))
 307	if err != nil {
 308		t.Fatal(err)
 309	}
 310	if (m != Meta{Width: 120, Height: 80}) {
 311		t.Fatalf("got %+v", m)
 312	}
 313}
 314
 315func TestParseExifErrors(t *testing.T) {
 316	for _, in := range []string{`not json`, `[]`, `[{}, {}]`, `[{"ImageWidth": 10}]`} {
 317		if _, err := parseExif([]byte(in)); err == nil {
 318			t.Errorf("%s: want error", in)
 319		}
 320	}
 321}
 322#+end_src
 323
 324- [ ] Step 2: Run it and confirm it fails
 325
 326Run: =go test ./internal/format/ -run Exif=
 327Expected: FAIL, =undefined: parseExif=.
 328
 329- [ ] Step 3: Implement =internal/format/exif.go=
 330
 331#+begin_src go
 332package format
 333
 334import (
 335	"encoding/json"
 336	"errors"
 337	"fmt"
 338	"math"
 339	"strconv"
 340	"strings"
 341	"time"
 342)
 343
 344type exifRecord struct {
 345	ImageWidth       int
 346	ImageHeight      int
 347	Orientation      int
 348	DateTimeOriginal string
 349	CreateDate       string
 350	Make             string
 351	Model            string
 352	LensModel        string
 353	FNumber          float64
 354	ExposureTime     float64
 355	ISO              int
 356}
 357
 358// parseExif reads `exiftool -json -n` output for a single file.
 359func parseExif(data []byte) (Meta, error) {
 360	var recs []exifRecord
 361	if err := json.Unmarshal(data, &recs); err != nil {
 362		return Meta{}, fmt.Errorf("exiftool output: %w", err)
 363	}
 364	if len(recs) != 1 {
 365		return Meta{}, fmt.Errorf("exiftool output: %d records, want 1", len(recs))
 366	}
 367	e := recs[0]
 368	if e.ImageWidth == 0 || e.ImageHeight == 0 {
 369		return Meta{}, errors.New("exiftool output: no image dimensions")
 370	}
 371	m := Meta{Width: e.ImageWidth, Height: e.ImageHeight, Lens: e.LensModel, ISO: e.ISO}
 372	if e.Orientation >= 5 && e.Orientation <= 8 {
 373		m.Width, m.Height = m.Height, m.Width
 374	}
 375	m.Taken = exifTime(e.DateTimeOriginal)
 376	if m.Taken.IsZero() {
 377		m.Taken = exifTime(e.CreateDate)
 378	}
 379	m.Camera = camera(e.Make, e.Model)
 380	if e.FNumber > 0 {
 381		m.Aperture = "f/" + strconv.FormatFloat(e.FNumber, 'f', -1, 64)
 382	}
 383	m.Shutter = shutter(e.ExposureTime)
 384	return m, nil
 385}
 386
 387// exifTime parses "YYYY:MM:DD HH:MM:SS", ignoring any trailing subseconds or zone.
 388func exifTime(s string) time.Time {
 389	if len(s) < 19 {
 390		return time.Time{}
 391	}
 392	t, err := time.Parse("2006:01:02 15:04:05", s[:19])
 393	if err != nil {
 394		return time.Time{}
 395	}
 396	return t
 397}
 398
 399func camera(maker, model string) string {
 400	maker, model = strings.TrimSpace(maker), strings.TrimSpace(model)
 401	if maker == "" {
 402		return model
 403	}
 404	if model == "" {
 405		return maker
 406	}
 407	brand := strings.ToLower(strings.Fields(maker)[0])
 408	if strings.Contains(strings.ToLower(model), brand) {
 409		return model
 410	}
 411	return maker + " " + model
 412}
 413
 414func shutter(sec float64) string {
 415	switch {
 416	case sec <= 0:
 417		return ""
 418	case sec >= 1:
 419		return strconv.FormatFloat(sec, 'f', -1, 64) + "s"
 420	default:
 421		return "1/" + strconv.Itoa(int(math.Round(1/sec)))
 422	}
 423}
 424#+end_src
 425
 426- [ ] Step 4: Run the tests
 427
 428Run: =go test ./internal/format/ && go vet ./...=
 429Expected: PASS.
 430
 431- [ ] Step 5: Commit
 432
 433#+begin_src sh
 434git add internal/format
 435git commit -m "Parse exiftool metadata"
 436#+end_src
 437
 438* Task 3: ImageMagick format and tool check
 439
 440Files:
 441- Create: =internal/format/magick.go=
 442- Test: =internal/format/magick_test.go=
 443
 444Interfaces:
 445- Consumes: =Format=, =Registry=, =hasExt= (Task 1); =parseExif= (Task 2)
 446- Produces:
 447  - =type Magick struct=; =NewMagick(name string, exts ...string) *Magick=
 448  - =Default() *Registry= registering =jpeg= (=.jpg=, =.jpeg=) then =png= (=.png=)
 449  - =CheckTools() error=, error text =<tool> not found on PATH=
 450
 451- [ ] Step 1: Write the failing test =internal/format/magick_test.go=
 452
 453These run the real tools and skip when either is missing.
 454
 455#+begin_src go
 456package format
 457
 458import (
 459	"bytes"
 460	"fmt"
 461	"image/jpeg"
 462	"os/exec"
 463	"path/filepath"
 464	"testing"
 465	"time"
 466)
 467
 468func requireTools(t *testing.T) {
 469	t.Helper()
 470	if err := CheckTools(); err != nil {
 471		t.Skip(err)
 472	}
 473}
 474
 475func makeImage(t *testing.T, name string, w, h int) string {
 476	t.Helper()
 477	p := filepath.Join(t.TempDir(), name)
 478	run(t, "magick", "-size", fmt.Sprintf("%dx%d", w, h), "xc:gray", p)
 479	return p
 480}
 481
 482func run(t *testing.T, name string, args ...string) {
 483	t.Helper()
 484	if out, err := exec.Command(name, args...).CombinedOutput(); err != nil {
 485		t.Fatalf("%s %v: %v\n%s", name, args, err, out)
 486	}
 487}
 488
 489func resizedSize(t *testing.T, f Format, src string, w int) (int, int) {
 490	t.Helper()
 491	var buf bytes.Buffer
 492	if err := f.Resize(src, w, &buf); err != nil {
 493		t.Fatal(err)
 494	}
 495	cfg, err := jpeg.DecodeConfig(&buf)
 496	if err != nil {
 497		t.Fatalf("output is not JPEG: %v", err)
 498	}
 499	return cfg.Width, cfg.Height
 500}
 501
 502func TestMagickJPEG(t *testing.T) {
 503	requireTools(t)
 504	p := makeImage(t, "a.jpg", 300, 200)
 505	run(t, "exiftool", "-overwrite_original", "-DateTimeOriginal=2025:03:14 23:10:05", "-Model=TestCam", p)
 506
 507	f := NewMagick("jpeg", ".jpg", ".jpeg")
 508	m, err := f.Metadata(p)
 509	if err != nil {
 510		t.Fatal(err)
 511	}
 512	if m.Width != 300 || m.Height != 200 || m.Camera != "TestCam" {
 513		t.Fatalf("got %+v", m)
 514	}
 515	if want := time.Date(2025, 3, 14, 23, 10, 5, 0, time.UTC); !m.Taken.Equal(want) {
 516		t.Fatalf("Taken = %v", m.Taken)
 517	}
 518	if w, h := resizedSize(t, f, p, 150); w != 150 || h != 100 {
 519		t.Fatalf("resized to %dx%d, want 150x100", w, h)
 520	}
 521}
 522
 523func TestMagickOrientation(t *testing.T) {
 524	requireTools(t)
 525	p := makeImage(t, "r.jpg", 300, 200)
 526	run(t, "exiftool", "-overwrite_original", "-n", "-Orientation=6", p)
 527
 528	f := NewMagick("jpeg", ".jpg")
 529	m, err := f.Metadata(p)
 530	if err != nil {
 531		t.Fatal(err)
 532	}
 533	if m.Width != 200 || m.Height != 300 {
 534		t.Fatalf("metadata %dx%d, want 200x300", m.Width, m.Height)
 535	}
 536	if w, h := resizedSize(t, f, p, 100); w != 100 || h != 150 {
 537		t.Fatalf("resized to %dx%d, want 100x150", w, h)
 538	}
 539}
 540
 541func TestMagickPNG(t *testing.T) {
 542	requireTools(t)
 543	p := makeImage(t, "a.png", 120, 80)
 544	f := NewMagick("png", ".png")
 545	m, err := f.Metadata(p)
 546	if err != nil {
 547		t.Fatal(err)
 548	}
 549	if m.Width != 120 || m.Height != 80 {
 550		t.Fatalf("got %+v", m)
 551	}
 552	if w, h := resizedSize(t, f, p, 60); w != 60 || h != 40 {
 553		t.Fatalf("resized to %dx%d, want 60x40", w, h)
 554	}
 555}
 556
 557func TestMagickResizeError(t *testing.T) {
 558	requireTools(t)
 559	var buf bytes.Buffer
 560	if err := NewMagick("jpeg", ".jpg").Resize(filepath.Join(t.TempDir(), "missing.jpg"), 100, &buf); err == nil {
 561		t.Fatal("want error")
 562	}
 563}
 564
 565func TestDefaultRegistry(t *testing.T) {
 566	r := Default()
 567	for path, want := range map[string]string{"a.JPG": "jpeg", "a.jpeg": "jpeg", "a.png": "png"} {
 568		if f := r.Lookup(path); f == nil || f.Name() != want {
 569			t.Errorf("%s: got %v, want %s", path, f, want)
 570		}
 571	}
 572	if r.Lookup("a.heic") != nil {
 573		t.Error("heic should not match in v1")
 574	}
 575}
 576#+end_src
 577
 578- [ ] Step 2: Run it and confirm it fails
 579
 580Run: =go test ./internal/format/=
 581Expected: FAIL, =undefined: CheckTools=, =undefined: NewMagick=, =undefined: Default=.
 582
 583- [ ] Step 3: Implement =internal/format/magick.go=
 584
 585#+begin_src go
 586package format
 587
 588import (
 589	"bytes"
 590	"fmt"
 591	"io"
 592	"os/exec"
 593	"strconv"
 594	"strings"
 595)
 596
 597// Magick reads metadata with exiftool and resizes with ImageMagick.
 598type Magick struct {
 599	name string
 600	exts []string
 601}
 602
 603func NewMagick(name string, exts ...string) *Magick {
 604	return &Magick{name: name, exts: exts}
 605}
 606
 607func (m *Magick) Name() string          { return m.name }
 608func (m *Magick) Match(path string) bool { return hasExt(path, m.exts) }
 609func (m *Magick) Kind() Kind             { return KindImage }
 610
 611func (m *Magick) Metadata(path string) (Meta, error) {
 612	out, err := exec.Command("exiftool", "-json", "-n",
 613		"-ImageWidth", "-ImageHeight", "-Orientation",
 614		"-DateTimeOriginal", "-CreateDate",
 615		"-Make", "-Model", "-LensModel",
 616		"-FNumber", "-ExposureTime", "-ISO",
 617		path).Output()
 618	if err != nil {
 619		return Meta{}, fmt.Errorf("exiftool %s: %w", path, err)
 620	}
 621	return parseExif(out)
 622}
 623
 624func (m *Magick) Resize(src string, width int, dst io.Writer) error {
 625	cmd := exec.Command("magick", src,
 626		"-auto-orient", "-resize", strconv.Itoa(width)+"x",
 627		"-strip", "-quality", "82", "jpg:-")
 628	var stderr bytes.Buffer
 629	cmd.Stdout = dst
 630	cmd.Stderr = &stderr
 631	if err := cmd.Run(); err != nil {
 632		return fmt.Errorf("magick %s: %w: %s", src, err, strings.TrimSpace(stderr.String()))
 633	}
 634	return nil
 635}
 636
 637// Default returns the formats supported in v1.
 638func Default() *Registry {
 639	r := &Registry{}
 640	r.Register(NewMagick("jpeg", ".jpg", ".jpeg"))
 641	r.Register(NewMagick("png", ".png"))
 642	return r
 643}
 644
 645// CheckTools reports the first required external tool missing from PATH.
 646func CheckTools() error {
 647	for _, tool := range []string{"magick", "exiftool"} {
 648		if _, err := exec.LookPath(tool); err != nil {
 649			return fmt.Errorf("%s not found on PATH", tool)
 650		}
 651	}
 652	return nil
 653}
 654#+end_src
 655
 656- [ ] Step 4: Run the tests
 657
 658Run: =go test -v ./internal/format/ && go vet ./...=
 659Expected: PASS, with the =TestMagick*= tests reported as run (not skipped) on
 660a machine with =magick= and =exiftool=.
 661
 662- [ ] Step 5: Commit
 663
 664#+begin_src sh
 665git add internal/format
 666git commit -m "Add ImageMagick-backed JPEG and PNG formats"
 667#+end_src
 668
 669* Task 4: Org parsing
 670
 671Files:
 672- Create: =internal/library/org.go=
 673- Test: =internal/library/org_test.go=
 674
 675Interfaces:
 676- Produces: =parseOrg(src []byte, path string) (map[string]string, template.HTML, error)=
 677  (package-private). Keys are lower-cased keyword names from =#+KEY: value=
 678  lines; the remaining text is rendered to HTML with go-org; empty body gives
 679  =""=.
 680
 681- [ ] Step 1: Add the dependency
 682
 683#+begin_src sh
 684go get github.com/niklasfasching/go-org@latest
 685#+end_src
 686
 687- [ ] Step 2: Write the failing test =internal/library/org_test.go=
 688
 689#+begin_src go
 690package library
 691
 692import (
 693	"strings"
 694	"testing"
 695)
 696
 697func TestParseOrg(t *testing.T) {
 698	src := "#+title: Tokyo: nights\n#+COVER: a.jpg\n\n#+begin_quote\nA /quiet/ week.\n#+end_quote\n"
 699	kw, html, err := parseOrg([]byte(src), "album.org")
 700	if err != nil {
 701		t.Fatal(err)
 702	}
 703	if len(kw) != 2 || kw["title"] != "Tokyo: nights" || kw["cover"] != "a.jpg" {
 704		t.Fatalf("keywords = %v", kw)
 705	}
 706	for _, want := range []string{"<blockquote>", "<em>quiet</em>"} {
 707		if !strings.Contains(string(html), want) {
 708			t.Errorf("html missing %q:\n%s", want, html)
 709		}
 710	}
 711}
 712
 713func TestParseOrgKeywordsOnly(t *testing.T) {
 714	kw, html, err := parseOrg([]byte("#+title: x\n\n"), "album.org")
 715	if err != nil {
 716		t.Fatal(err)
 717	}
 718	if kw["title"] != "x" || html != "" {
 719		t.Fatalf("kw=%v html=%q", kw, html)
 720	}
 721}
 722#+end_src
 723
 724- [ ] Step 3: Run it and confirm it fails
 725
 726Run: =go test ./internal/library/=
 727Expected: FAIL, =undefined: parseOrg=.
 728
 729- [ ] Step 4: Implement =internal/library/org.go=
 730
 731#+begin_src go
 732package library
 733
 734import (
 735	"html/template"
 736	"strings"
 737
 738	"github.com/niklasfasching/go-org/org"
 739)
 740
 741// parseOrg splits "#+KEY: value" lines out as lower-cased keywords and
 742// renders the rest of the document to HTML.
 743func parseOrg(src []byte, path string) (map[string]string, template.HTML, error) {
 744	kw := map[string]string{}
 745	var body []string
 746	for _, line := range strings.Split(string(src), "\n") {
 747		t := strings.TrimSpace(line)
 748		if rest, ok := strings.CutPrefix(t, "#+"); ok {
 749			if k, v, ok := strings.Cut(rest, ":"); ok && k != "" && !strings.ContainsAny(k, " \t") {
 750				kw[strings.ToLower(k)] = strings.TrimSpace(v)
 751				continue
 752			}
 753		}
 754		body = append(body, line)
 755	}
 756	text := strings.TrimSpace(strings.Join(body, "\n"))
 757	if text == "" {
 758		return kw, "", nil
 759	}
 760	out, err := org.New().Parse(strings.NewReader(text), path).Write(org.NewHTMLWriter())
 761	if err != nil {
 762		return nil, "", err
 763	}
 764	return kw, template.HTML(out), nil
 765}
 766#+end_src
 767
 768- [ ] Step 5: Run the tests
 769
 770Run: =go mod tidy && go test ./internal/library/ && go vet ./...=
 771Expected: PASS.
 772
 773- [ ] Step 6: Commit
 774
 775#+begin_src sh
 776git add go.mod go.sum internal/library
 777git commit -m "Parse org keywords and body"
 778#+end_src
 779
 780* Task 5: Metadata cache
 781
 782Files:
 783- Create: =internal/library/metacache.go=
 784- Test: =internal/library/metacache_test.go=
 785
 786Interfaces:
 787- Consumes: =format.Meta= (Task 1)
 788- Produces:
 789  - =type MetaCache struct=; =OpenMetaCache(path string) (*MetaCache, error)=
 790  - =(*MetaCache).get(rel string, fi fs.FileInfo) (format.Meta, bool)=
 791  - =(*MetaCache).put(rel string, fi fs.FileInfo, m format.Meta)=
 792  - =(*MetaCache).save(keep map[string]bool) error= drops entries whose key is
 793    not in =keep=, then writes atomically.
 794
 795- [ ] Step 1: Write the failing test =internal/library/metacache_test.go=
 796
 797#+begin_src go
 798package library
 799
 800import (
 801	"io/fs"
 802	"os"
 803	"path/filepath"
 804	"testing"
 805	"time"
 806
 807	"gitbay.org/cmc/gallery/internal/format"
 808)
 809
 810func statFile(t *testing.T, body string) (string, fs.FileInfo) {
 811	t.Helper()
 812	p := filepath.Join(t.TempDir(), "a.jpg")
 813	if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
 814		t.Fatal(err)
 815	}
 816	fi, err := os.Stat(p)
 817	if err != nil {
 818		t.Fatal(err)
 819	}
 820	return p, fi
 821}
 822
 823func TestMetaCacheRoundTrip(t *testing.T) {
 824	path := filepath.Join(t.TempDir(), "sub", "meta.json")
 825	c, err := OpenMetaCache(path)
 826	if err != nil {
 827		t.Fatal(err)
 828	}
 829	_, fi := statFile(t, "x")
 830	m := format.Meta{Width: 3, Height: 2, Camera: "X-T5", Taken: time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC)}
 831	c.put("tokyo/a.jpg", fi, m)
 832	c.put("gone/b.jpg", fi, m)
 833	if err := c.save(map[string]bool{"tokyo/a.jpg": true}); err != nil {
 834		t.Fatal(err)
 835	}
 836
 837	c2, err := OpenMetaCache(path)
 838	if err != nil {
 839		t.Fatal(err)
 840	}
 841	got, ok := c2.get("tokyo/a.jpg", fi)
 842	if !ok || !got.Taken.Equal(m.Taken) || got.Camera != "X-T5" || got.Width != 3 {
 843		t.Fatalf("get = %+v, %v", got, ok)
 844	}
 845	if _, ok := c2.get("gone/b.jpg", fi); ok {
 846		t.Fatal("entry outside keep survived save")
 847	}
 848}
 849
 850func TestMetaCacheMissOnChange(t *testing.T) {
 851	c, _ := OpenMetaCache(filepath.Join(t.TempDir(), "meta.json"))
 852	p, fi := statFile(t, "x")
 853	c.put("a.jpg", fi, format.Meta{Width: 1, Height: 1})
 854
 855	if err := os.WriteFile(p, []byte("longer"), 0o644); err != nil {
 856		t.Fatal(err)
 857	}
 858	fi2, _ := os.Stat(p)
 859	if _, ok := c.get("a.jpg", fi2); ok {
 860		t.Fatal("size change should miss")
 861	}
 862
 863	later := fi.ModTime().Add(time.Hour)
 864	os.WriteFile(p, []byte("x"), 0o644)
 865	os.Chtimes(p, later, later)
 866	fi3, _ := os.Stat(p)
 867	if _, ok := c.get("a.jpg", fi3); ok {
 868		t.Fatal("mtime change should miss")
 869	}
 870}
 871
 872func TestMetaCacheCorruptStartsEmpty(t *testing.T) {
 873	path := filepath.Join(t.TempDir(), "meta.json")
 874	os.WriteFile(path, []byte("{nope"), 0o644)
 875	c, err := OpenMetaCache(path)
 876	if err != nil {
 877		t.Fatal(err)
 878	}
 879	if len(c.entries) != 0 {
 880		t.Fatalf("entries = %v", c.entries)
 881	}
 882}
 883#+end_src
 884
 885- [ ] Step 2: Run it and confirm it fails
 886
 887Run: =go test ./internal/library/ -run MetaCache=
 888Expected: FAIL, =undefined: OpenMetaCache=.
 889
 890- [ ] Step 3: Implement =internal/library/metacache.go=
 891
 892#+begin_src go
 893package library
 894
 895import (
 896	"encoding/json"
 897	"errors"
 898	"io/fs"
 899	"log"
 900	"os"
 901	"path/filepath"
 902
 903	"gitbay.org/cmc/gallery/internal/format"
 904)
 905
 906type metaEntry struct {
 907	ModTime int64       `json:"mtime"`
 908	Size    int64       `json:"size"`
 909	Meta    format.Meta `json:"meta"`
 910}
 911
 912// MetaCache persists extracted metadata keyed by album-relative path.
 913type MetaCache struct {
 914	path    string
 915	entries map[string]metaEntry
 916}
 917
 918func OpenMetaCache(path string) (*MetaCache, error) {
 919	c := &MetaCache{path: path, entries: map[string]metaEntry{}}
 920	data, err := os.ReadFile(path)
 921	if errors.Is(err, fs.ErrNotExist) {
 922		return c, nil
 923	}
 924	if err != nil {
 925		return nil, err
 926	}
 927	if err := json.Unmarshal(data, &c.entries); err != nil {
 928		log.Printf("meta cache %s unreadable, starting empty: %v", path, err)
 929		c.entries = map[string]metaEntry{}
 930	}
 931	return c, nil
 932}
 933
 934func (c *MetaCache) get(rel string, fi fs.FileInfo) (format.Meta, bool) {
 935	e, ok := c.entries[rel]
 936	if !ok || e.ModTime != fi.ModTime().UnixNano() || e.Size != fi.Size() {
 937		return format.Meta{}, false
 938	}
 939	return e.Meta, true
 940}
 941
 942func (c *MetaCache) put(rel string, fi fs.FileInfo, m format.Meta) {
 943	c.entries[rel] = metaEntry{ModTime: fi.ModTime().UnixNano(), Size: fi.Size(), Meta: m}
 944}
 945
 946func (c *MetaCache) save(keep map[string]bool) error {
 947	for k := range c.entries {
 948		if !keep[k] {
 949			delete(c.entries, k)
 950		}
 951	}
 952	data, err := json.Marshal(c.entries)
 953	if err != nil {
 954		return err
 955	}
 956	if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil {
 957		return err
 958	}
 959	tmp := c.path + ".tmp"
 960	if err := os.WriteFile(tmp, data, 0o644); err != nil {
 961		return err
 962	}
 963	return os.Rename(tmp, c.path)
 964}
 965#+end_src
 966
 967- [ ] Step 4: Run the tests
 968
 969Run: =go test ./internal/library/ && go vet ./...=
 970Expected: PASS.
 971
 972- [ ] Step 5: Commit
 973
 974#+begin_src sh
 975git add internal/library
 976git commit -m "Add metadata cache"
 977#+end_src
 978
 979* Task 6: Library model and scan
 980
 981Files:
 982- Create: =internal/library/library.go=, =internal/library/scan.go=
 983- Test: =internal/library/scan_test.go=
 984
 985Interfaces:
 986- Consumes: =format.Registry=, =format.Format=, =format.Meta= (Tasks 1-3);
 987  =parseOrg= (Task 4); =MetaCache= (Task 5)
 988- Produces:
 989  - =type Library struct { Albums []*Album; About template.HTML }=; =(*Library).Album(slug string) *Album= (nil if absent)
 990  - =type Album struct { Slug, Title, Date string; Description template.HTML; Order int; HasOrder bool; Cover *Item; Items []*Item }=
 991  - =(*Album).Item(name string) (int, *Item)= (=-1, nil= if absent); =(*Album).Year() string=
 992  - =type Item struct { Album, Name, Path string; ModTime time.Time; Size int64; Meta format.Meta; Format format.Format }=
 993  - =type Store struct=; =(*Store).Load() *Library=; =(*Store).Set(*Library)=
 994  - =Scan(root string, reg *format.Registry, cache *MetaCache) (*Library, error)=
 995
 996- [ ] Step 1: Write the failing test =internal/library/scan_test.go=
 997
 998#+begin_src go
 999package library
1000
1001import (
1002	"errors"
1003	"io"
1004	"os"
1005	"path/filepath"
1006	"strings"
1007	"testing"
1008	"time"
1009
1010	"gitbay.org/cmc/gallery/internal/format"
1011)
1012
1013type fakeFormat struct {
1014	meta  map[string]format.Meta // by base name
1015	fail  map[string]bool
1016	calls int
1017}
1018
1019func (f *fakeFormat) Name() string        { return "fake" }
1020func (f *fakeFormat) Match(p string) bool { return strings.EqualFold(filepath.Ext(p), ".jpg") }
1021func (f *fakeFormat) Kind() format.Kind   { return format.KindImage }
1022func (f *fakeFormat) Resize(string, int, io.Writer) error { return nil }
1023func (f *fakeFormat) Metadata(p string) (format.Meta, error) {
1024	f.calls++
1025	name := filepath.Base(p)
1026	if f.fail[name] {
1027		return format.Meta{}, errors.New("bad file")
1028	}
1029	if m, ok := f.meta[name]; ok {
1030		return m, nil
1031	}
1032	return format.Meta{Width: 300, Height: 200}, nil
1033}
1034
1035func day(n int) time.Time { return time.Date(2025, 3, n, 0, 0, 0, 0, time.UTC) }
1036
1037func writeTree(t *testing.T, files map[string]string) string {
1038	t.Helper()
1039	root := t.TempDir()
1040	for rel, body := range files {
1041		p := filepath.Join(root, rel)
1042		if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
1043			t.Fatal(err)
1044		}
1045		if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
1046			t.Fatal(err)
1047		}
1048	}
1049	return root
1050}
1051
1052func scanWith(t *testing.T, root string, f *fakeFormat, cache *MetaCache) *Library {
1053	t.Helper()
1054	if cache == nil {
1055		var err error
1056		if cache, err = OpenMetaCache(filepath.Join(t.TempDir(), "meta.json")); err != nil {
1057			t.Fatal(err)
1058		}
1059	}
1060	reg := &format.Registry{}
1061	reg.Register(f)
1062	lib, err := Scan(root, reg, cache)
1063	if err != nil {
1064		t.Fatal(err)
1065	}
1066	return lib
1067}
1068
1069func names(items []*Item) string {
1070	var s []string
1071	for _, it := range items {
1072		s = append(s, it.Name)
1073	}
1074	return strings.Join(s, ",")
1075}
1076
1077func slugs(as []*Album) string {
1078	var s []string
1079	for _, a := range as {
1080		s = append(s, a.Slug)
1081	}
1082	return strings.Join(s, ",")
1083}
1084
1085func TestScanSkipsAndSorts(t *testing.T) {
1086	root := writeTree(t, map[string]string{
1087		"tokyo/b.jpg":       "x",
1088		"tokyo/a.jpg":       "x",
1089		"tokyo/.hidden.jpg": "x",
1090		"tokyo/notes.txt":   "x",
1091		"tokyo/sub/c.jpg":   "x",
1092		".git/x.jpg":        "x",
1093		"img/x.jpg":         "x",
1094		"info/x.jpg":        "x",
1095		"_/x.jpg":           "x",
1096		"empty/notes.txt":   "x",
1097		"loose.jpg":         "x",
1098	})
1099	f := &fakeFormat{meta: map[string]format.Meta{
1100		"a.jpg": {Width: 300, Height: 200, Taken: day(2)},
1101		"b.jpg": {Width: 300, Height: 200, Taken: day(1)},
1102	}}
1103	lib := scanWith(t, root, f, nil)
1104
1105	if got := slugs(lib.Albums); got != "tokyo" {
1106		t.Fatalf("albums = %s", got)
1107	}
1108	a := lib.Album("tokyo")
1109	if got := names(a.Items); got != "b.jpg,a.jpg" {
1110		t.Fatalf("items = %s", got)
1111	}
1112	if a.Title != "tokyo" || a.Cover != a.Items[0] || a.Year() != "2025" {
1113		t.Fatalf("title %q cover %v year %q", a.Title, a.Cover, a.Year())
1114	}
1115	if i, it := a.Item("a.jpg"); i != 1 || it.Path != filepath.Join(root, "tokyo", "a.jpg") {
1116		t.Fatalf("Item(a.jpg) = %d, %+v", i, it)
1117	}
1118	if i, it := a.Item("nope.jpg"); i != -1 || it != nil {
1119		t.Fatal("Item(nope.jpg) should be absent")
1120	}
1121	if lib.Album("nope") != nil {
1122		t.Fatal("Album(nope) should be nil")
1123	}
1124}
1125
1126func TestScanAlbumOrg(t *testing.T) {
1127	root := writeTree(t, map[string]string{
1128		"tokyo/album.org": "#+title: Tokyo\n#+date: 2024-11\n#+cover: b.jpg\n#+sort: name\n\nA /quiet/ week.\n",
1129		"tokyo/b.jpg":     "x",
1130		"tokyo/a.jpg":     "x",
1131	})
1132	f := &fakeFormat{meta: map[string]format.Meta{
1133		"a.jpg": {Width: 1, Height: 1, Taken: day(2)},
1134		"b.jpg": {Width: 1, Height: 1, Taken: day(1)},
1135	}}
1136	a := scanWith(t, root, f, nil).Album("tokyo")
1137	if a.Title != "Tokyo" || a.Date != "2024-11" || a.Year() != "2024" {
1138		t.Fatalf("title %q date %q year %q", a.Title, a.Date, a.Year())
1139	}
1140	if got := names(a.Items); got != "a.jpg,b.jpg" {
1141		t.Fatalf("sort name: items = %s", got)
1142	}
1143	if a.Cover.Name != "b.jpg" {
1144		t.Fatalf("cover = %s", a.Cover.Name)
1145	}
1146	if !strings.Contains(string(a.Description), "<em>quiet</em>") {
1147		t.Fatalf("description = %s", a.Description)
1148	}
1149}
1150
1151func TestScanAlbumOrdering(t *testing.T) {
1152	root := writeTree(t, map[string]string{
1153		"a/album.org": "#+order: 2\n",
1154		"a/x.jpg":     "x",
1155		"b/album.org": "#+order: 1\n",
1156		"b/x.jpg":     "x",
1157		"c/album.org": "#+date: 2024\n",
1158		"c/x.jpg":     "x",
1159		"d/album.org": "#+date: 2025-06\n",
1160		"d/x.jpg":     "x",
1161		"e/x.jpg":     "x", // no album.org; newest item taken is day(1) of 2025-03
1162	})
1163	f := &fakeFormat{meta: map[string]format.Meta{"x.jpg": {Width: 1, Height: 1, Taken: day(1)}}}
1164	if got := slugs(scanWith(t, root, f, nil).Albums); got != "b,a,d,e,c" {
1165		t.Fatalf("albums = %s", got)
1166	}
1167}
1168
1169func TestScanTakenFallsBackToModTime(t *testing.T) {
1170	root := writeTree(t, map[string]string{"tokyo/a.jpg": "x"})
1171	mt := time.Date(2023, 5, 6, 7, 8, 9, 0, time.UTC)
1172	os.Chtimes(filepath.Join(root, "tokyo", "a.jpg"), mt, mt)
1173	a := scanWith(t, root, &fakeFormat{}, nil).Album("tokyo")
1174	if !a.Items[0].Meta.Taken.Equal(mt) {
1175		t.Fatalf("Taken = %v, want %v", a.Items[0].Meta.Taken, mt)
1176	}
1177}
1178
1179func TestScanSkipsFailedMetadata(t *testing.T) {
1180	root := writeTree(t, map[string]string{"tokyo/a.jpg": "x", "tokyo/bad.jpg": "x"})
1181	a := scanWith(t, root, &fakeFormat{fail: map[string]bool{"bad.jpg": true}}, nil).Album("tokyo")
1182	if got := names(a.Items); got != "a.jpg" {
1183		t.Fatalf("items = %s", got)
1184	}
1185}
1186
1187func TestScanUsesMetaCache(t *testing.T) {
1188	root := writeTree(t, map[string]string{"tokyo/a.jpg": "x", "tokyo/b.jpg": "x"})
1189	cache, _ := OpenMetaCache(filepath.Join(t.TempDir(), "meta.json"))
1190	f := &fakeFormat{}
1191	scanWith(t, root, f, cache)
1192	scanWith(t, root, f, cache)
1193	if f.calls != 2 {
1194		t.Fatalf("calls after two scans = %d, want 2", f.calls)
1195	}
1196	later := time.Now().Add(time.Hour)
1197	os.Chtimes(filepath.Join(root, "tokyo", "a.jpg"), later, later)
1198	scanWith(t, root, f, cache)
1199	if f.calls != 3 {
1200		t.Fatalf("calls after touch = %d, want 3", f.calls)
1201	}
1202}
1203
1204func TestScanAbout(t *testing.T) {
1205	root := writeTree(t, map[string]string{"about.org": "Hello /there/.\n", "tokyo/a.jpg": "x"})
1206	lib := scanWith(t, root, &fakeFormat{}, nil)
1207	if !strings.Contains(string(lib.About), "<em>there</em>") {
1208		t.Fatalf("About = %s", lib.About)
1209	}
1210	root2 := writeTree(t, map[string]string{"tokyo/a.jpg": "x"})
1211	if lib := scanWith(t, root2, &fakeFormat{}, nil); lib.About != "" {
1212		t.Fatalf("About without file = %q", lib.About)
1213	}
1214}
1215
1216func TestStore(t *testing.T) {
1217	var s Store
1218	if s.Load() != nil {
1219		t.Fatal("empty store should load nil")
1220	}
1221	lib := &Library{}
1222	s.Set(lib)
1223	if s.Load() != lib {
1224		t.Fatal("Load after Set")
1225	}
1226}
1227#+end_src
1228
1229- [ ] Step 2: Run it and confirm it fails
1230
1231Run: =go test ./internal/library/=
1232Expected: FAIL, =undefined: Scan=, =undefined: Library=, =undefined: Store=.
1233
1234- [ ] Step 3: Implement =internal/library/library.go=
1235
1236#+begin_src go
1237// Package library turns the photo folder tree into albums and items.
1238package library
1239
1240import (
1241	"html/template"
1242	"sort"
1243	"strconv"
1244	"sync/atomic"
1245	"time"
1246
1247	"gitbay.org/cmc/gallery/internal/format"
1248)
1249
1250type Library struct {
1251	Albums []*Album
1252	About  template.HTML
1253	bySlug map[string]*Album
1254}
1255
1256func (l *Library) Album(slug string) *Album {
1257	return l.bySlug[slug]
1258}
1259
1260type Album struct {
1261	Slug        string
1262	Title       string
1263	Date        string
1264	Description template.HTML
1265	Order       int
1266	HasOrder    bool
1267	Cover       *Item
1268	Items       []*Item
1269	byName      map[string]int
1270}
1271
1272func (a *Album) Item(name string) (int, *Item) {
1273	i, ok := a.byName[name]
1274	if !ok {
1275		return -1, nil
1276	}
1277	return i, a.Items[i]
1278}
1279
1280// Year is the album's #+date year, else the cover's taken year.
1281func (a *Album) Year() string {
1282	if len(a.Date) >= 4 {
1283		return a.Date[:4]
1284	}
1285	if a.Cover != nil && !a.Cover.Meta.Taken.IsZero() {
1286		return strconv.Itoa(a.Cover.Meta.Taken.Year())
1287	}
1288	return ""
1289}
1290
1291func (a *Album) sortTime() time.Time {
1292	for _, layout := range []string{"2006-01-02", "2006-01", "2006"} {
1293		if t, err := time.Parse(layout, a.Date); err == nil {
1294			return t
1295		}
1296	}
1297	var newest time.Time
1298	for _, it := range a.Items {
1299		if it.Meta.Taken.After(newest) {
1300			newest = it.Meta.Taken
1301		}
1302	}
1303	return newest
1304}
1305
1306type Item struct {
1307	Album   string
1308	Name    string
1309	Path    string
1310	ModTime time.Time
1311	Size    int64
1312	Meta    format.Meta
1313	Format  format.Format
1314}
1315
1316// Store holds the current Library snapshot.
1317type Store struct {
1318	p atomic.Pointer[Library]
1319}
1320
1321func (s *Store) Load() *Library  { return s.p.Load() }
1322func (s *Store) Set(l *Library) { s.p.Store(l) }
1323
1324// sortAlbums puts albums with #+order first (ascending), then the rest newest first.
1325func sortAlbums(as []*Album) {
1326	sort.SliceStable(as, func(i, j int) bool {
1327		a, b := as[i], as[j]
1328		if a.HasOrder != b.HasOrder {
1329			return a.HasOrder
1330		}
1331		if a.HasOrder && a.Order != b.Order {
1332			return a.Order < b.Order
1333		}
1334		if !a.HasOrder {
1335			if ta, tb := a.sortTime(), b.sortTime(); !ta.Equal(tb) {
1336				return ta.After(tb)
1337			}
1338		}
1339		return a.Slug < b.Slug
1340	})
1341}
1342
1343func sortItems(items []*Item, by string) {
1344	sort.SliceStable(items, func(i, j int) bool {
1345		a, b := items[i], items[j]
1346		if by == "taken" && !a.Meta.Taken.Equal(b.Meta.Taken) {
1347			return a.Meta.Taken.Before(b.Meta.Taken)
1348		}
1349		return a.Name < b.Name
1350	})
1351}
1352#+end_src
1353
1354- [ ] Step 4: Implement =internal/library/scan.go=
1355
1356#+begin_src go
1357package library
1358
1359import (
1360	"log"
1361	"os"
1362	"path/filepath"
1363	"strconv"
1364	"strings"
1365
1366	"gitbay.org/cmc/gallery/internal/format"
1367)
1368
1369// Slugs that would collide with fixed routes.
1370var reserved = map[string]bool{"img": true, "info": true, "_": true}
1371
1372// Scan reads every album under root. Unreadable albums and files are logged and skipped.
1373func Scan(root string, reg *format.Registry, cache *MetaCache) (*Library, error) {
1374	entries, err := os.ReadDir(root)
1375	if err != nil {
1376		return nil, err
1377	}
1378	lib := &Library{bySlug: map[string]*Album{}}
1379	keep := map[string]bool{}
1380	for _, e := range entries {
1381		name := e.Name()
1382		if !e.IsDir() || strings.HasPrefix(name, ".") {
1383			continue
1384		}
1385		if reserved[name] {
1386			log.Printf("skipping album %q: reserved name", name)
1387			continue
1388		}
1389		a, err := scanAlbum(root, name, reg, cache, keep)
1390		if err != nil {
1391			log.Printf("album %s: %v", name, err)
1392			continue
1393		}
1394		if len(a.Items) == 0 {
1395			continue
1396		}
1397		lib.Albums = append(lib.Albums, a)
1398		lib.bySlug[name] = a
1399	}
1400	sortAlbums(lib.Albums)
1401
1402	if src, err := os.ReadFile(filepath.Join(root, "about.org")); err == nil {
1403		if _, html, err := parseOrg(src, "about.org"); err != nil {
1404			log.Printf("about.org: %v", err)
1405		} else {
1406			lib.About = html
1407		}
1408	}
1409	if err := cache.save(keep); err != nil {
1410		log.Printf("meta cache: %v", err)
1411	}
1412	return lib, nil
1413}
1414
1415func scanAlbum(root, slug string, reg *format.Registry, cache *MetaCache, keep map[string]bool) (*Album, error) {
1416	dir := filepath.Join(root, slug)
1417	entries, err := os.ReadDir(dir)
1418	if err != nil {
1419		return nil, err
1420	}
1421	a := &Album{Slug: slug, Title: slug, byName: map[string]int{}}
1422	sortBy, cover := "taken", ""
1423
1424	if src, err := os.ReadFile(filepath.Join(dir, "album.org")); err == nil {
1425		kw, desc, err := parseOrg(src, filepath.Join(slug, "album.org"))
1426		if err != nil {
1427			log.Printf("%s/album.org: %v", slug, err)
1428		} else {
1429			if v := kw["title"]; v != "" {
1430				a.Title = v
1431			}
1432			a.Date, a.Description, cover = kw["date"], desc, kw["cover"]
1433			if v := kw["order"]; v != "" {
1434				if n, err := strconv.Atoi(v); err == nil {
1435					a.Order, a.HasOrder = n, true
1436				} else {
1437					log.Printf("%s/album.org: bad order %q", slug, v)
1438				}
1439			}
1440			switch v := kw["sort"]; v {
1441			case "", "taken":
1442			case "name":
1443				sortBy = v
1444			default:
1445				log.Printf("%s/album.org: bad sort %q", slug, v)
1446			}
1447		}
1448	}
1449
1450	for _, e := range entries {
1451		name := e.Name()
1452		if e.IsDir() || strings.HasPrefix(name, ".") {
1453			continue
1454		}
1455		path := filepath.Join(dir, name)
1456		f := reg.Lookup(path)
1457		if f == nil {
1458			continue
1459		}
1460		fi, err := e.Info()
1461		if err != nil {
1462			log.Printf("%s/%s: %v", slug, name, err)
1463			continue
1464		}
1465		rel := slug + "/" + name
1466		m, ok := cache.get(rel, fi)
1467		if !ok {
1468			if m, err = f.Metadata(path); err != nil {
1469				log.Printf("%s: %v", rel, err)
1470				continue
1471			}
1472			cache.put(rel, fi, m)
1473		}
1474		keep[rel] = true
1475		if m.Taken.IsZero() {
1476			m.Taken = fi.ModTime()
1477		}
1478		a.Items = append(a.Items, &Item{
1479			Album: slug, Name: name, Path: path,
1480			ModTime: fi.ModTime(), Size: fi.Size(),
1481			Meta: m, Format: f,
1482		})
1483	}
1484
1485	sortItems(a.Items, sortBy)
1486	for i, it := range a.Items {
1487		a.byName[it.Name] = i
1488	}
1489	if i, ok := a.byName[cover]; ok {
1490		a.Cover = a.Items[i]
1491	} else if len(a.Items) > 0 {
1492		if cover != "" {
1493			log.Printf("%s/album.org: cover %q not found", slug, cover)
1494		}
1495		a.Cover = a.Items[0]
1496	}
1497	return a, nil
1498}
1499#+end_src
1500
1501- [ ] Step 5: Run the tests
1502
1503Run: =go test ./internal/library/ && go vet ./...=
1504Expected: PASS.
1505
1506- [ ] Step 6: Commit
1507
1508#+begin_src sh
1509git add internal/library
1510git commit -m "Scan photo folders into albums"
1511#+end_src
1512
1513* Task 7: Filesystem watcher
1514
1515Files:
1516- Create: =internal/library/watch.go=
1517- Test: =internal/library/watch_test.go=
1518
1519Interfaces:
1520- Produces: =Watch(ctx context.Context, root string, debounce time.Duration, onChange func()) error=.
1521  Watches =root= and its non-hidden subdirectories, adds newly created
1522  directories, and calls =onChange= once per quiet period of length
1523  =debounce=. Returns after setup; stops when =ctx= is done.
1524
1525- [ ] Step 1: Add the dependency
1526
1527#+begin_src sh
1528go get github.com/fsnotify/fsnotify@latest
1529#+end_src
1530
1531- [ ] Step 2: Write the failing test =internal/library/watch_test.go=
1532
1533#+begin_src go
1534package library
1535
1536import (
1537	"context"
1538	"os"
1539	"path/filepath"
1540	"testing"
1541	"time"
1542)
1543
1544func expectCall(t *testing.T, ch <-chan struct{}, what string) {
1545	t.Helper()
1546	select {
1547	case <-ch:
1548	case <-time.After(3 * time.Second):
1549		t.Fatalf("no onChange after %s", what)
1550	}
1551}
1552
1553func TestWatchDebouncesAndFollowsNewDirs(t *testing.T) {
1554	root := writeTree(t, map[string]string{"tokyo/a.jpg": "x"})
1555	ctx, cancel := context.WithCancel(context.Background())
1556	defer cancel()
1557
1558	calls := make(chan struct{}, 10)
1559	if err := Watch(ctx, root, 100*time.Millisecond, func() { calls <- struct{}{} }); err != nil {
1560		t.Fatal(err)
1561	}
1562
1563	for _, n := range []string{"b.jpg", "c.jpg", "d.jpg"} {
1564		os.WriteFile(filepath.Join(root, "tokyo", n), []byte("x"), 0o644)
1565	}
1566	expectCall(t, calls, "writes in existing album")
1567	select {
1568	case <-calls:
1569		t.Fatal("burst of writes produced more than one call")
1570	case <-time.After(300 * time.Millisecond):
1571	}
1572
1573	os.Mkdir(filepath.Join(root, "iceland"), 0o755)
1574	expectCall(t, calls, "new album dir")
1575	time.Sleep(150 * time.Millisecond) // let the watcher add the new dir
1576	os.WriteFile(filepath.Join(root, "iceland", "a.jpg"), []byte("x"), 0o644)
1577	expectCall(t, calls, "write in new album")
1578}
1579#+end_src
1580
1581- [ ] Step 3: Run it and confirm it fails
1582
1583Run: =go test ./internal/library/ -run Watch=
1584Expected: FAIL, =undefined: Watch=.
1585
1586- [ ] Step 4: Implement =internal/library/watch.go=
1587
1588#+begin_src go
1589package library
1590
1591import (
1592	"context"
1593	"log"
1594	"os"
1595	"path/filepath"
1596	"strings"
1597	"time"
1598
1599	"github.com/fsnotify/fsnotify"
1600)
1601
1602// Watch calls onChange after filesystem activity under root has been quiet for debounce.
1603func Watch(ctx context.Context, root string, debounce time.Duration, onChange func()) error {
1604	w, err := fsnotify.NewWatcher()
1605	if err != nil {
1606		return err
1607	}
1608	if err := addTree(w, root); err != nil {
1609		w.Close()
1610		return err
1611	}
1612	go func() {
1613		defer w.Close()
1614		timer := time.NewTimer(debounce)
1615		timer.Stop()
1616		for {
1617			select {
1618			case <-ctx.Done():
1619				return
1620			case ev, ok := <-w.Events:
1621				if !ok {
1622					return
1623				}
1624				if ev.Has(fsnotify.Create) {
1625					if fi, err := os.Stat(ev.Name); err == nil && fi.IsDir() {
1626						if err := w.Add(ev.Name); err != nil {
1627							log.Printf("watch %s: %v", ev.Name, err)
1628						}
1629					}
1630				}
1631				timer.Reset(debounce)
1632			case err, ok := <-w.Errors:
1633				if !ok {
1634					return
1635				}
1636				log.Printf("watch: %v", err)
1637			case <-timer.C:
1638				onChange()
1639			}
1640		}
1641	}()
1642	return nil
1643}
1644
1645func addTree(w *fsnotify.Watcher, root string) error {
1646	if err := w.Add(root); err != nil {
1647		return err
1648	}
1649	entries, err := os.ReadDir(root)
1650	if err != nil {
1651		return err
1652	}
1653	for _, e := range entries {
1654		if e.IsDir() && !strings.HasPrefix(e.Name(), ".") {
1655			if err := w.Add(filepath.Join(root, e.Name())); err != nil {
1656				return err
1657			}
1658		}
1659	}
1660	return nil
1661}
1662#+end_src
1663
1664=timer.Reset= without draining is correct because =go.mod= declares Go ≥ 1.23
1665(unbuffered timer channels).
1666
1667- [ ] Step 5: Run the tests
1668
1669Run: =go mod tidy && go test -race ./internal/library/ && go vet ./...=
1670Expected: PASS.
1671
1672- [ ] Step 6: Commit
1673
1674#+begin_src sh
1675git add go.mod go.sum internal/library
1676git commit -m "Rescan on filesystem changes"
1677#+end_src
1678
1679* Task 8: Renderer and derivative cache
1680
1681Files:
1682- Create: =internal/render/render.go=
1683- Test: =internal/render/render_test.go=
1684
1685Interfaces:
1686- Consumes: =library.Item=, =library.Library= (Task 6); =format.Format= (Task 1)
1687- Produces:
1688  - =var Widths = []int{480, 960, 1600, 2400}=; =Allowed(w int) bool=
1689  - =New(cacheDir string, concurrency int) *Renderer= (images under =<cacheDir>/img=)
1690  - =(*Renderer).Path(it *library.Item, w int) string=
1691  - =(*Renderer).Get(it *library.Item, w int) (string, error)= returns the path of a finished file
1692  - =(*Renderer).Prune(lib *library.Library) error=
1693
1694- [ ] Step 1: Add the dependency
1695
1696#+begin_src sh
1697go get golang.org/x/sync@latest
1698#+end_src
1699
1700- [ ] Step 2: Write the failing test =internal/render/render_test.go=
1701
1702#+begin_src go
1703package render
1704
1705import (
1706	"errors"
1707	"io"
1708	"os"
1709	"path/filepath"
1710	"sync"
1711	"sync/atomic"
1712	"testing"
1713	"time"
1714
1715	"gitbay.org/cmc/gallery/internal/format"
1716	"gitbay.org/cmc/gallery/internal/library"
1717)
1718
1719type fakeFormat struct {
1720	calls atomic.Int32
1721	width atomic.Int32
1722	fail  bool
1723}
1724
1725func (f *fakeFormat) Name() string                              { return "fake" }
1726func (f *fakeFormat) Match(string) bool                         { return true }
1727func (f *fakeFormat) Kind() format.Kind                         { return format.KindImage }
1728func (f *fakeFormat) Metadata(string) (format.Meta, error)      { return format.Meta{}, nil }
1729func (f *fakeFormat) Resize(_ string, w int, dst io.Writer) error {
1730	f.calls.Add(1)
1731	f.width.Store(int32(w))
1732	time.Sleep(20 * time.Millisecond)
1733	io.WriteString(dst, "partial")
1734	if f.fail {
1735		return errors.New("magick failed")
1736	}
1737	io.WriteString(dst, "-done")
1738	return nil
1739}
1740
1741func item(f format.Format) *library.Item {
1742	return &library.Item{
1743		Album: "tokyo", Name: "a.jpg", Path: "/src/a.jpg",
1744		ModTime: time.Unix(100, 0), Size: 42,
1745		Meta:   format.Meta{Width: 3000, Height: 2000},
1746		Format: f,
1747	}
1748}
1749
1750func TestAllowed(t *testing.T) {
1751	for _, w := range Widths {
1752		if !Allowed(w) {
1753			t.Errorf("%d should be allowed", w)
1754		}
1755	}
1756	for _, w := range []int{0, 1, 961, 4000} {
1757		if Allowed(w) {
1758			t.Errorf("%d should not be allowed", w)
1759		}
1760	}
1761}
1762
1763func TestGetGeneratesOnce(t *testing.T) {
1764	f := &fakeFormat{}
1765	r := New(t.TempDir(), 4)
1766	it := item(f)
1767
1768	var wg sync.WaitGroup
1769	for range 10 {
1770		wg.Add(1)
1771		go func() {
1772			defer wg.Done()
1773			if _, err := r.Get(it, 960); err != nil {
1774				t.Error(err)
1775			}
1776		}()
1777	}
1778	wg.Wait()
1779	p, err := r.Get(it, 960)
1780	if err != nil {
1781		t.Fatal(err)
1782	}
1783	if n := f.calls.Load(); n != 1 {
1784		t.Fatalf("Resize calls = %d, want 1", n)
1785	}
1786	if b, _ := os.ReadFile(p); string(b) != "partial-done" {
1787		t.Fatalf("content = %q", b)
1788	}
1789	if p != r.Path(it, 960) {
1790		t.Fatalf("Get path %s != Path %s", p, r.Path(it, 960))
1791	}
1792}
1793
1794func TestGetRejectsWidth(t *testing.T) {
1795	if _, err := New(t.TempDir(), 1).Get(item(&fakeFormat{}), 961); err == nil {
1796		t.Fatal("want error")
1797	}
1798}
1799
1800func TestGetClampsToSource(t *testing.T) {
1801	f := &fakeFormat{}
1802	it := item(f)
1803	it.Meta.Width = 700
1804	if _, err := New(t.TempDir(), 1).Get(it, 960); err != nil {
1805		t.Fatal(err)
1806	}
1807	if w := f.width.Load(); w != 700 {
1808		t.Fatalf("resized to %d, want 700", w)
1809	}
1810}
1811
1812func TestGetFailureLeavesNothing(t *testing.T) {
1813	f := &fakeFormat{fail: true}
1814	r := New(t.TempDir(), 1)
1815	it := item(f)
1816	if _, err := r.Get(it, 480); err == nil {
1817		t.Fatal("want error")
1818	}
1819	entries, _ := os.ReadDir(filepath.Dir(r.Path(it, 480)))
1820	if len(entries) != 0 {
1821		t.Fatalf("left behind: %v", entries)
1822	}
1823	r.Get(it, 480)
1824	if n := f.calls.Load(); n != 2 {
1825		t.Fatalf("failure was cached: calls = %d", n)
1826	}
1827}
1828
1829func TestPathChangesWithSource(t *testing.T) {
1830	r := New("/cache", 1)
1831	a := item(nil)
1832	b := item(nil)
1833	b.ModTime = time.Unix(200, 0)
1834	if r.Path(a, 480) == r.Path(b, 480) {
1835		t.Fatal("path must change with mtime")
1836	}
1837	want := filepath.Join("/cache", "img", "tokyo", "a.jpg", "100000000000-42", "480.jpg")
1838	if got := r.Path(a, 480); got != want {
1839		t.Fatalf("Path = %s, want %s", got, want)
1840	}
1841}
1842
1843func TestPrune(t *testing.T) {
1844	f := &fakeFormat{}
1845	r := New(t.TempDir(), 1)
1846	cur := item(f)
1847	old := item(f)
1848	old.ModTime = time.Unix(50, 0)
1849	gone := item(f)
1850	gone.Name = "gone.jpg"
1851	for _, it := range []*library.Item{cur, old, gone} {
1852		if _, err := r.Get(it, 480); err != nil {
1853			t.Fatal(err)
1854		}
1855	}
1856	lib := &library.Library{Albums: []*library.Album{{Slug: "tokyo", Items: []*library.Item{cur}}}}
1857	if err := r.Prune(lib); err != nil {
1858		t.Fatal(err)
1859	}
1860	if _, err := os.Stat(r.Path(cur, 480)); err != nil {
1861		t.Fatalf("current derivative removed: %v", err)
1862	}
1863	for _, it := range []*library.Item{old, gone} {
1864		if _, err := os.Stat(filepath.Dir(r.Path(it, 480))); !os.IsNotExist(err) {
1865			t.Fatalf("stale dir kept: %s", filepath.Dir(r.Path(it, 480)))
1866		}
1867	}
1868}
1869#+end_src
1870
1871- [ ] Step 3: Run it and confirm it fails
1872
1873Run: =go test ./internal/render/=
1874Expected: FAIL, =undefined: New=, =undefined: Widths=.
1875
1876- [ ] Step 4: Implement =internal/render/render.go=
1877
1878#+begin_src go
1879// Package render produces and caches resized copies of library items.
1880package render
1881
1882import (
1883	"fmt"
1884	"os"
1885	"path/filepath"
1886	"slices"
1887	"strconv"
1888
1889	"golang.org/x/sync/singleflight"
1890
1891	"gitbay.org/cmc/gallery/internal/library"
1892)
1893
1894var Widths = []int{480, 960, 1600, 2400}
1895
1896func Allowed(w int) bool { return slices.Contains(Widths, w) }
1897
1898type Renderer struct {
1899	root  string
1900	sem   chan struct{}
1901	group singleflight.Group
1902}
1903
1904func New(cacheDir string, concurrency int) *Renderer {
1905	return &Renderer{
1906		root: filepath.Join(cacheDir, "img"),
1907		sem:  make(chan struct{}, concurrency),
1908	}
1909}
1910
1911func (r *Renderer) dir(it *library.Item) string {
1912	return filepath.Join(r.root, it.Album, it.Name, fmt.Sprintf("%d-%d", it.ModTime.UnixNano(), it.Size))
1913}
1914
1915func (r *Renderer) Path(it *library.Item, w int) string {
1916	return filepath.Join(r.dir(it), strconv.Itoa(w)+".jpg")
1917}
1918
1919// Get returns the cached derivative for it at width w, generating it if needed.
1920func (r *Renderer) Get(it *library.Item, w int) (string, error) {
1921	if !Allowed(w) {
1922		return "", fmt.Errorf("width %d not allowed", w)
1923	}
1924	p := r.Path(it, w)
1925	if _, err := os.Stat(p); err == nil {
1926		return p, nil
1927	}
1928	_, err, _ := r.group.Do(p, func() (any, error) {
1929		if _, err := os.Stat(p); err == nil {
1930			return nil, nil
1931		}
1932		r.sem <- struct{}{}
1933		defer func() { <-r.sem }()
1934		return nil, r.generate(it, w, p)
1935	})
1936	if err != nil {
1937		return "", err
1938	}
1939	return p, nil
1940}
1941
1942func (r *Renderer) generate(it *library.Item, w int, dst string) error {
1943	if it.Meta.Width > 0 && w > it.Meta.Width {
1944		w = it.Meta.Width
1945	}
1946	if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
1947		return err
1948	}
1949	tmp, err := os.CreateTemp(filepath.Dir(dst), ".tmp-*")
1950	if err != nil {
1951		return err
1952	}
1953	defer os.Remove(tmp.Name())
1954	if err := it.Format.Resize(it.Path, w, tmp); err != nil {
1955		tmp.Close()
1956		return err
1957	}
1958	if err := tmp.Close(); err != nil {
1959		return err
1960	}
1961	return os.Rename(tmp.Name(), dst)
1962}
1963
1964// Prune removes derivative directories for items no longer in lib or whose source changed.
1965func (r *Renderer) Prune(lib *library.Library) error {
1966	keep := map[string]bool{}
1967	for _, a := range lib.Albums {
1968		for _, it := range a.Items {
1969			keep[r.dir(it)] = true
1970		}
1971	}
1972	versions, err := filepath.Glob(filepath.Join(r.root, "*", "*", "*"))
1973	if err != nil {
1974		return err
1975	}
1976	for _, v := range versions {
1977		if !keep[v] {
1978			if err := os.RemoveAll(v); err != nil {
1979				return err
1980			}
1981		}
1982	}
1983	return nil
1984}
1985#+end_src
1986
1987- [ ] Step 5: Run the tests
1988
1989Run: =go mod tidy && go test -race ./internal/render/ && go vet ./...=
1990Expected: PASS.
1991
1992- [ ] Step 6: Commit
1993
1994#+begin_src sh
1995git add go.mod go.sum internal/render
1996git commit -m "Add derivative renderer with disk cache"
1997#+end_src
1998
1999* Task 9: Web server, templates and theme
2000
2001Files:
2002- Create: =internal/web/server.go=, =internal/web/funcs.go=,
2003  =internal/web/templates/{base,home,album,photo,info,error}.html=,
2004  =internal/web/assets/theme.css=
2005- Test: =internal/web/server_test.go=
2006
2007Interfaces:
2008- Consumes: =library.Store=, =library.Library=, =library.Album=,
2009  =library.Item=, =library.Scan=, =library.OpenMetaCache= (Tasks 5-6);
2010  =render.Renderer=, =render.Widths=, =render.Allowed=, =render.New= (Task 8);
2011  =format.Registry=, =format.Meta= (Task 1)
2012- Produces:
2013  - =type Options struct { Title string; ThemePath string; Templates fs.FS }=
2014  - =New(store *library.Store, rend *render.Renderer, opt Options) (http.Handler, error)=
2015  - Routes: =GET /=, =GET /info/=, =GET /_/theme.css=,
2016    =GET /img/{album}/{file}/{width}=, =GET /{album}/=, =GET /{album}/{file}/=,
2017    everything else 404.
2018
2019- [ ] Step 1: Write the failing test =internal/web/server_test.go=
2020
2021#+begin_src go
2022package web
2023
2024import (
2025	"errors"
2026	"io"
2027	"net/http"
2028	"net/http/httptest"
2029	"os"
2030	"path/filepath"
2031	"strings"
2032	"testing"
2033	"time"
2034
2035	"gitbay.org/cmc/gallery/internal/format"
2036	"gitbay.org/cmc/gallery/internal/library"
2037	"gitbay.org/cmc/gallery/internal/render"
2038)
2039
2040type fakeFormat struct{ fail bool }
2041
2042func (fakeFormat) Name() string        { return "fake" }
2043func (fakeFormat) Match(p string) bool { return strings.HasSuffix(p, ".jpg") }
2044func (fakeFormat) Kind() format.Kind   { return format.KindImage }
2045func (fakeFormat) Metadata(string) (format.Meta, error) {
2046	return format.Meta{
2047		Width: 3000, Height: 2000,
2048		Taken:  time.Date(2025, 3, 14, 0, 0, 0, 0, time.UTC),
2049		Camera: "X-T5", Aperture: "f/2", Shutter: "1/125", ISO: 1600,
2050	}, nil
2051}
2052func (f fakeFormat) Resize(_ string, _ int, dst io.Writer) error {
2053	if f.fail {
2054		return errors.New("magick failed")
2055	}
2056	_, err := io.WriteString(dst, "JPEGDATA")
2057	return err
2058}
2059
2060type setup struct {
2061	fail  bool
2062	about bool
2063	opt   Options
2064}
2065
2066func newServer(t *testing.T, s setup) http.Handler {
2067	t.Helper()
2068	root := t.TempDir()
2069	files := map[string]string{
2070		"tokyo/a.jpg":     "x",
2071		"tokyo/b.jpg":     "x",
2072		"tokyo/album.org": "#+title: Tokyo\n#+date: 2025-03\n",
2073	}
2074	if s.about {
2075		files["about.org"] = "Hello /there/.\n"
2076	}
2077	for rel, body := range files {
2078		p := filepath.Join(root, rel)
2079		os.MkdirAll(filepath.Dir(p), 0o755)
2080		os.WriteFile(p, []byte(body), 0o644)
2081	}
2082	reg := &format.Registry{}
2083	reg.Register(fakeFormat{fail: s.fail})
2084	cache, err := library.OpenMetaCache(filepath.Join(t.TempDir(), "meta.json"))
2085	if err != nil {
2086		t.Fatal(err)
2087	}
2088	lib, err := library.Scan(root, reg, cache)
2089	if err != nil {
2090		t.Fatal(err)
2091	}
2092	store := &library.Store{}
2093	store.Set(lib)
2094	if s.opt.Title == "" {
2095		s.opt.Title = "gallery"
2096	}
2097	h, err := New(store, render.New(t.TempDir(), 2), s.opt)
2098	if err != nil {
2099		t.Fatal(err)
2100	}
2101	return h
2102}
2103
2104func get(h http.Handler, path string) *httptest.ResponseRecorder {
2105	rec := httptest.NewRecorder()
2106	h.ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
2107	return rec
2108}
2109
2110func TestPages(t *testing.T) {
2111	h := newServer(t, setup{about: true})
2112	for _, tc := range []struct {
2113		path   string
2114		code   int
2115		want   []string
2116		reject []string
2117	}{
2118		{"/", 200, []string{`href="/tokyo/"`, `data-ar="1.5000"`, "Tokyo", "2025", `href="/info/"`}, nil},
2119		{"/tokyo/", 200, []string{`href="/tokyo/a.jpg/"`, `href="/tokyo/b.jpg/"`, `/img/tokyo/a.jpg/960.jpg?v=`, `480w`, `class="on"`}, nil},
2120		{"/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"`}},
2121		{"/tokyo/b.jpg/", 200, []string{`rel="prev" href="/tokyo/a.jpg/"`, "2 / 2"}, []string{`rel="next"`}},
2122		{"/info/", 200, []string{"<em>there</em>"}, nil},
2123		{"/nope/", 404, []string{"404"}, nil},
2124		{"/tokyo/nope.jpg/", 404, []string{"404"}, nil},
2125		{"/tokyo/a.jpg/extra/", 404, nil, nil},
2126	} {
2127		rec := get(h, tc.path)
2128		if rec.Code != tc.code {
2129			t.Errorf("%s: code %d, want %d", tc.path, rec.Code, tc.code)
2130			continue
2131		}
2132		body := rec.Body.String()
2133		for _, w := range tc.want {
2134			if !strings.Contains(body, w) {
2135				t.Errorf("%s: missing %q", tc.path, w)
2136			}
2137		}
2138		for _, r := range tc.reject {
2139			if strings.Contains(body, r) {
2140				t.Errorf("%s: unexpected %q", tc.path, r)
2141			}
2142		}
2143	}
2144}
2145
2146func TestInfoHiddenWithoutAbout(t *testing.T) {
2147	h := newServer(t, setup{})
2148	if strings.Contains(get(h, "/").Body.String(), `href="/info/"`) {
2149		t.Error("nav links Info without about.org")
2150	}
2151	if code := get(h, "/info/").Code; code != 404 {
2152		t.Errorf("/info/ = %d, want 404", code)
2153	}
2154}
2155
2156func TestImages(t *testing.T) {
2157	h := newServer(t, setup{})
2158	rec := get(h, "/img/tokyo/a.jpg/960.jpg?v=1")
2159	if rec.Code != 200 || rec.Body.String() != "JPEGDATA" {
2160		t.Fatalf("code %d body %q", rec.Code, rec.Body.String())
2161	}
2162	if cc := rec.Header().Get("Cache-Control"); cc != "public, max-age=31536000, immutable" {
2163		t.Errorf("Cache-Control = %q", cc)
2164	}
2165	if ct := rec.Header().Get("Content-Type"); ct != "image/jpeg" {
2166		t.Errorf("Content-Type = %q", ct)
2167	}
2168	for path, code := range map[string]int{
2169		"/img/tokyo/a.jpg/961.jpg":      400,
2170		"/img/tokyo/a.jpg/960.png":      400,
2171		"/img/tokyo/a.jpg/big.jpg":      400,
2172		"/img/nope/a.jpg/960.jpg":       404,
2173		"/img/tokyo/nope.jpg/960.jpg":   404,
2174	} {
2175		if got := get(h, path).Code; got != code {
2176			t.Errorf("%s: %d, want %d", path, got, code)
2177		}
2178	}
2179	// The mux may 404 or redirect these; either way no file is served.
2180	for _, path := range []string{"/img/%2e%2e/a.jpg/960.jpg", "/img/tokyo/..%2fa.jpg/960.jpg"} {
2181		if rec := get(h, path); rec.Code == 200 {
2182			t.Errorf("%s: served %q", path, rec.Body.String())
2183		}
2184	}
2185}
2186
2187func TestImageRenderFailure(t *testing.T) {
2188	h := newServer(t, setup{fail: true})
2189	if code := get(h, "/img/tokyo/a.jpg/480.jpg").Code; code != 500 {
2190		t.Fatalf("code %d, want 500", code)
2191	}
2192}
2193
2194func TestTheme(t *testing.T) {
2195	rec := get(newServer(t, setup{}), "/_/theme.css")
2196	if rec.Code != 200 || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/css") || !strings.Contains(rec.Body.String(), "--bg") {
2197		t.Fatalf("code %d type %q", rec.Code, rec.Header().Get("Content-Type"))
2198	}
2199
2200	custom := filepath.Join(t.TempDir(), "theme.css")
2201	os.WriteFile(custom, []byte(":root{--bg:red}"), 0o644)
2202	rec = get(newServer(t, setup{opt: Options{ThemePath: custom}}), "/_/theme.css")
2203	if rec.Body.String() != ":root{--bg:red}" {
2204		t.Fatalf("custom theme not served: %q", rec.Body.String())
2205	}
2206}
2207
2208func TestCustomTemplates(t *testing.T) {
2209	dir := t.TempDir()
2210	for _, name := range []string{"base", "home", "album", "photo", "info", "error"} {
2211		body := `{{define "content"}}custom-` + name + `{{end}}`
2212		if name == "base" {
2213			body = `{{template "content" .}}`
2214		}
2215		os.WriteFile(filepath.Join(dir, name+".html"), []byte(body), 0o644)
2216	}
2217	h := newServer(t, setup{opt: Options{Templates: os.DirFS(dir)}})
2218	if body := get(h, "/").Body.String(); body != "custom-home" {
2219		t.Fatalf("body = %q", body)
2220	}
2221}
2222#+end_src
2223
2224- [ ] Step 2: Run it and confirm it fails
2225
2226Run: =go test ./internal/web/=
2227Expected: FAIL, =undefined: New=, =undefined: Options=.
2228
2229- [ ] Step 3: Implement =internal/web/funcs.go=
2230
2231#+begin_src go
2232package web
2233
2234import (
2235	"fmt"
2236	"html/template"
2237	"net/url"
2238	"strconv"
2239	"strings"
2240
2241	"gitbay.org/cmc/gallery/internal/library"
2242	"gitbay.org/cmc/gallery/internal/render"
2243)
2244
2245var funcs = template.FuncMap{
2246	"albumURL": albumURL,
2247	"photoURL": photoURL,
2248	"imgURL":   imgURL,
2249	"srcset":   srcset,
2250	"ar":       aspect,
2251	"exif":     exifLine,
2252	"add":      func(a, b int) int { return a + b },
2253}
2254
2255func albumURL(a *library.Album) string {
2256	return "/" + url.PathEscape(a.Slug) + "/"
2257}
2258
2259func photoURL(it *library.Item) string {
2260	return "/" + url.PathEscape(it.Album) + "/" + url.PathEscape(it.Name) + "/"
2261}
2262
2263func imgURL(it *library.Item, w int) string {
2264	return fmt.Sprintf("/img/%s/%s/%d.jpg?v=%d", url.PathEscape(it.Album), url.PathEscape(it.Name), w, it.ModTime.Unix())
2265}
2266
2267// srcset lists allowed widths up to the first one that reaches the source width.
2268func srcset(it *library.Item) string {
2269	var parts []string
2270	for _, w := range render.Widths {
2271		actual := w
2272		if it.Meta.Width > 0 && w >= it.Meta.Width {
2273			actual = it.Meta.Width
2274		}
2275		parts = append(parts, fmt.Sprintf("%s %dw", imgURL(it, w), actual))
2276		if actual != w {
2277			break
2278		}
2279	}
2280	return strings.Join(parts, ", ")
2281}
2282
2283func aspect(it *library.Item) string {
2284	if it.Meta.Height == 0 {
2285		return "1"
2286	}
2287	return strconv.FormatFloat(float64(it.Meta.Width)/float64(it.Meta.Height), 'f', 4, 64)
2288}
2289
2290func exifLine(it *library.Item) string {
2291	m := it.Meta
2292	var parts []string
2293	for _, s := range []string{m.Camera, m.Lens, m.Aperture, m.Shutter} {
2294		if s != "" {
2295			parts = append(parts, s)
2296		}
2297	}
2298	if m.ISO > 0 {
2299		parts = append(parts, "ISO "+strconv.Itoa(m.ISO))
2300	}
2301	return strings.Join(parts, " · ")
2302}
2303#+end_src
2304
2305- [ ] Step 4: Implement =internal/web/server.go=
2306
2307#+begin_src go
2308// Package web serves gallery pages and images.
2309package web
2310
2311import (
2312	"bytes"
2313	"embed"
2314	"fmt"
2315	"html/template"
2316	"io/fs"
2317	"log"
2318	"net/http"
2319	"os"
2320	"strconv"
2321	"strings"
2322
2323	"gitbay.org/cmc/gallery/internal/library"
2324	"gitbay.org/cmc/gallery/internal/render"
2325)
2326
2327//go:embed templates/*.html
2328var embeddedTemplates embed.FS
2329
2330//go:embed assets/theme.css
2331var embeddedTheme []byte
2332
2333type Options struct {
2334	Title     string
2335	ThemePath string // replaces the embedded theme.css
2336	Templates fs.FS  // replaces the embedded templates; same file names
2337}
2338
2339var pageNames = []string{"home", "album", "photo", "info", "error"}
2340
2341type server struct {
2342	store *library.Store
2343	rend  *render.Renderer
2344	title string
2345	theme []byte
2346	pages map[string]*template.Template
2347}
2348
2349type page struct {
2350	Title   string
2351	Lib     *library.Library
2352	Current string
2353	Album   *library.Album
2354	Item    *library.Item
2355	Index   int
2356	Prev    *library.Item
2357	Next    *library.Item
2358	Code    int
2359	Message string
2360}
2361
2362func New(store *library.Store, rend *render.Renderer, opt Options) (http.Handler, error) {
2363	s := &server{store: store, rend: rend, title: opt.Title, theme: embeddedTheme, pages: map[string]*template.Template{}}
2364	if opt.ThemePath != "" {
2365		b, err := os.ReadFile(opt.ThemePath)
2366		if err != nil {
2367			return nil, err
2368		}
2369		s.theme = b
2370	}
2371	tfs := opt.Templates
2372	if tfs == nil {
2373		sub, err := fs.Sub(embeddedTemplates, "templates")
2374		if err != nil {
2375			return nil, err
2376		}
2377		tfs = sub
2378	}
2379	for _, name := range pageNames {
2380		t, err := template.New("base.html").Funcs(funcs).ParseFS(tfs, "base.html", name+".html")
2381		if err != nil {
2382			return nil, fmt.Errorf("template %s: %w", name, err)
2383		}
2384		s.pages[name] = t
2385	}
2386
2387	mux := http.NewServeMux()
2388	mux.HandleFunc("GET /{$}", s.home)
2389	mux.HandleFunc("GET /info/{$}", s.info)
2390	mux.HandleFunc("GET /_/theme.css", s.themeCSS)
2391	mux.HandleFunc("GET /img/{album}/{file}/{width}", s.image)
2392	mux.HandleFunc("GET /{album}/{$}", s.album)
2393	mux.HandleFunc("GET /{album}/{file}/{$}", s.photo)
2394	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { s.error(w, http.StatusNotFound) })
2395	return mux, nil
2396}
2397
2398func (s *server) render(w http.ResponseWriter, name string, status int, p page) {
2399	p.Title = s.title
2400	if p.Lib == nil {
2401		p.Lib = s.store.Load()
2402	}
2403	var buf bytes.Buffer
2404	if err := s.pages[name].Execute(&buf, p); err != nil {
2405		log.Printf("render %s: %v", name, err)
2406		http.Error(w, "internal error", http.StatusInternalServerError)
2407		return
2408	}
2409	w.Header().Set("Content-Type", "text/html; charset=utf-8")
2410	w.WriteHeader(status)
2411	buf.WriteTo(w)
2412}
2413
2414func (s *server) error(w http.ResponseWriter, code int) {
2415	s.render(w, "error", code, page{Code: code, Message: http.StatusText(code)})
2416}
2417
2418func (s *server) home(w http.ResponseWriter, r *http.Request) {
2419	s.render(w, "home", http.StatusOK, page{})
2420}
2421
2422func (s *server) info(w http.ResponseWriter, r *http.Request) {
2423	lib := s.store.Load()
2424	if lib.About == "" {
2425		s.error(w, http.StatusNotFound)
2426		return
2427	}
2428	s.render(w, "info", http.StatusOK, page{Lib: lib, Current: "info"})
2429}
2430
2431func (s *server) album(w http.ResponseWriter, r *http.Request) {
2432	lib := s.store.Load()
2433	a := lib.Album(r.PathValue("album"))
2434	if a == nil {
2435		s.error(w, http.StatusNotFound)
2436		return
2437	}
2438	s.render(w, "album", http.StatusOK, page{Lib: lib, Current: a.Slug, Album: a})
2439}
2440
2441func (s *server) photo(w http.ResponseWriter, r *http.Request) {
2442	lib := s.store.Load()
2443	a := lib.Album(r.PathValue("album"))
2444	if a == nil {
2445		s.error(w, http.StatusNotFound)
2446		return
2447	}
2448	i, it := a.Item(r.PathValue("file"))
2449	if it == nil {
2450		s.error(w, http.StatusNotFound)
2451		return
2452	}
2453	p := page{Lib: lib, Current: a.Slug, Album: a, Item: it, Index: i}
2454	if i > 0 {
2455		p.Prev = a.Items[i-1]
2456	}
2457	if i < len(a.Items)-1 {
2458		p.Next = a.Items[i+1]
2459	}
2460	s.render(w, "photo", http.StatusOK, p)
2461}
2462
2463func (s *server) image(w http.ResponseWriter, r *http.Request) {
2464	lib := s.store.Load()
2465	a := lib.Album(r.PathValue("album"))
2466	if a == nil {
2467		http.NotFound(w, r)
2468		return
2469	}
2470	_, it := a.Item(r.PathValue("file"))
2471	if it == nil {
2472		http.NotFound(w, r)
2473		return
2474	}
2475	ws, ok := strings.CutSuffix(r.PathValue("width"), ".jpg")
2476	n, err := strconv.Atoi(ws)
2477	if !ok || err != nil || !render.Allowed(n) {
2478		http.Error(w, "bad width", http.StatusBadRequest)
2479		return
2480	}
2481	p, err := s.rend.Get(it, n)
2482	if err != nil {
2483		log.Printf("render %s/%s@%d: %v", it.Album, it.Name, n, err)
2484		http.Error(w, "render failed", http.StatusInternalServerError)
2485		return
2486	}
2487	w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
2488	w.Header().Set("Content-Type", "image/jpeg")
2489	http.ServeFile(w, r, p)
2490}
2491
2492func (s *server) themeCSS(w http.ResponseWriter, r *http.Request) {
2493	w.Header().Set("Content-Type", "text/css; charset=utf-8")
2494	w.Header().Set("Cache-Control", "no-cache")
2495	w.Write(s.theme)
2496}
2497#+end_src
2498
2499- [ ] Step 5: Create the templates
2500
2501=internal/web/templates/base.html=:
2502
2503#+begin_src html
2504<!doctype html>
2505<html lang="en">
2506<head>
2507<meta charset="utf-8">
2508<meta name="viewport" content="width=device-width, initial-scale=1">
2509<title>{{block "title" .}}{{.Title}}{{end}}</title>
2510<link rel="stylesheet" href="/_/theme.css">
2511</head>
2512<body class="{{block "bodyclass" .}}{{end}}">
2513<nav class="site">
2514  <a class="brand" href="/">{{.Title}}</a>
2515  {{- range .Lib.Albums}}
2516  <a href="{{albumURL .}}"{{if eq .Slug $.Current}} class="on"{{end}}>{{.Title}}</a>
2517  {{- end}}
2518  {{- if .Lib.About}}
2519  <a href="/info/"{{if eq .Current "info"}} class="on"{{end}}>Info</a>
2520  {{- end}}
2521</nav>
2522<main>{{template "content" .}}</main>
2523</body>
2524</html>
2525#+end_src
2526
2527=internal/web/templates/home.html=:
2528
2529#+begin_src html
2530{{define "content"}}
2531<div class="rows captioned">
2532{{- range $a := .Lib.Albums}}{{$c := $a.Cover}}
2533  <a class="tile" href="{{albumURL $a}}" data-ar="{{ar $c}}" style="--ar:{{ar $c}}">
2534    <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}}">
2535    <span class="label">{{$a.Title}}{{with $a.Year}} · {{.}}{{end}}</span>
2536  </a>
2537{{- end}}
2538</div>
2539{{end}}
2540#+end_src
2541
2542=internal/web/templates/album.html=:
2543
2544#+begin_src html
2545{{define "title"}}{{.Album.Title}} · {{.Title}}{{end}}
2546{{define "content"}}
2547<header class="title">
2548  <h1>{{.Album.Title}}</h1>
2549  <span>{{with .Album.Year}}{{.}} · {{end}}{{len .Album.Items}}</span>
2550</header>
2551{{with .Album.Description}}<div class="desc">{{.}}</div>{{end}}
2552<div class="rows">
2553{{- range .Album.Items}}
2554  <a class="tile" href="{{photoURL .}}" data-ar="{{ar .}}" style="--ar:{{ar .}}">
2555    <img src="{{imgURL . 960}}" srcset="{{srcset .}}" sizes="(max-width: 600px) 100vw, 33vw" width="{{.Meta.Width}}" height="{{.Meta.Height}}" loading="lazy" alt="">
2556  </a>
2557{{- end}}
2558</div>
2559{{end}}
2560#+end_src
2561
2562=internal/web/templates/photo.html=:
2563
2564#+begin_src html
2565{{define "title"}}{{.Item.Name}} · {{.Album.Title}} · {{.Title}}{{end}}
2566{{define "bodyclass"}}photo{{end}}
2567{{define "content"}}
2568<figure class="stage">
2569  {{with .Prev}}<a class="prev" rel="prev" href="{{photoURL .}}" aria-label="Previous">‹</a>{{end}}
2570  <img src="{{imgURL .Item 1600}}" srcset="{{srcset .Item}}" sizes="100vw" width="{{.Item.Meta.Width}}" height="{{.Item.Meta.Height}}" alt="">
2571  {{with .Next}}<a class="next" rel="next" href="{{photoURL .}}" aria-label="Next">›</a>{{end}}
2572</figure>
2573<footer class="meta">
2574  <span>{{.Item.Name}} — {{add .Index 1}} / {{len .Album.Items}}</span>
2575  <span>{{exif .Item}}</span>
2576</footer>
2577<a id="up" href="{{albumURL .Album}}" hidden></a>
2578{{with .Prev}}<link rel="preload" as="image" href="{{imgURL . 1600}}">{{end}}
2579{{with .Next}}<link rel="preload" as="image" href="{{imgURL . 1600}}">{{end}}
2580{{end}}
2581#+end_src
2582
2583=internal/web/templates/info.html=:
2584
2585#+begin_src html
2586{{define "title"}}Info · {{.Title}}{{end}}
2587{{define "content"}}<article class="info">{{.Lib.About}}</article>{{end}}
2588#+end_src
2589
2590=internal/web/templates/error.html=:
2591
2592#+begin_src html
2593{{define "title"}}{{.Code}} · {{.Title}}{{end}}
2594{{define "content"}}<p class="error">{{.Code}} — {{.Message}}</p>{{end}}
2595#+end_src
2596
2597- [ ] Step 6: Create =internal/web/assets/theme.css=
2598
2599#+begin_src css
2600:root {
2601  --bg: #0a0a0a;
2602  --fg: #dddddd;
2603  --muted: #777777;
2604  --accent: #ffffff;
2605  --gap: 4px;
2606  --row-height: 320px;
2607  --font: "Helvetica Neue", Arial, sans-serif;
2608  --tracking: 0.25em;
2609}
2610html { box-sizing: border-box; } *, *::before, *::after { box-sizing: inherit; }
2611html, body { margin: 0; background: var(--bg); color: var(--fg); font: 13px/1.5 var(--font); }
2612a { color: inherit; text-decoration: none; }
2613
2614nav.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); }
2615nav.site .brand { color: var(--accent); font-weight: 600; margin-right: auto; }
2616nav.site a:hover, nav.site a.on { color: var(--accent); }
2617
2618.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); }
2619.title h1 { margin: 0; font: inherit; color: var(--fg); }
2620.desc { padding: 0 20px 16px; max-width: 60ch; color: var(--muted); }
2621
2622.rows { display: flex; flex-wrap: wrap; gap: var(--gap); padding: 0 var(--gap) var(--gap); }
2623.tile { display: block; position: relative; height: var(--row-height); width: calc(var(--row-height) * var(--ar)); max-width: 100%; }
2624.tile img { display: block; width: 100%; height: 100%; object-fit: contain; }
2625.captioned { row-gap: 36px; padding-top: 4px; padding-bottom: 40px; }
2626.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; }
2627
2628body.photo { display: flex; flex-direction: column; min-height: 100vh; }
2629body.photo main { flex: 1; display: flex; flex-direction: column; }
2630.stage { flex: 1; margin: 0; display: flex; align-items: center; justify-content: center; position: relative; padding: 0 48px; }
2631.stage img { max-width: 100%; max-height: calc(100vh - 120px); width: auto; height: auto; }
2632.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); }
2633.stage .prev { left: 0; }
2634.stage .next { right: 0; }
2635.stage .prev:hover, .stage .next:hover { color: var(--accent); }
2636.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); }
2637.meta span:first-child { color: var(--fg); }
2638
2639.info { padding: 0 20px 40px; max-width: 60ch; }
2640.error { padding: 40px 20px; font-size: 10px; letter-spacing: var(--tracking); text-transform: uppercase; color: var(--muted); }
2641
2642@media (max-width: 600px) {
2643  :root { --row-height: 200px; }
2644  .stage { padding: 0; }
2645  .stage .prev, .stage .next { display: none; }
2646}
2647#+end_src
2648
2649- [ ] Step 7: Run the tests
2650
2651Run: =go test ./internal/web/ && go vet ./...=
2652Expected: PASS. If =TestPages= fails on an HTML-escaped string (for example
2653=·= or =—= rendered as an entity), fix the template output, not the test.
2654
2655- [ ] Step 8: Commit
2656
2657#+begin_src sh
2658git add internal/web
2659git commit -m "Add web handlers, templates and theme"
2660#+end_src
2661
2662* Task 10: Page script (justified rows, keyboard, swipe)
2663
2664Files:
2665- Create: =internal/web/assets/gallery.js=
2666- Modify: =internal/web/server.go= (embed and route), =internal/web/templates/base.html= (script tag)
2667- Test: =internal/web/server_test.go= (add =TestScript=)
2668
2669Interfaces:
2670- Consumes: markup from Task 9: =.rows= containers of =.tile= elements with
2671  =data-ar=; =a[rel=prev]=, =a[rel=next]=, =#up= on the photo page;
2672  =--row-height= and =gap= from =theme.css=.
2673- Produces: route =GET /_/gallery.js=.
2674
2675- [ ] Step 1: Add the failing test to =internal/web/server_test.go=
2676
2677#+begin_src go
2678func TestScript(t *testing.T) {
2679	h := newServer(t, setup{})
2680	rec := get(h, "/_/gallery.js")
2681	if rec.Code != 200 || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/javascript") {
2682		t.Fatalf("code %d type %q", rec.Code, rec.Header().Get("Content-Type"))
2683	}
2684	if !strings.Contains(get(h, "/tokyo/").Body.String(), `<script src="/_/gallery.js" defer></script>`) {
2685		t.Fatal("album page missing script tag")
2686	}
2687}
2688#+end_src
2689
2690- [ ] Step 2: Run it and confirm it fails
2691
2692Run: =go test ./internal/web/ -run Script=
2693Expected: FAIL, code 404.
2694
2695- [ ] Step 3: Create =internal/web/assets/gallery.js=
2696
2697#+begin_src js
2698(() => {
2699  // Justified rows: fill each row to the container width at roughly --row-height.
2700  const layout = (el) => {
2701    const cs = getComputedStyle(el);
2702    const gap = parseFloat(cs.columnGap) || 0;
2703    const target = parseFloat(cs.getPropertyValue('--row-height')) || 320;
2704    const width = el.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
2705    let row = [], sum = 0;
2706    const flush = (last) => {
2707      let h = (width - gap * (row.length - 1)) / sum;
2708      if (last && h > target) h = target;
2709      for (const t of row) {
2710        t.style.width = Math.floor(h * t.dataset.ar) + 'px';
2711        t.style.height = Math.floor(h) + 'px';
2712      }
2713      row = [];
2714      sum = 0;
2715    };
2716    for (const t of el.children) {
2717      row.push(t);
2718      sum += +t.dataset.ar;
2719      if ((width - gap * (row.length - 1)) / sum <= target) flush(false);
2720    }
2721    if (row.length) flush(true);
2722  };
2723  const layoutAll = () => document.querySelectorAll('.rows').forEach(layout);
2724  let timer;
2725  addEventListener('resize', () => { clearTimeout(timer); timer = setTimeout(layoutAll, 100); });
2726  layoutAll();
2727
2728  const follow = (sel) => { const a = document.querySelector(sel); if (a) location.href = a.href; };
2729  addEventListener('keydown', (e) => {
2730    if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
2731    if (e.key === 'ArrowLeft') follow('a[rel=prev]');
2732    else if (e.key === 'ArrowRight') follow('a[rel=next]');
2733    else if (e.key === 'Escape') follow('#up');
2734  });
2735
2736  let x0 = null;
2737  addEventListener('touchstart', (e) => { x0 = e.touches[0].clientX; }, { passive: true });
2738  addEventListener('touchend', (e) => {
2739    if (x0 === null) return;
2740    const dx = e.changedTouches[0].clientX - x0;
2741    x0 = null;
2742    if (Math.abs(dx) > 50) follow(dx > 0 ? 'a[rel=prev]' : 'a[rel=next]');
2743  }, { passive: true });
2744})();
2745#+end_src
2746
2747- [ ] Step 4: Serve it
2748
2749In =internal/web/server.go=, below =embeddedTheme=:
2750
2751#+begin_src go
2752//go:embed assets/gallery.js
2753var embeddedScript []byte
2754#+end_src
2755
2756Register the route after =/_/theme.css=:
2757
2758#+begin_src go
2759	mux.HandleFunc("GET /_/gallery.js", s.script)
2760#+end_src
2761
2762Add the handler after =themeCSS=:
2763
2764#+begin_src go
2765func (s *server) script(w http.ResponseWriter, r *http.Request) {
2766	w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
2767	w.Header().Set("Cache-Control", "no-cache")
2768	w.Write(embeddedScript)
2769}
2770#+end_src
2771
2772In =internal/web/templates/base.html=, replace
2773
2774#+begin_src html
2775<link rel="stylesheet" href="/_/theme.css">
2776#+end_src
2777
2778with
2779
2780#+begin_src html
2781<link rel="stylesheet" href="/_/theme.css">
2782<script src="/_/gallery.js" defer></script>
2783#+end_src
2784
2785- [ ] Step 5: Run the tests
2786
2787Run: =go test ./internal/web/ && go vet ./...=
2788Expected: PASS.
2789
2790- [ ] Step 6: Commit
2791
2792#+begin_src sh
2793git add internal/web
2794git commit -m "Add justified row layout and photo navigation script"
2795#+end_src
2796
2797* Task 11: Command, wiring and local run
2798
2799Files:
2800- Create: =cmd/gallery/main.go=
2801- Modify: =README.org=
2802
2803Interfaces:
2804- Consumes: =format.CheckTools=, =format.Default= (Task 3);
2805  =library.OpenMetaCache=, =library.Scan=, =library.Store=, =library.Watch=
2806  (Tasks 5-7); =render.New=, =(*Renderer).Prune= (Task 8);
2807  =web.New=, =web.Options= (Task 9)
2808- Produces: the =gallery= binary with flags =-photos -cache -addr -theme
2809  -templates -title= and =GALLERY_*= environment fallbacks.
2810
2811- [ ] Step 1: Implement =cmd/gallery/main.go=
2812
2813#+begin_src go
2814package main
2815
2816import (
2817	"context"
2818	"errors"
2819	"flag"
2820	"fmt"
2821	"log"
2822	"net/http"
2823	"os"
2824	"os/signal"
2825	"path/filepath"
2826	"runtime"
2827	"syscall"
2828	"time"
2829
2830	"gitbay.org/cmc/gallery/internal/format"
2831	"gitbay.org/cmc/gallery/internal/library"
2832	"gitbay.org/cmc/gallery/internal/render"
2833	"gitbay.org/cmc/gallery/internal/web"
2834)
2835
2836func env(key, def string) string {
2837	if v := os.Getenv(key); v != "" {
2838		return v
2839	}
2840	return def
2841}
2842
2843func main() {
2844	photos := flag.String("photos", env("GALLERY_PHOTOS", ""), "photo root (required)")
2845	cache := flag.String("cache", env("GALLERY_CACHE", ""), "cache directory (required)")
2846	addr := flag.String("addr", env("GALLERY_ADDR", ":8080"), "listen address")
2847	theme := flag.String("theme", env("GALLERY_THEME", ""), "replacement theme.css")
2848	templates := flag.String("templates", env("GALLERY_TEMPLATES", ""), "replacement template directory")
2849	title := flag.String("title", env("GALLERY_TITLE", "gallery"), "site name")
2850	flag.Parse()
2851	if *photos == "" || *cache == "" {
2852		fmt.Fprintln(os.Stderr, "gallery: -photos and -cache are required")
2853		flag.Usage()
2854		os.Exit(2)
2855	}
2856	if err := format.CheckTools(); err != nil {
2857		log.Fatal(err)
2858	}
2859
2860	reg := format.Default()
2861	meta, err := library.OpenMetaCache(filepath.Join(*cache, "meta.json"))
2862	if err != nil {
2863		log.Fatal(err)
2864	}
2865	rend := render.New(*cache, runtime.NumCPU())
2866	store := &library.Store{}
2867	rescan := func() error {
2868		lib, err := library.Scan(*photos, reg, meta)
2869		if err != nil {
2870			return err
2871		}
2872		store.Set(lib)
2873		if err := rend.Prune(lib); err != nil {
2874			log.Printf("prune: %v", err)
2875		}
2876		log.Printf("library: %d albums", len(lib.Albums))
2877		return nil
2878	}
2879	if err := rescan(); err != nil {
2880		log.Fatal(err)
2881	}
2882
2883	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
2884	defer stop()
2885	onChange := func() {
2886		if err := rescan(); err != nil {
2887			log.Printf("rescan: %v", err)
2888		}
2889	}
2890	if err := library.Watch(ctx, *photos, 500*time.Millisecond, onChange); err != nil {
2891		log.Fatal(err)
2892	}
2893
2894	opt := web.Options{Title: *title, ThemePath: *theme}
2895	if *templates != "" {
2896		opt.Templates = os.DirFS(*templates)
2897	}
2898	h, err := web.New(store, rend, opt)
2899	if err != nil {
2900		log.Fatal(err)
2901	}
2902
2903	srv := &http.Server{Addr: *addr, Handler: h, ReadHeaderTimeout: 10 * time.Second}
2904	go func() {
2905		<-ctx.Done()
2906		sctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
2907		defer cancel()
2908		srv.Shutdown(sctx)
2909	}()
2910	log.Printf("listening on %s", *addr)
2911	if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
2912		log.Fatal(err)
2913	}
2914}
2915#+end_src
2916
2917- [ ] Step 2: Build and run against sample photos
2918
2919#+begin_src sh
2920go build -o gallery ./cmd/gallery
2921mkdir -p photos/tokyo photos/iceland
2922for i in 1 2 3 4 5; do magick -size $((400 + i * 150))x600 plasma: photos/tokyo/t$i.jpg; done
2923for i in 1 2 3; do magick -size 900x$((400 + i * 120)) gradient:navy-orange photos/iceland/i$i.png; done
2924printf '#+title: Tokyo\n#+date: 2025-03\n\nA /quiet/ week.\n' > photos/tokyo/album.org
2925printf 'Photographs by me.\n' > photos/about.org
2926./gallery -photos ./photos -cache ./cache -addr 127.0.0.1:8080 &
2927#+end_src
2928
2929Expected log: =library: 2 albums= and =listening on 127.0.0.1:8080=.
2930
2931- [ ] Step 3: Check the endpoints
2932
2933#+begin_src sh
2934curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/
2935curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/tokyo/
2936curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/tokyo/t1.jpg/
2937curl -s -D - -o /tmp/g.jpg http://127.0.0.1:8080/img/tokyo/t1.jpg/480.jpg | grep -i cache-control
2938magick identify /tmp/g.jpg
2939#+end_src
2940
2941Expected: =200= three times; =Cache-Control: public, max-age=31536000,
2942immutable=; identify reports =JPEG 480x...=.
2943
2944- [ ] Step 4: Check the watcher
2945
2946#+begin_src sh
2947magick -size 800x500 plasma: photos/tokyo/t6.jpg
2948sleep 1
2949curl -s http://127.0.0.1:8080/tokyo/ | grep -c 'href="/tokyo/t6.jpg/"'
2950#+end_src
2951
2952Expected: =1=, and a second =library: 2 albums= log line.
2953
2954- [ ] Step 5: Check the pages in a browser
2955
2956Open =http://127.0.0.1:8080/=, =/tokyo/= and =/tokyo/t1.jpg/=, then:
2957- Rows fill the width edge to edge with 4 px gaps; last row is not stretched;
2958  nothing is cropped; resizing the window re-flows rows.
2959- On the photo page, =←= =→= move between photos, =Esc= returns to the album,
2960  the first photo has no previous arrow, the last has no next.
2961- At 375 px wide, rows are shorter and the arrows are hidden.
2962
2963Stop the server (=kill %1=).
2964
2965- [ ] Step 6: Document usage in =README.org=
2966
2967Replace the file with:
2968
2969#+begin_src org
2970,#+title: gallery
2971
2972Self-hosted photo gallery. Albums are folders; resized copies are generated on
2973request with ImageMagick and cached on disk.
2974
2975,* Requirements
2976
2977Go 1.27, ImageMagick 7 (=magick=), exiftool.
2978
2979,* Photos
2980
2981,#+begin_example
2982photos/
2983  about.org           optional; rendered as the Info page
2984  tokyo/
2985    album.org         optional
2986    DSCF0412.jpg
2987    ...
2988,#+end_example
2989
2990=album.org= keywords, all optional:
2991
2992| Keyword    | Meaning                                   | Default            |
2993|------------+-------------------------------------------+--------------------|
2994| =#+title=  | album title                               | folder name        |
2995| =#+date=   | =YYYY=, =YYYY-MM= or =YYYY-MM-DD=          | newest photo       |
2996| =#+cover=  | file name of the cover photo              | first photo        |
2997| =#+order=  | integer; ordered albums come first        | unset              |
2998| =#+sort=   | =taken= or =name=                         | =taken=            |
2999
3000Text below the keywords is shown as the album description.
3001
3002,* Run
3003
3004,#+begin_src sh
3005go build -o gallery ./cmd/gallery
3006./gallery -photos ./photos -cache ./cache -addr :8080
3007,#+end_src
3008
3009| Flag         | Env                 | Default   |
3010|--------------+---------------------+-----------|
3011| =-photos=    | =GALLERY_PHOTOS=    | required  |
3012| =-cache=     | =GALLERY_CACHE=     | required  |
3013| =-addr=      | =GALLERY_ADDR=      | =:8080=   |
3014| =-theme=     | =GALLERY_THEME=     | embedded  |
3015| =-templates= | =GALLERY_TEMPLATES= | embedded  |
3016| =-title=     | =GALLERY_TITLE=     | =gallery= |
3017
3018=-theme= replaces [[file:internal/web/assets/theme.css]]. =-templates= points
3019at a directory with the same file names as [[file:internal/web/templates]].
3020
3021See [[file:docs/specs/2026-09-25-gallery-design.org][the design spec]].
3022#+end_src
3023
3024(The commas before =#+= and =*= at line start are org escapes inside this
3025plan; the real =README.org= has none.)
3026
3027- [ ] Step 7: Run the full suite and commit
3028
3029#+begin_src sh
3030go test -race ./... && go vet ./...
3031git add cmd README.org
3032git commit -m "Add gallery command"
3033#+end_src
3034
3035* Task 12: Docker image and homelab deploy
3036
3037Files:
3038- Create: =Dockerfile=, =compose.yml=, =.dockerignore=
3039- Modify: =README.org= (Deploy section)
3040
3041Interfaces:
3042- Consumes: =cmd/gallery= (Task 11)
3043- Produces: container =gallery= on homelab at =127.0.0.1:8003=.
3044
3045- [ ] Step 1: Create =Dockerfile=
3046
3047#+begin_src dockerfile
3048FROM golang:1-alpine AS build
3049WORKDIR /src
3050COPY go.mod go.sum ./
3051RUN go mod download
3052COPY . .
3053RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /gallery ./cmd/gallery
3054
3055FROM alpine:3
3056RUN apk add --no-cache imagemagick imagemagick-jpeg imagemagick-heic imagemagick-webp exiftool
3057COPY --from=build /gallery /usr/local/bin/gallery
3058ENV GALLERY_PHOTOS=/photos GALLERY_CACHE=/cache GALLERY_ADDR=:8080
3059EXPOSE 8080
3060ENTRYPOINT ["gallery"]
3061#+end_src
3062
3063- [ ] Step 2: Create =.dockerignore=
3064
3065#+begin_example
3066.git
3067photos
3068cache
3069gallery
3070.superpowers
3071#+end_example
3072
3073- [ ] Step 3: Create =compose.yml=
3074
3075#+begin_src yaml
3076services:
3077  gallery:
3078    build: .
3079    container_name: gallery
3080    restart: unless-stopped
3081    ports:
3082      - "127.0.0.1:8003:8080"
3083    volumes:
3084      - ${GALLERY_PHOTOS_DIR:-./photos}:/photos:ro
3085      - ./cache:/cache
3086    environment:
3087      GALLERY_TITLE: ${GALLERY_TITLE:-gallery}
3088#+end_src
3089
3090- [ ] Step 4: Build and check the image locally
3091
3092#+begin_src sh
3093docker build -t gallery .
3094docker run --rm --entrypoint magick gallery -list format | grep -E '^ +(JPEG|PNG|HEIC|WEBP)'
3095docker run --rm --entrypoint exiftool gallery -ver
3096#+end_src
3097
3098Expected: rows for JPEG, PNG, HEIC and WEBP with =rw= mode, and an exiftool
3099version. If PNG is missing, add =imagemagick-png= to the =apk add= line (the
3100Alpine split varies by release) and rebuild.
3101
3102#+begin_src sh
3103docker compose up -d --build
3104curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8003/
3105curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8003/img/tokyo/t1.jpg/960.jpg
3106docker compose down
3107#+end_src
3108
3109Expected: =200= twice (using the sample =photos/= from Task 11).
3110
3111- [ ] Step 5: Add a Deploy section to =README.org=
3112
3113Append:
3114
3115#+begin_src org
3116,* Deploy
3117
3118,#+begin_src sh
3119git clone https://gitbay.org/cmc/gallery.git ~/docker/gallery
3120cd ~/docker/gallery
3121GALLERY_PHOTOS_DIR=/path/to/photos docker compose up -d --build
3122,#+end_src
3123
3124The container listens on =127.0.0.1:8003=; put a reverse proxy in front of it.
3125Photos are mounted read-only; derivatives and =meta.json= live in =./cache=.
3126#+end_src
3127
3128- [ ] Step 6: Commit, push, open the MR
3129
3130#+begin_src sh
3131go test -race ./... && go vet ./...
3132git add Dockerfile .dockerignore compose.yml README.org
3133git commit -m "Add Docker image and compose file"
3134git push -u origin v1
3135gitbay 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."
3136#+end_src
3137
3138- [ ] Step 7: Deploy to homelab (after the MR is merged)
3139
3140#+begin_src sh
3141ssh homelab 'git clone https://gitbay.org/cmc/gallery.git ~/docker/gallery'
3142#+end_src
3143
3144Then, on homelab, create =~/docker/gallery/.env= with
3145=GALLERY_PHOTOS_DIR=<photo directory>= (ask the user for the path), and run:
3146
3147#+begin_src sh
3148ssh 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/'
3149#+end_src
3150
3151Expected: =200=. The nginx site for the public hostname is out of scope for
3152this plan; it needs the hostname from the user.