#+title: gallery v1 implementation plan #+date: 2026-09-25 - For agentic workers :: REQUIRED SUB-SKILL: use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task by task. Steps use checkbox (=- [ ]=) syntax for tracking. - Goal :: A single Go binary that serves a dark, justified-row photo gallery from a folder tree, generating resized JPEGs on request with ImageMagick. - Architecture :: =library= scans =photos/= into an immutable snapshot held in an atomic pointer and rescans on filesystem events. =format= hides each file type behind one interface backed by =magick= and =exiftool=. =render= turns (item, width) into a cached JPEG on disk. =web= serves templates and images over the stdlib mux. - Tech stack :: Go 1.27, =net/http= 1.22+ patterns, =html/template=, =github.com/fsnotify/fsnotify=, =github.com/niklasfasching/go-org=, =golang.org/x/sync/singleflight=, ImageMagick 7 (=magick=), exiftool, Docker. - Spec :: [[file:../specs/2026-09-25-gallery-design.org]] * Global constraints - Module path: =gitbay.org/cmc/gallery=. - All prose files are org-mode (=.org=). No Markdown anywhere, including =album.org= and =about.org= content files. - Photos and cache are never inside the repository. - Allowed widths: 480, 960, 1600, 2400. Output is always JPEG, quality 82. - Reserved album slugs: =img=, =info=, =_=. - Resize command: =magick -auto-orient -resize x -strip -quality 82 jpg:-=. - Image responses: =Cache-Control: public, max-age=31536000, immutable=. - Never cropping photos: tiles use =object-fit: contain=. - Code comments are terse and factual. No attribution lines in commits. - Work on branch =v1= off =main=; open an MR with =gitbay mr create --source v1 --target main= at the end. - =go test ./...= and =go vet ./...= pass after every task. * File structure #+begin_example .gitignore go.mod, go.sum cmd/gallery/main.go flags, env fallback, wiring, HTTP server internal/format/format.go Kind, Meta, Format, Registry internal/format/exif.go exiftool JSON → Meta (pure) internal/format/magick.go Magick format, Default(), CheckTools() internal/library/library.go Library, Album, Item, Store, sorting internal/library/org.go parseOrg: keywords + body HTML internal/library/metacache.go MetaCache (meta.json) internal/library/scan.go Scan internal/library/watch.go Watch (fsnotify, debounce) internal/render/render.go Widths, Allowed, Renderer (Path, Get, Prune) internal/web/server.go New, routes, handlers internal/web/funcs.go template helpers internal/web/templates/*.html base, home, album, photo, info, error internal/web/assets/theme.css internal/web/assets/gallery.js Dockerfile, compose.yml README.org #+end_example * Task 1: Module scaffold and format registry Files: - Create: =.gitignore=, =go.mod=, =internal/format/format.go= - Test: =internal/format/format_test.go= Interfaces: - Produces: - =type Kind int=; =const KindImage Kind = 0= - =type Meta struct { Width, Height int; Taken time.Time; Camera, Lens, Aperture, Shutter string; ISO int }= - =type Format interface { Name() string; Match(path string) bool; Metadata(path string) (Meta, error); Resize(src string, width int, dst io.Writer) error; Kind() Kind }= - =type Registry struct=; =(*Registry).Register(Format)=; =(*Registry).Lookup(path string) Format= (nil when unmatched) - =hasExt(path string, exts []string) bool= (package-private) - [ ] Step 1: Branch and initialise the module Start after the spec MR (=spec= → =main=) is merged, so =v1= carries the spec and this plan. #+begin_src sh git switch main && git pull && git switch -c v1 go mod init gitbay.org/cmc/gallery #+end_src Create =.gitignore=: #+begin_example /photos/ /cache/ /gallery .superpowers/ #+end_example - [ ] Step 2: Write the failing test =internal/format/format_test.go= #+begin_src go package format import ( "io" "path/filepath" "strings" "testing" ) type fake struct{ name, ext string } func (f fake) Name() string { return f.name } func (f fake) Match(p string) bool { return strings.EqualFold(filepath.Ext(p), f.ext) } func (fake) Metadata(string) (Meta, error) { return Meta{}, nil } func (fake) Resize(string, int, io.Writer) error { return nil } func (fake) Kind() Kind { return KindImage } func TestRegistryFirstMatchWins(t *testing.T) { var r Registry r.Register(fake{"a", ".jpg"}) r.Register(fake{"b", ".jpg"}) r.Register(fake{"c", ".png"}) if got := r.Lookup("x/IMG.JPG"); got == nil || got.Name() != "a" { t.Fatalf("jpg: got %v, want a", got) } if got := r.Lookup("x.png"); got == nil || got.Name() != "c" { t.Fatalf("png: got %v, want c", got) } if got := r.Lookup("notes.txt"); got != nil { t.Fatalf("txt: got %v, want nil", got) } } func TestHasExt(t *testing.T) { exts := []string{".jpg", ".jpeg"} for p, want := range map[string]bool{ "a.jpg": true, "a.JPEG": true, "a.png": false, "jpg": false, "a.jpg.txt": false, } { if got := hasExt(p, exts); got != want { t.Errorf("hasExt(%q) = %v, want %v", p, got, want) } } } #+end_src - [ ] Step 3: Run it and confirm it fails Run: =go test ./internal/format/= Expected: FAIL, =undefined: Registry=, =undefined: Meta=, =undefined: hasExt=. - [ ] Step 4: Implement =internal/format/format.go= #+begin_src go // Package format describes the file types the gallery can show. package format import ( "io" "path/filepath" "strings" "time" ) type Kind int const ( KindImage Kind = iota ) // Meta holds display metadata. Width and Height are after EXIF orientation. type Meta struct { Width, Height int Taken time.Time Camera, Lens string Aperture string Shutter string ISO int } type Format interface { Name() string Match(path string) bool Metadata(path string) (Meta, error) Resize(src string, width int, dst io.Writer) error Kind() Kind } // Registry resolves a path to the first registered Format that matches it. type Registry struct { formats []Format } func (r *Registry) Register(f Format) { r.formats = append(r.formats, f) } func (r *Registry) Lookup(path string) Format { for _, f := range r.formats { if f.Match(path) { return f } } return nil } func hasExt(path string, exts []string) bool { ext := strings.ToLower(filepath.Ext(path)) for _, e := range exts { if ext == e { return true } } return false } #+end_src - [ ] Step 5: Run the tests Run: =go test ./internal/format/ && go vet ./...= Expected: PASS. - [ ] Step 6: Commit #+begin_src sh git add .gitignore go.mod internal/format git commit -m "Add format registry" #+end_src * Task 2: EXIF parsing Files: - Create: =internal/format/exif.go= - Test: =internal/format/exif_test.go= Interfaces: - Consumes: =Meta= (Task 1) - Produces: =parseExif(data []byte) (Meta, error)= (package-private), input is the output of =exiftool -json -n= for one file. - [ ] Step 1: Write the failing test =internal/format/exif_test.go= #+begin_src go package format import ( "testing" "time" ) func TestParseExifFull(t *testing.T) { data := []byte(`[{ "SourceFile": "a.jpg", "ImageWidth": 6240, "ImageHeight": 4160, "Orientation": 1, "DateTimeOriginal": "2025:03:14 23:10:05", "Make": "FUJIFILM", "Model": "X-T5", "LensModel": "XF23mmF2 R WR", "FNumber": 2, "ExposureTime": 0.008, "ISO": 1600 }]`) m, err := parseExif(data) if err != nil { t.Fatal(err) } want := Meta{ Width: 6240, Height: 4160, Taken: time.Date(2025, 3, 14, 23, 10, 5, 0, time.UTC), Camera: "FUJIFILM X-T5", Lens: "XF23mmF2 R WR", Aperture: "f/2", Shutter: "1/125", ISO: 1600, } if m != want { t.Fatalf("got %+v\nwant %+v", m, want) } } func TestParseExifRotatedSwapsDimensions(t *testing.T) { for _, o := range []string{"5", "6", "7", "8"} { m, err := parseExif([]byte(`[{"ImageWidth": 300, "ImageHeight": 200, "Orientation": ` + o + `}]`)) if err != nil { t.Fatal(err) } if m.Width != 200 || m.Height != 300 { t.Errorf("orientation %s: got %dx%d, want 200x300", o, m.Width, m.Height) } } } func TestParseExifFallbacks(t *testing.T) { m, err := parseExif([]byte(`[{ "ImageWidth": 10, "ImageHeight": 10, "CreateDate": "2024:01:02 03:04:05+01:00", "Make": "Canon", "Model": "Canon EOS R6", "FNumber": 5.6, "ExposureTime": 2 }]`)) if err != nil { t.Fatal(err) } if want := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC); !m.Taken.Equal(want) { t.Errorf("Taken = %v, want %v", m.Taken, want) } if m.Camera != "Canon EOS R6" { t.Errorf("Camera = %q", m.Camera) } if m.Aperture != "f/5.6" || m.Shutter != "2s" { t.Errorf("Aperture %q Shutter %q", m.Aperture, m.Shutter) } } func TestParseExifMinimal(t *testing.T) { m, err := parseExif([]byte(`[{"ImageWidth": 120, "ImageHeight": 80}]`)) if err != nil { t.Fatal(err) } if (m != Meta{Width: 120, Height: 80}) { t.Fatalf("got %+v", m) } } func TestParseExifErrors(t *testing.T) { for _, in := range []string{`not json`, `[]`, `[{}, {}]`, `[{"ImageWidth": 10}]`} { if _, err := parseExif([]byte(in)); err == nil { t.Errorf("%s: want error", in) } } } #+end_src - [ ] Step 2: Run it and confirm it fails Run: =go test ./internal/format/ -run Exif= Expected: FAIL, =undefined: parseExif=. - [ ] Step 3: Implement =internal/format/exif.go= #+begin_src go package format import ( "encoding/json" "errors" "fmt" "math" "strconv" "strings" "time" ) type exifRecord struct { ImageWidth int ImageHeight int Orientation int DateTimeOriginal string CreateDate string Make string Model string LensModel string FNumber float64 ExposureTime float64 ISO int } // parseExif reads `exiftool -json -n` output for a single file. func parseExif(data []byte) (Meta, error) { var recs []exifRecord if err := json.Unmarshal(data, &recs); err != nil { return Meta{}, fmt.Errorf("exiftool output: %w", err) } if len(recs) != 1 { return Meta{}, fmt.Errorf("exiftool output: %d records, want 1", len(recs)) } e := recs[0] if e.ImageWidth == 0 || e.ImageHeight == 0 { return Meta{}, errors.New("exiftool output: no image dimensions") } m := Meta{Width: e.ImageWidth, Height: e.ImageHeight, Lens: e.LensModel, ISO: e.ISO} if e.Orientation >= 5 && e.Orientation <= 8 { m.Width, m.Height = m.Height, m.Width } m.Taken = exifTime(e.DateTimeOriginal) if m.Taken.IsZero() { m.Taken = exifTime(e.CreateDate) } m.Camera = camera(e.Make, e.Model) if e.FNumber > 0 { m.Aperture = "f/" + strconv.FormatFloat(e.FNumber, 'f', -1, 64) } m.Shutter = shutter(e.ExposureTime) return m, nil } // exifTime parses "YYYY:MM:DD HH:MM:SS", ignoring any trailing subseconds or zone. func exifTime(s string) time.Time { if len(s) < 19 { return time.Time{} } t, err := time.Parse("2006:01:02 15:04:05", s[:19]) if err != nil { return time.Time{} } return t } func camera(maker, model string) string { maker, model = strings.TrimSpace(maker), strings.TrimSpace(model) if maker == "" { return model } if model == "" { return maker } brand := strings.ToLower(strings.Fields(maker)[0]) if strings.Contains(strings.ToLower(model), brand) { return model } return maker + " " + model } func shutter(sec float64) string { switch { case sec <= 0: return "" case sec >= 1: return strconv.FormatFloat(sec, 'f', -1, 64) + "s" default: return "1/" + strconv.Itoa(int(math.Round(1/sec))) } } #+end_src - [ ] Step 4: Run the tests Run: =go test ./internal/format/ && go vet ./...= Expected: PASS. - [ ] Step 5: Commit #+begin_src sh git add internal/format git commit -m "Parse exiftool metadata" #+end_src * Task 3: ImageMagick format and tool check Files: - Create: =internal/format/magick.go= - Test: =internal/format/magick_test.go= Interfaces: - Consumes: =Format=, =Registry=, =hasExt= (Task 1); =parseExif= (Task 2) - Produces: - =type Magick struct=; =NewMagick(name string, exts ...string) *Magick= - =Default() *Registry= registering =jpeg= (=.jpg=, =.jpeg=) then =png= (=.png=) - =CheckTools() error=, error text = not found on PATH= - [ ] Step 1: Write the failing test =internal/format/magick_test.go= These run the real tools and skip when either is missing. #+begin_src go package format import ( "bytes" "fmt" "image/jpeg" "os/exec" "path/filepath" "testing" "time" ) func requireTools(t *testing.T) { t.Helper() if err := CheckTools(); err != nil { t.Skip(err) } } func makeImage(t *testing.T, name string, w, h int) string { t.Helper() p := filepath.Join(t.TempDir(), name) run(t, "magick", "-size", fmt.Sprintf("%dx%d", w, h), "xc:gray", p) return p } func run(t *testing.T, name string, args ...string) { t.Helper() if out, err := exec.Command(name, args...).CombinedOutput(); err != nil { t.Fatalf("%s %v: %v\n%s", name, args, err, out) } } func resizedSize(t *testing.T, f Format, src string, w int) (int, int) { t.Helper() var buf bytes.Buffer if err := f.Resize(src, w, &buf); err != nil { t.Fatal(err) } cfg, err := jpeg.DecodeConfig(&buf) if err != nil { t.Fatalf("output is not JPEG: %v", err) } return cfg.Width, cfg.Height } func TestMagickJPEG(t *testing.T) { requireTools(t) p := makeImage(t, "a.jpg", 300, 200) run(t, "exiftool", "-overwrite_original", "-DateTimeOriginal=2025:03:14 23:10:05", "-Model=TestCam", p) f := NewMagick("jpeg", ".jpg", ".jpeg") m, err := f.Metadata(p) if err != nil { t.Fatal(err) } if m.Width != 300 || m.Height != 200 || m.Camera != "TestCam" { t.Fatalf("got %+v", m) } if want := time.Date(2025, 3, 14, 23, 10, 5, 0, time.UTC); !m.Taken.Equal(want) { t.Fatalf("Taken = %v", m.Taken) } if w, h := resizedSize(t, f, p, 150); w != 150 || h != 100 { t.Fatalf("resized to %dx%d, want 150x100", w, h) } } func TestMagickOrientation(t *testing.T) { requireTools(t) p := makeImage(t, "r.jpg", 300, 200) run(t, "exiftool", "-overwrite_original", "-n", "-Orientation=6", p) f := NewMagick("jpeg", ".jpg") m, err := f.Metadata(p) if err != nil { t.Fatal(err) } if m.Width != 200 || m.Height != 300 { t.Fatalf("metadata %dx%d, want 200x300", m.Width, m.Height) } if w, h := resizedSize(t, f, p, 100); w != 100 || h != 150 { t.Fatalf("resized to %dx%d, want 100x150", w, h) } } func TestMagickPNG(t *testing.T) { requireTools(t) p := makeImage(t, "a.png", 120, 80) f := NewMagick("png", ".png") m, err := f.Metadata(p) if err != nil { t.Fatal(err) } if m.Width != 120 || m.Height != 80 { t.Fatalf("got %+v", m) } if w, h := resizedSize(t, f, p, 60); w != 60 || h != 40 { t.Fatalf("resized to %dx%d, want 60x40", w, h) } } func TestMagickResizeError(t *testing.T) { requireTools(t) var buf bytes.Buffer if err := NewMagick("jpeg", ".jpg").Resize(filepath.Join(t.TempDir(), "missing.jpg"), 100, &buf); err == nil { t.Fatal("want error") } } func TestDefaultRegistry(t *testing.T) { r := Default() for path, want := range map[string]string{"a.JPG": "jpeg", "a.jpeg": "jpeg", "a.png": "png"} { if f := r.Lookup(path); f == nil || f.Name() != want { t.Errorf("%s: got %v, want %s", path, f, want) } } if r.Lookup("a.heic") != nil { t.Error("heic should not match in v1") } } #+end_src - [ ] Step 2: Run it and confirm it fails Run: =go test ./internal/format/= Expected: FAIL, =undefined: CheckTools=, =undefined: NewMagick=, =undefined: Default=. - [ ] Step 3: Implement =internal/format/magick.go= #+begin_src go package format import ( "bytes" "fmt" "io" "os/exec" "strconv" "strings" ) // Magick reads metadata with exiftool and resizes with ImageMagick. type Magick struct { name string exts []string } func NewMagick(name string, exts ...string) *Magick { return &Magick{name: name, exts: exts} } func (m *Magick) Name() string { return m.name } func (m *Magick) Match(path string) bool { return hasExt(path, m.exts) } func (m *Magick) Kind() Kind { return KindImage } func (m *Magick) Metadata(path string) (Meta, error) { out, err := exec.Command("exiftool", "-json", "-n", "-ImageWidth", "-ImageHeight", "-Orientation", "-DateTimeOriginal", "-CreateDate", "-Make", "-Model", "-LensModel", "-FNumber", "-ExposureTime", "-ISO", path).Output() if err != nil { return Meta{}, fmt.Errorf("exiftool %s: %w", path, err) } return parseExif(out) } func (m *Magick) Resize(src string, width int, dst io.Writer) error { cmd := exec.Command("magick", src, "-auto-orient", "-resize", strconv.Itoa(width)+"x", "-strip", "-quality", "82", "jpg:-") var stderr bytes.Buffer cmd.Stdout = dst cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return fmt.Errorf("magick %s: %w: %s", src, err, strings.TrimSpace(stderr.String())) } return nil } // Default returns the formats supported in v1. func Default() *Registry { r := &Registry{} r.Register(NewMagick("jpeg", ".jpg", ".jpeg")) r.Register(NewMagick("png", ".png")) return r } // CheckTools reports the first required external tool missing from PATH. func CheckTools() error { for _, tool := range []string{"magick", "exiftool"} { if _, err := exec.LookPath(tool); err != nil { return fmt.Errorf("%s not found on PATH", tool) } } return nil } #+end_src - [ ] Step 4: Run the tests Run: =go test -v ./internal/format/ && go vet ./...= Expected: PASS, with the =TestMagick*= tests reported as run (not skipped) on a machine with =magick= and =exiftool=. - [ ] Step 5: Commit #+begin_src sh git add internal/format git commit -m "Add ImageMagick-backed JPEG and PNG formats" #+end_src * Task 4: Org parsing Files: - Create: =internal/library/org.go= - Test: =internal/library/org_test.go= Interfaces: - Produces: =parseOrg(src []byte, path string) (map[string]string, template.HTML, error)= (package-private). Keys are lower-cased keyword names from =#+KEY: value= lines; the remaining text is rendered to HTML with go-org; empty body gives =""=. - [ ] Step 1: Add the dependency #+begin_src sh go get github.com/niklasfasching/go-org@latest #+end_src - [ ] Step 2: Write the failing test =internal/library/org_test.go= #+begin_src go package library import ( "strings" "testing" ) func TestParseOrg(t *testing.T) { src := "#+title: Tokyo: nights\n#+COVER: a.jpg\n\n#+begin_quote\nA /quiet/ week.\n#+end_quote\n" kw, html, err := parseOrg([]byte(src), "album.org") if err != nil { t.Fatal(err) } if len(kw) != 2 || kw["title"] != "Tokyo: nights" || kw["cover"] != "a.jpg" { t.Fatalf("keywords = %v", kw) } for _, want := range []string{"
", "quiet"} { if !strings.Contains(string(html), want) { t.Errorf("html missing %q:\n%s", want, html) } } } func TestParseOrgKeywordsOnly(t *testing.T) { kw, html, err := parseOrg([]byte("#+title: x\n\n"), "album.org") if err != nil { t.Fatal(err) } if kw["title"] != "x" || html != "" { t.Fatalf("kw=%v html=%q", kw, html) } } #+end_src - [ ] Step 3: Run it and confirm it fails Run: =go test ./internal/library/= Expected: FAIL, =undefined: parseOrg=. - [ ] Step 4: Implement =internal/library/org.go= #+begin_src go package library import ( "html/template" "strings" "github.com/niklasfasching/go-org/org" ) // parseOrg splits "#+KEY: value" lines out as lower-cased keywords and // renders the rest of the document to HTML. func parseOrg(src []byte, path string) (map[string]string, template.HTML, error) { kw := map[string]string{} var body []string for _, line := range strings.Split(string(src), "\n") { t := strings.TrimSpace(line) if rest, ok := strings.CutPrefix(t, "#+"); ok { if k, v, ok := strings.Cut(rest, ":"); ok && k != "" && !strings.ContainsAny(k, " \t") { kw[strings.ToLower(k)] = strings.TrimSpace(v) continue } } body = append(body, line) } text := strings.TrimSpace(strings.Join(body, "\n")) if text == "" { return kw, "", nil } out, err := org.New().Parse(strings.NewReader(text), path).Write(org.NewHTMLWriter()) if err != nil { return nil, "", err } return kw, template.HTML(out), nil } #+end_src - [ ] Step 5: Run the tests Run: =go mod tidy && go test ./internal/library/ && go vet ./...= Expected: PASS. - [ ] Step 6: Commit #+begin_src sh git add go.mod go.sum internal/library git commit -m "Parse org keywords and body" #+end_src * Task 5: Metadata cache Files: - Create: =internal/library/metacache.go= - Test: =internal/library/metacache_test.go= Interfaces: - Consumes: =format.Meta= (Task 1) - Produces: - =type MetaCache struct=; =OpenMetaCache(path string) (*MetaCache, error)= - =(*MetaCache).get(rel string, fi fs.FileInfo) (format.Meta, bool)= - =(*MetaCache).put(rel string, fi fs.FileInfo, m format.Meta)= - =(*MetaCache).save(keep map[string]bool) error= drops entries whose key is not in =keep=, then writes atomically. - [ ] Step 1: Write the failing test =internal/library/metacache_test.go= #+begin_src go package library import ( "io/fs" "os" "path/filepath" "testing" "time" "gitbay.org/cmc/gallery/internal/format" ) func statFile(t *testing.T, body string) (string, fs.FileInfo) { t.Helper() p := filepath.Join(t.TempDir(), "a.jpg") if err := os.WriteFile(p, []byte(body), 0o644); err != nil { t.Fatal(err) } fi, err := os.Stat(p) if err != nil { t.Fatal(err) } return p, fi } func TestMetaCacheRoundTrip(t *testing.T) { path := filepath.Join(t.TempDir(), "sub", "meta.json") c, err := OpenMetaCache(path) if err != nil { t.Fatal(err) } _, fi := statFile(t, "x") m := format.Meta{Width: 3, Height: 2, Camera: "X-T5", Taken: time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC)} c.put("tokyo/a.jpg", fi, m) c.put("gone/b.jpg", fi, m) if err := c.save(map[string]bool{"tokyo/a.jpg": true}); err != nil { t.Fatal(err) } c2, err := OpenMetaCache(path) if err != nil { t.Fatal(err) } got, ok := c2.get("tokyo/a.jpg", fi) if !ok || !got.Taken.Equal(m.Taken) || got.Camera != "X-T5" || got.Width != 3 { t.Fatalf("get = %+v, %v", got, ok) } if _, ok := c2.get("gone/b.jpg", fi); ok { t.Fatal("entry outside keep survived save") } } func TestMetaCacheMissOnChange(t *testing.T) { c, _ := OpenMetaCache(filepath.Join(t.TempDir(), "meta.json")) p, fi := statFile(t, "x") c.put("a.jpg", fi, format.Meta{Width: 1, Height: 1}) if err := os.WriteFile(p, []byte("longer"), 0o644); err != nil { t.Fatal(err) } fi2, _ := os.Stat(p) if _, ok := c.get("a.jpg", fi2); ok { t.Fatal("size change should miss") } later := fi.ModTime().Add(time.Hour) os.WriteFile(p, []byte("x"), 0o644) os.Chtimes(p, later, later) fi3, _ := os.Stat(p) if _, ok := c.get("a.jpg", fi3); ok { t.Fatal("mtime change should miss") } } func TestMetaCacheCorruptStartsEmpty(t *testing.T) { path := filepath.Join(t.TempDir(), "meta.json") os.WriteFile(path, []byte("{nope"), 0o644) c, err := OpenMetaCache(path) if err != nil { t.Fatal(err) } if len(c.entries) != 0 { t.Fatalf("entries = %v", c.entries) } } #+end_src - [ ] Step 2: Run it and confirm it fails Run: =go test ./internal/library/ -run MetaCache= Expected: FAIL, =undefined: OpenMetaCache=. - [ ] Step 3: Implement =internal/library/metacache.go= #+begin_src go package library import ( "encoding/json" "errors" "io/fs" "log" "os" "path/filepath" "gitbay.org/cmc/gallery/internal/format" ) type metaEntry struct { ModTime int64 `json:"mtime"` Size int64 `json:"size"` Meta format.Meta `json:"meta"` } // MetaCache persists extracted metadata keyed by album-relative path. type MetaCache struct { path string entries map[string]metaEntry } func OpenMetaCache(path string) (*MetaCache, error) { c := &MetaCache{path: path, entries: map[string]metaEntry{}} data, err := os.ReadFile(path) if errors.Is(err, fs.ErrNotExist) { return c, nil } if err != nil { return nil, err } if err := json.Unmarshal(data, &c.entries); err != nil { log.Printf("meta cache %s unreadable, starting empty: %v", path, err) c.entries = map[string]metaEntry{} } return c, nil } func (c *MetaCache) get(rel string, fi fs.FileInfo) (format.Meta, bool) { e, ok := c.entries[rel] if !ok || e.ModTime != fi.ModTime().UnixNano() || e.Size != fi.Size() { return format.Meta{}, false } return e.Meta, true } func (c *MetaCache) put(rel string, fi fs.FileInfo, m format.Meta) { c.entries[rel] = metaEntry{ModTime: fi.ModTime().UnixNano(), Size: fi.Size(), Meta: m} } func (c *MetaCache) save(keep map[string]bool) error { for k := range c.entries { if !keep[k] { delete(c.entries, k) } } data, err := json.Marshal(c.entries) if err != nil { return err } if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil { return err } tmp := c.path + ".tmp" if err := os.WriteFile(tmp, data, 0o644); err != nil { return err } return os.Rename(tmp, c.path) } #+end_src - [ ] Step 4: Run the tests Run: =go test ./internal/library/ && go vet ./...= Expected: PASS. - [ ] Step 5: Commit #+begin_src sh git add internal/library git commit -m "Add metadata cache" #+end_src * Task 6: Library model and scan Files: - Create: =internal/library/library.go=, =internal/library/scan.go= - Test: =internal/library/scan_test.go= Interfaces: - Consumes: =format.Registry=, =format.Format=, =format.Meta= (Tasks 1-3); =parseOrg= (Task 4); =MetaCache= (Task 5) - Produces: - =type Library struct { Albums []*Album; About template.HTML }=; =(*Library).Album(slug string) *Album= (nil if absent) - =type Album struct { Slug, Title, Date string; Description template.HTML; Order int; HasOrder bool; Cover *Item; Items []*Item }= - =(*Album).Item(name string) (int, *Item)= (=-1, nil= if absent); =(*Album).Year() string= - =type Item struct { Album, Name, Path string; ModTime time.Time; Size int64; Meta format.Meta; Format format.Format }= - =type Store struct=; =(*Store).Load() *Library=; =(*Store).Set(*Library)= - =Scan(root string, reg *format.Registry, cache *MetaCache) (*Library, error)= - [ ] Step 1: Write the failing test =internal/library/scan_test.go= #+begin_src go package library import ( "errors" "io" "os" "path/filepath" "strings" "testing" "time" "gitbay.org/cmc/gallery/internal/format" ) type fakeFormat struct { meta map[string]format.Meta // by base name fail map[string]bool calls int } func (f *fakeFormat) Name() string { return "fake" } func (f *fakeFormat) Match(p string) bool { return strings.EqualFold(filepath.Ext(p), ".jpg") } func (f *fakeFormat) Kind() format.Kind { return format.KindImage } func (f *fakeFormat) Resize(string, int, io.Writer) error { return nil } func (f *fakeFormat) Metadata(p string) (format.Meta, error) { f.calls++ name := filepath.Base(p) if f.fail[name] { return format.Meta{}, errors.New("bad file") } if m, ok := f.meta[name]; ok { return m, nil } return format.Meta{Width: 300, Height: 200}, nil } func day(n int) time.Time { return time.Date(2025, 3, n, 0, 0, 0, 0, time.UTC) } func writeTree(t *testing.T, files map[string]string) string { t.Helper() root := t.TempDir() for rel, body := range files { p := filepath.Join(root, rel) if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(p, []byte(body), 0o644); err != nil { t.Fatal(err) } } return root } func scanWith(t *testing.T, root string, f *fakeFormat, cache *MetaCache) *Library { t.Helper() if cache == nil { var err error if cache, err = OpenMetaCache(filepath.Join(t.TempDir(), "meta.json")); err != nil { t.Fatal(err) } } reg := &format.Registry{} reg.Register(f) lib, err := Scan(root, reg, cache) if err != nil { t.Fatal(err) } return lib } func names(items []*Item) string { var s []string for _, it := range items { s = append(s, it.Name) } return strings.Join(s, ",") } func slugs(as []*Album) string { var s []string for _, a := range as { s = append(s, a.Slug) } return strings.Join(s, ",") } func TestScanSkipsAndSorts(t *testing.T) { root := writeTree(t, map[string]string{ "tokyo/b.jpg": "x", "tokyo/a.jpg": "x", "tokyo/.hidden.jpg": "x", "tokyo/notes.txt": "x", "tokyo/sub/c.jpg": "x", ".git/x.jpg": "x", "img/x.jpg": "x", "info/x.jpg": "x", "_/x.jpg": "x", "empty/notes.txt": "x", "loose.jpg": "x", }) f := &fakeFormat{meta: map[string]format.Meta{ "a.jpg": {Width: 300, Height: 200, Taken: day(2)}, "b.jpg": {Width: 300, Height: 200, Taken: day(1)}, }} lib := scanWith(t, root, f, nil) if got := slugs(lib.Albums); got != "tokyo" { t.Fatalf("albums = %s", got) } a := lib.Album("tokyo") if got := names(a.Items); got != "b.jpg,a.jpg" { t.Fatalf("items = %s", got) } if a.Title != "tokyo" || a.Cover != a.Items[0] || a.Year() != "2025" { t.Fatalf("title %q cover %v year %q", a.Title, a.Cover, a.Year()) } if i, it := a.Item("a.jpg"); i != 1 || it.Path != filepath.Join(root, "tokyo", "a.jpg") { t.Fatalf("Item(a.jpg) = %d, %+v", i, it) } if i, it := a.Item("nope.jpg"); i != -1 || it != nil { t.Fatal("Item(nope.jpg) should be absent") } if lib.Album("nope") != nil { t.Fatal("Album(nope) should be nil") } } func TestScanAlbumOrg(t *testing.T) { root := writeTree(t, map[string]string{ "tokyo/album.org": "#+title: Tokyo\n#+date: 2024-11\n#+cover: b.jpg\n#+sort: name\n\nA /quiet/ week.\n", "tokyo/b.jpg": "x", "tokyo/a.jpg": "x", }) f := &fakeFormat{meta: map[string]format.Meta{ "a.jpg": {Width: 1, Height: 1, Taken: day(2)}, "b.jpg": {Width: 1, Height: 1, Taken: day(1)}, }} a := scanWith(t, root, f, nil).Album("tokyo") if a.Title != "Tokyo" || a.Date != "2024-11" || a.Year() != "2024" { t.Fatalf("title %q date %q year %q", a.Title, a.Date, a.Year()) } if got := names(a.Items); got != "a.jpg,b.jpg" { t.Fatalf("sort name: items = %s", got) } if a.Cover.Name != "b.jpg" { t.Fatalf("cover = %s", a.Cover.Name) } if !strings.Contains(string(a.Description), "quiet") { t.Fatalf("description = %s", a.Description) } } func TestScanAlbumOrdering(t *testing.T) { root := writeTree(t, map[string]string{ "a/album.org": "#+order: 2\n", "a/x.jpg": "x", "b/album.org": "#+order: 1\n", "b/x.jpg": "x", "c/album.org": "#+date: 2024\n", "c/x.jpg": "x", "d/album.org": "#+date: 2025-06\n", "d/x.jpg": "x", "e/x.jpg": "x", // no album.org; newest item taken is day(1) of 2025-03 }) f := &fakeFormat{meta: map[string]format.Meta{"x.jpg": {Width: 1, Height: 1, Taken: day(1)}}} if got := slugs(scanWith(t, root, f, nil).Albums); got != "b,a,d,e,c" { t.Fatalf("albums = %s", got) } } func TestScanTakenFallsBackToModTime(t *testing.T) { root := writeTree(t, map[string]string{"tokyo/a.jpg": "x"}) mt := time.Date(2023, 5, 6, 7, 8, 9, 0, time.UTC) os.Chtimes(filepath.Join(root, "tokyo", "a.jpg"), mt, mt) a := scanWith(t, root, &fakeFormat{}, nil).Album("tokyo") if !a.Items[0].Meta.Taken.Equal(mt) { t.Fatalf("Taken = %v, want %v", a.Items[0].Meta.Taken, mt) } } func TestScanSkipsFailedMetadata(t *testing.T) { root := writeTree(t, map[string]string{"tokyo/a.jpg": "x", "tokyo/bad.jpg": "x"}) a := scanWith(t, root, &fakeFormat{fail: map[string]bool{"bad.jpg": true}}, nil).Album("tokyo") if got := names(a.Items); got != "a.jpg" { t.Fatalf("items = %s", got) } } func TestScanUsesMetaCache(t *testing.T) { root := writeTree(t, map[string]string{"tokyo/a.jpg": "x", "tokyo/b.jpg": "x"}) cache, _ := OpenMetaCache(filepath.Join(t.TempDir(), "meta.json")) f := &fakeFormat{} scanWith(t, root, f, cache) scanWith(t, root, f, cache) if f.calls != 2 { t.Fatalf("calls after two scans = %d, want 2", f.calls) } later := time.Now().Add(time.Hour) os.Chtimes(filepath.Join(root, "tokyo", "a.jpg"), later, later) scanWith(t, root, f, cache) if f.calls != 3 { t.Fatalf("calls after touch = %d, want 3", f.calls) } } func TestScanAbout(t *testing.T) { root := writeTree(t, map[string]string{"about.org": "Hello /there/.\n", "tokyo/a.jpg": "x"}) lib := scanWith(t, root, &fakeFormat{}, nil) if !strings.Contains(string(lib.About), "there") { t.Fatalf("About = %s", lib.About) } root2 := writeTree(t, map[string]string{"tokyo/a.jpg": "x"}) if lib := scanWith(t, root2, &fakeFormat{}, nil); lib.About != "" { t.Fatalf("About without file = %q", lib.About) } } func TestStore(t *testing.T) { var s Store if s.Load() != nil { t.Fatal("empty store should load nil") } lib := &Library{} s.Set(lib) if s.Load() != lib { t.Fatal("Load after Set") } } #+end_src - [ ] Step 2: Run it and confirm it fails Run: =go test ./internal/library/= Expected: FAIL, =undefined: Scan=, =undefined: Library=, =undefined: Store=. - [ ] Step 3: Implement =internal/library/library.go= #+begin_src go // Package library turns the photo folder tree into albums and items. package library import ( "html/template" "sort" "strconv" "sync/atomic" "time" "gitbay.org/cmc/gallery/internal/format" ) type Library struct { Albums []*Album About template.HTML bySlug map[string]*Album } func (l *Library) Album(slug string) *Album { return l.bySlug[slug] } type Album struct { Slug string Title string Date string Description template.HTML Order int HasOrder bool Cover *Item Items []*Item byName map[string]int } func (a *Album) Item(name string) (int, *Item) { i, ok := a.byName[name] if !ok { return -1, nil } return i, a.Items[i] } // Year is the album's #+date year, else the cover's taken year. func (a *Album) Year() string { if len(a.Date) >= 4 { return a.Date[:4] } if a.Cover != nil && !a.Cover.Meta.Taken.IsZero() { return strconv.Itoa(a.Cover.Meta.Taken.Year()) } return "" } func (a *Album) sortTime() time.Time { for _, layout := range []string{"2006-01-02", "2006-01", "2006"} { if t, err := time.Parse(layout, a.Date); err == nil { return t } } var newest time.Time for _, it := range a.Items { if it.Meta.Taken.After(newest) { newest = it.Meta.Taken } } return newest } type Item struct { Album string Name string Path string ModTime time.Time Size int64 Meta format.Meta Format format.Format } // Store holds the current Library snapshot. type Store struct { p atomic.Pointer[Library] } func (s *Store) Load() *Library { return s.p.Load() } func (s *Store) Set(l *Library) { s.p.Store(l) } // sortAlbums puts albums with #+order first (ascending), then the rest newest first. func sortAlbums(as []*Album) { sort.SliceStable(as, func(i, j int) bool { a, b := as[i], as[j] if a.HasOrder != b.HasOrder { return a.HasOrder } if a.HasOrder && a.Order != b.Order { return a.Order < b.Order } if !a.HasOrder { if ta, tb := a.sortTime(), b.sortTime(); !ta.Equal(tb) { return ta.After(tb) } } return a.Slug < b.Slug }) } func sortItems(items []*Item, by string) { sort.SliceStable(items, func(i, j int) bool { a, b := items[i], items[j] if by == "taken" && !a.Meta.Taken.Equal(b.Meta.Taken) { return a.Meta.Taken.Before(b.Meta.Taken) } return a.Name < b.Name }) } #+end_src - [ ] Step 4: Implement =internal/library/scan.go= #+begin_src go package library import ( "log" "os" "path/filepath" "strconv" "strings" "gitbay.org/cmc/gallery/internal/format" ) // Slugs that would collide with fixed routes. var reserved = map[string]bool{"img": true, "info": true, "_": true} // Scan reads every album under root. Unreadable albums and files are logged and skipped. func Scan(root string, reg *format.Registry, cache *MetaCache) (*Library, error) { entries, err := os.ReadDir(root) if err != nil { return nil, err } lib := &Library{bySlug: map[string]*Album{}} keep := map[string]bool{} for _, e := range entries { name := e.Name() if !e.IsDir() || strings.HasPrefix(name, ".") { continue } if reserved[name] { log.Printf("skipping album %q: reserved name", name) continue } a, err := scanAlbum(root, name, reg, cache, keep) if err != nil { log.Printf("album %s: %v", name, err) continue } if len(a.Items) == 0 { continue } lib.Albums = append(lib.Albums, a) lib.bySlug[name] = a } sortAlbums(lib.Albums) if src, err := os.ReadFile(filepath.Join(root, "about.org")); err == nil { if _, html, err := parseOrg(src, "about.org"); err != nil { log.Printf("about.org: %v", err) } else { lib.About = html } } if err := cache.save(keep); err != nil { log.Printf("meta cache: %v", err) } return lib, nil } func scanAlbum(root, slug string, reg *format.Registry, cache *MetaCache, keep map[string]bool) (*Album, error) { dir := filepath.Join(root, slug) entries, err := os.ReadDir(dir) if err != nil { return nil, err } a := &Album{Slug: slug, Title: slug, byName: map[string]int{}} sortBy, cover := "taken", "" if src, err := os.ReadFile(filepath.Join(dir, "album.org")); err == nil { kw, desc, err := parseOrg(src, filepath.Join(slug, "album.org")) if err != nil { log.Printf("%s/album.org: %v", slug, err) } else { if v := kw["title"]; v != "" { a.Title = v } a.Date, a.Description, cover = kw["date"], desc, kw["cover"] if v := kw["order"]; v != "" { if n, err := strconv.Atoi(v); err == nil { a.Order, a.HasOrder = n, true } else { log.Printf("%s/album.org: bad order %q", slug, v) } } switch v := kw["sort"]; v { case "", "taken": case "name": sortBy = v default: log.Printf("%s/album.org: bad sort %q", slug, v) } } } for _, e := range entries { name := e.Name() if e.IsDir() || strings.HasPrefix(name, ".") { continue } path := filepath.Join(dir, name) f := reg.Lookup(path) if f == nil { continue } fi, err := e.Info() if err != nil { log.Printf("%s/%s: %v", slug, name, err) continue } rel := slug + "/" + name m, ok := cache.get(rel, fi) if !ok { if m, err = f.Metadata(path); err != nil { log.Printf("%s: %v", rel, err) continue } cache.put(rel, fi, m) } keep[rel] = true if m.Taken.IsZero() { m.Taken = fi.ModTime() } a.Items = append(a.Items, &Item{ Album: slug, Name: name, Path: path, ModTime: fi.ModTime(), Size: fi.Size(), Meta: m, Format: f, }) } sortItems(a.Items, sortBy) for i, it := range a.Items { a.byName[it.Name] = i } if i, ok := a.byName[cover]; ok { a.Cover = a.Items[i] } else if len(a.Items) > 0 { if cover != "" { log.Printf("%s/album.org: cover %q not found", slug, cover) } a.Cover = a.Items[0] } return a, nil } #+end_src - [ ] Step 5: Run the tests Run: =go test ./internal/library/ && go vet ./...= Expected: PASS. - [ ] Step 6: Commit #+begin_src sh git add internal/library git commit -m "Scan photo folders into albums" #+end_src * Task 7: Filesystem watcher Files: - Create: =internal/library/watch.go= - Test: =internal/library/watch_test.go= Interfaces: - Produces: =Watch(ctx context.Context, root string, debounce time.Duration, onChange func()) error=. Watches =root= and its non-hidden subdirectories, adds newly created directories, and calls =onChange= once per quiet period of length =debounce=. Returns after setup; stops when =ctx= is done. - [ ] Step 1: Add the dependency #+begin_src sh go get github.com/fsnotify/fsnotify@latest #+end_src - [ ] Step 2: Write the failing test =internal/library/watch_test.go= #+begin_src go package library import ( "context" "os" "path/filepath" "testing" "time" ) func expectCall(t *testing.T, ch <-chan struct{}, what string) { t.Helper() select { case <-ch: case <-time.After(3 * time.Second): t.Fatalf("no onChange after %s", what) } } func TestWatchDebouncesAndFollowsNewDirs(t *testing.T) { root := writeTree(t, map[string]string{"tokyo/a.jpg": "x"}) ctx, cancel := context.WithCancel(context.Background()) defer cancel() calls := make(chan struct{}, 10) if err := Watch(ctx, root, 100*time.Millisecond, func() { calls <- struct{}{} }); err != nil { t.Fatal(err) } for _, n := range []string{"b.jpg", "c.jpg", "d.jpg"} { os.WriteFile(filepath.Join(root, "tokyo", n), []byte("x"), 0o644) } expectCall(t, calls, "writes in existing album") select { case <-calls: t.Fatal("burst of writes produced more than one call") case <-time.After(300 * time.Millisecond): } os.Mkdir(filepath.Join(root, "iceland"), 0o755) expectCall(t, calls, "new album dir") time.Sleep(150 * time.Millisecond) // let the watcher add the new dir os.WriteFile(filepath.Join(root, "iceland", "a.jpg"), []byte("x"), 0o644) expectCall(t, calls, "write in new album") } #+end_src - [ ] Step 3: Run it and confirm it fails Run: =go test ./internal/library/ -run Watch= Expected: FAIL, =undefined: Watch=. - [ ] Step 4: Implement =internal/library/watch.go= #+begin_src go package library import ( "context" "log" "os" "path/filepath" "strings" "time" "github.com/fsnotify/fsnotify" ) // Watch calls onChange after filesystem activity under root has been quiet for debounce. func Watch(ctx context.Context, root string, debounce time.Duration, onChange func()) error { w, err := fsnotify.NewWatcher() if err != nil { return err } if err := addTree(w, root); err != nil { w.Close() return err } go func() { defer w.Close() timer := time.NewTimer(debounce) timer.Stop() for { select { case <-ctx.Done(): return case ev, ok := <-w.Events: if !ok { return } if ev.Has(fsnotify.Create) { if fi, err := os.Stat(ev.Name); err == nil && fi.IsDir() { if err := w.Add(ev.Name); err != nil { log.Printf("watch %s: %v", ev.Name, err) } } } timer.Reset(debounce) case err, ok := <-w.Errors: if !ok { return } log.Printf("watch: %v", err) case <-timer.C: onChange() } } }() return nil } func addTree(w *fsnotify.Watcher, root string) error { if err := w.Add(root); err != nil { return err } entries, err := os.ReadDir(root) if err != nil { return err } for _, e := range entries { if e.IsDir() && !strings.HasPrefix(e.Name(), ".") { if err := w.Add(filepath.Join(root, e.Name())); err != nil { return err } } } return nil } #+end_src =timer.Reset= without draining is correct because =go.mod= declares Go ≥ 1.23 (unbuffered timer channels). - [ ] Step 5: Run the tests Run: =go mod tidy && go test -race ./internal/library/ && go vet ./...= Expected: PASS. - [ ] Step 6: Commit #+begin_src sh git add go.mod go.sum internal/library git commit -m "Rescan on filesystem changes" #+end_src * Task 8: Renderer and derivative cache Files: - Create: =internal/render/render.go= - Test: =internal/render/render_test.go= Interfaces: - Consumes: =library.Item=, =library.Library= (Task 6); =format.Format= (Task 1) - Produces: - =var Widths = []int{480, 960, 1600, 2400}=; =Allowed(w int) bool= - =New(cacheDir string, concurrency int) *Renderer= (images under =/img=) - =(*Renderer).Path(it *library.Item, w int) string= - =(*Renderer).Get(it *library.Item, w int) (string, error)= returns the path of a finished file - =(*Renderer).Prune(lib *library.Library) error= - [ ] Step 1: Add the dependency #+begin_src sh go get golang.org/x/sync@latest #+end_src - [ ] Step 2: Write the failing test =internal/render/render_test.go= #+begin_src go package render import ( "errors" "io" "os" "path/filepath" "sync" "sync/atomic" "testing" "time" "gitbay.org/cmc/gallery/internal/format" "gitbay.org/cmc/gallery/internal/library" ) type fakeFormat struct { calls atomic.Int32 width atomic.Int32 fail bool } func (f *fakeFormat) Name() string { return "fake" } func (f *fakeFormat) Match(string) bool { return true } func (f *fakeFormat) Kind() format.Kind { return format.KindImage } func (f *fakeFormat) Metadata(string) (format.Meta, error) { return format.Meta{}, nil } func (f *fakeFormat) Resize(_ string, w int, dst io.Writer) error { f.calls.Add(1) f.width.Store(int32(w)) time.Sleep(20 * time.Millisecond) io.WriteString(dst, "partial") if f.fail { return errors.New("magick failed") } io.WriteString(dst, "-done") return nil } func item(f format.Format) *library.Item { return &library.Item{ Album: "tokyo", Name: "a.jpg", Path: "/src/a.jpg", ModTime: time.Unix(100, 0), Size: 42, Meta: format.Meta{Width: 3000, Height: 2000}, Format: f, } } func TestAllowed(t *testing.T) { for _, w := range Widths { if !Allowed(w) { t.Errorf("%d should be allowed", w) } } for _, w := range []int{0, 1, 961, 4000} { if Allowed(w) { t.Errorf("%d should not be allowed", w) } } } func TestGetGeneratesOnce(t *testing.T) { f := &fakeFormat{} r := New(t.TempDir(), 4) it := item(f) var wg sync.WaitGroup for range 10 { wg.Add(1) go func() { defer wg.Done() if _, err := r.Get(it, 960); err != nil { t.Error(err) } }() } wg.Wait() p, err := r.Get(it, 960) if err != nil { t.Fatal(err) } if n := f.calls.Load(); n != 1 { t.Fatalf("Resize calls = %d, want 1", n) } if b, _ := os.ReadFile(p); string(b) != "partial-done" { t.Fatalf("content = %q", b) } if p != r.Path(it, 960) { t.Fatalf("Get path %s != Path %s", p, r.Path(it, 960)) } } func TestGetRejectsWidth(t *testing.T) { if _, err := New(t.TempDir(), 1).Get(item(&fakeFormat{}), 961); err == nil { t.Fatal("want error") } } func TestGetClampsToSource(t *testing.T) { f := &fakeFormat{} it := item(f) it.Meta.Width = 700 if _, err := New(t.TempDir(), 1).Get(it, 960); err != nil { t.Fatal(err) } if w := f.width.Load(); w != 700 { t.Fatalf("resized to %d, want 700", w) } } func TestGetFailureLeavesNothing(t *testing.T) { f := &fakeFormat{fail: true} r := New(t.TempDir(), 1) it := item(f) if _, err := r.Get(it, 480); err == nil { t.Fatal("want error") } entries, _ := os.ReadDir(filepath.Dir(r.Path(it, 480))) if len(entries) != 0 { t.Fatalf("left behind: %v", entries) } r.Get(it, 480) if n := f.calls.Load(); n != 2 { t.Fatalf("failure was cached: calls = %d", n) } } func TestPathChangesWithSource(t *testing.T) { r := New("/cache", 1) a := item(nil) b := item(nil) b.ModTime = time.Unix(200, 0) if r.Path(a, 480) == r.Path(b, 480) { t.Fatal("path must change with mtime") } want := filepath.Join("/cache", "img", "tokyo", "a.jpg", "100000000000-42", "480.jpg") if got := r.Path(a, 480); got != want { t.Fatalf("Path = %s, want %s", got, want) } } func TestPrune(t *testing.T) { f := &fakeFormat{} r := New(t.TempDir(), 1) cur := item(f) old := item(f) old.ModTime = time.Unix(50, 0) gone := item(f) gone.Name = "gone.jpg" for _, it := range []*library.Item{cur, old, gone} { if _, err := r.Get(it, 480); err != nil { t.Fatal(err) } } lib := &library.Library{Albums: []*library.Album{{Slug: "tokyo", Items: []*library.Item{cur}}}} if err := r.Prune(lib); err != nil { t.Fatal(err) } if _, err := os.Stat(r.Path(cur, 480)); err != nil { t.Fatalf("current derivative removed: %v", err) } for _, it := range []*library.Item{old, gone} { if _, err := os.Stat(filepath.Dir(r.Path(it, 480))); !os.IsNotExist(err) { t.Fatalf("stale dir kept: %s", filepath.Dir(r.Path(it, 480))) } } } #+end_src - [ ] Step 3: Run it and confirm it fails Run: =go test ./internal/render/= Expected: FAIL, =undefined: New=, =undefined: Widths=. - [ ] Step 4: Implement =internal/render/render.go= #+begin_src go // Package render produces and caches resized copies of library items. package render import ( "fmt" "os" "path/filepath" "slices" "strconv" "golang.org/x/sync/singleflight" "gitbay.org/cmc/gallery/internal/library" ) var Widths = []int{480, 960, 1600, 2400} func Allowed(w int) bool { return slices.Contains(Widths, w) } type Renderer struct { root string sem chan struct{} group singleflight.Group } func New(cacheDir string, concurrency int) *Renderer { return &Renderer{ root: filepath.Join(cacheDir, "img"), sem: make(chan struct{}, concurrency), } } func (r *Renderer) dir(it *library.Item) string { return filepath.Join(r.root, it.Album, it.Name, fmt.Sprintf("%d-%d", it.ModTime.UnixNano(), it.Size)) } func (r *Renderer) Path(it *library.Item, w int) string { return filepath.Join(r.dir(it), strconv.Itoa(w)+".jpg") } // Get returns the cached derivative for it at width w, generating it if needed. func (r *Renderer) Get(it *library.Item, w int) (string, error) { if !Allowed(w) { return "", fmt.Errorf("width %d not allowed", w) } p := r.Path(it, w) if _, err := os.Stat(p); err == nil { return p, nil } _, err, _ := r.group.Do(p, func() (any, error) { if _, err := os.Stat(p); err == nil { return nil, nil } r.sem <- struct{}{} defer func() { <-r.sem }() return nil, r.generate(it, w, p) }) if err != nil { return "", err } return p, nil } func (r *Renderer) generate(it *library.Item, w int, dst string) error { if it.Meta.Width > 0 && w > it.Meta.Width { w = it.Meta.Width } if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { return err } tmp, err := os.CreateTemp(filepath.Dir(dst), ".tmp-*") if err != nil { return err } defer os.Remove(tmp.Name()) if err := it.Format.Resize(it.Path, w, tmp); err != nil { tmp.Close() return err } if err := tmp.Close(); err != nil { return err } return os.Rename(tmp.Name(), dst) } // Prune removes derivative directories for items no longer in lib or whose source changed. func (r *Renderer) Prune(lib *library.Library) error { keep := map[string]bool{} for _, a := range lib.Albums { for _, it := range a.Items { keep[r.dir(it)] = true } } versions, err := filepath.Glob(filepath.Join(r.root, "*", "*", "*")) if err != nil { return err } for _, v := range versions { if !keep[v] { if err := os.RemoveAll(v); err != nil { return err } } } return nil } #+end_src - [ ] Step 5: Run the tests Run: =go mod tidy && go test -race ./internal/render/ && go vet ./...= Expected: PASS. - [ ] Step 6: Commit #+begin_src sh git add go.mod go.sum internal/render git commit -m "Add derivative renderer with disk cache" #+end_src * Task 9: Web server, templates and theme Files: - Create: =internal/web/server.go=, =internal/web/funcs.go=, =internal/web/templates/{base,home,album,photo,info,error}.html=, =internal/web/assets/theme.css= - Test: =internal/web/server_test.go= Interfaces: - Consumes: =library.Store=, =library.Library=, =library.Album=, =library.Item=, =library.Scan=, =library.OpenMetaCache= (Tasks 5-6); =render.Renderer=, =render.Widths=, =render.Allowed=, =render.New= (Task 8); =format.Registry=, =format.Meta= (Task 1) - Produces: - =type Options struct { Title string; ThemePath string; Templates fs.FS }= - =New(store *library.Store, rend *render.Renderer, opt Options) (http.Handler, error)= - Routes: =GET /=, =GET /info/=, =GET /_/theme.css=, =GET /img/{album}/{file}/{width}=, =GET /{album}/=, =GET /{album}/{file}/=, everything else 404. - [ ] Step 1: Write the failing test =internal/web/server_test.go= #+begin_src go package web import ( "errors" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "gitbay.org/cmc/gallery/internal/format" "gitbay.org/cmc/gallery/internal/library" "gitbay.org/cmc/gallery/internal/render" ) type fakeFormat struct{ fail bool } func (fakeFormat) Name() string { return "fake" } func (fakeFormat) Match(p string) bool { return strings.HasSuffix(p, ".jpg") } func (fakeFormat) Kind() format.Kind { return format.KindImage } func (fakeFormat) Metadata(string) (format.Meta, error) { return format.Meta{ Width: 3000, Height: 2000, Taken: time.Date(2025, 3, 14, 0, 0, 0, 0, time.UTC), Camera: "X-T5", Aperture: "f/2", Shutter: "1/125", ISO: 1600, }, nil } func (f fakeFormat) Resize(_ string, _ int, dst io.Writer) error { if f.fail { return errors.New("magick failed") } _, err := io.WriteString(dst, "JPEGDATA") return err } type setup struct { fail bool about bool opt Options } func newServer(t *testing.T, s setup) http.Handler { t.Helper() root := t.TempDir() files := map[string]string{ "tokyo/a.jpg": "x", "tokyo/b.jpg": "x", "tokyo/album.org": "#+title: Tokyo\n#+date: 2025-03\n", } if s.about { files["about.org"] = "Hello /there/.\n" } for rel, body := range files { p := filepath.Join(root, rel) os.MkdirAll(filepath.Dir(p), 0o755) os.WriteFile(p, []byte(body), 0o644) } reg := &format.Registry{} reg.Register(fakeFormat{fail: s.fail}) cache, err := library.OpenMetaCache(filepath.Join(t.TempDir(), "meta.json")) if err != nil { t.Fatal(err) } lib, err := library.Scan(root, reg, cache) if err != nil { t.Fatal(err) } store := &library.Store{} store.Set(lib) if s.opt.Title == "" { s.opt.Title = "gallery" } h, err := New(store, render.New(t.TempDir(), 2), s.opt) if err != nil { t.Fatal(err) } return h } func get(h http.Handler, path string) *httptest.ResponseRecorder { rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest("GET", path, nil)) return rec } func TestPages(t *testing.T) { h := newServer(t, setup{about: true}) for _, tc := range []struct { path string code int want []string reject []string }{ {"/", 200, []string{`href="/tokyo/"`, `data-ar="1.5000"`, "Tokyo", "2025", `href="/info/"`}, nil}, {"/tokyo/", 200, []string{`href="/tokyo/a.jpg/"`, `href="/tokyo/b.jpg/"`, `/img/tokyo/a.jpg/960.jpg?v=`, `480w`, `class="on"`}, nil}, {"/tokyo/a.jpg/", 200, []string{"a.jpg — 1 / 2", "X-T5 · f/2 · 1/125 · ISO 1600", `rel="next" href="/tokyo/b.jpg/"`, `id="up" href="/tokyo/"`}, []string{`rel="prev"`}}, {"/tokyo/b.jpg/", 200, []string{`rel="prev" href="/tokyo/a.jpg/"`, "2 / 2"}, []string{`rel="next"`}}, {"/info/", 200, []string{"there"}, nil}, {"/nope/", 404, []string{"404"}, nil}, {"/tokyo/nope.jpg/", 404, []string{"404"}, nil}, {"/tokyo/a.jpg/extra/", 404, nil, nil}, } { rec := get(h, tc.path) if rec.Code != tc.code { t.Errorf("%s: code %d, want %d", tc.path, rec.Code, tc.code) continue } body := rec.Body.String() for _, w := range tc.want { if !strings.Contains(body, w) { t.Errorf("%s: missing %q", tc.path, w) } } for _, r := range tc.reject { if strings.Contains(body, r) { t.Errorf("%s: unexpected %q", tc.path, r) } } } } func TestInfoHiddenWithoutAbout(t *testing.T) { h := newServer(t, setup{}) if strings.Contains(get(h, "/").Body.String(), `href="/info/"`) { t.Error("nav links Info without about.org") } if code := get(h, "/info/").Code; code != 404 { t.Errorf("/info/ = %d, want 404", code) } } func TestImages(t *testing.T) { h := newServer(t, setup{}) rec := get(h, "/img/tokyo/a.jpg/960.jpg?v=1") if rec.Code != 200 || rec.Body.String() != "JPEGDATA" { t.Fatalf("code %d body %q", rec.Code, rec.Body.String()) } if cc := rec.Header().Get("Cache-Control"); cc != "public, max-age=31536000, immutable" { t.Errorf("Cache-Control = %q", cc) } if ct := rec.Header().Get("Content-Type"); ct != "image/jpeg" { t.Errorf("Content-Type = %q", ct) } for path, code := range map[string]int{ "/img/tokyo/a.jpg/961.jpg": 400, "/img/tokyo/a.jpg/960.png": 400, "/img/tokyo/a.jpg/big.jpg": 400, "/img/nope/a.jpg/960.jpg": 404, "/img/tokyo/nope.jpg/960.jpg": 404, } { if got := get(h, path).Code; got != code { t.Errorf("%s: %d, want %d", path, got, code) } } // The mux may 404 or redirect these; either way no file is served. for _, path := range []string{"/img/%2e%2e/a.jpg/960.jpg", "/img/tokyo/..%2fa.jpg/960.jpg"} { if rec := get(h, path); rec.Code == 200 { t.Errorf("%s: served %q", path, rec.Body.String()) } } } func TestImageRenderFailure(t *testing.T) { h := newServer(t, setup{fail: true}) if code := get(h, "/img/tokyo/a.jpg/480.jpg").Code; code != 500 { t.Fatalf("code %d, want 500", code) } } func TestTheme(t *testing.T) { rec := get(newServer(t, setup{}), "/_/theme.css") if rec.Code != 200 || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/css") || !strings.Contains(rec.Body.String(), "--bg") { t.Fatalf("code %d type %q", rec.Code, rec.Header().Get("Content-Type")) } custom := filepath.Join(t.TempDir(), "theme.css") os.WriteFile(custom, []byte(":root{--bg:red}"), 0o644) rec = get(newServer(t, setup{opt: Options{ThemePath: custom}}), "/_/theme.css") if rec.Body.String() != ":root{--bg:red}" { t.Fatalf("custom theme not served: %q", rec.Body.String()) } } func TestCustomTemplates(t *testing.T) { dir := t.TempDir() for _, name := range []string{"base", "home", "album", "photo", "info", "error"} { body := `{{define "content"}}custom-` + name + `{{end}}` if name == "base" { body = `{{template "content" .}}` } os.WriteFile(filepath.Join(dir, name+".html"), []byte(body), 0o644) } h := newServer(t, setup{opt: Options{Templates: os.DirFS(dir)}}) if body := get(h, "/").Body.String(); body != "custom-home" { t.Fatalf("body = %q", body) } } #+end_src - [ ] Step 2: Run it and confirm it fails Run: =go test ./internal/web/= Expected: FAIL, =undefined: New=, =undefined: Options=. - [ ] Step 3: Implement =internal/web/funcs.go= #+begin_src go package web import ( "fmt" "html/template" "net/url" "strconv" "strings" "gitbay.org/cmc/gallery/internal/library" "gitbay.org/cmc/gallery/internal/render" ) var funcs = template.FuncMap{ "albumURL": albumURL, "photoURL": photoURL, "imgURL": imgURL, "srcset": srcset, "ar": aspect, "exif": exifLine, "add": func(a, b int) int { return a + b }, } func albumURL(a *library.Album) string { return "/" + url.PathEscape(a.Slug) + "/" } func photoURL(it *library.Item) string { return "/" + url.PathEscape(it.Album) + "/" + url.PathEscape(it.Name) + "/" } func imgURL(it *library.Item, w int) string { return fmt.Sprintf("/img/%s/%s/%d.jpg?v=%d", url.PathEscape(it.Album), url.PathEscape(it.Name), w, it.ModTime.Unix()) } // srcset lists allowed widths up to the first one that reaches the source width. func srcset(it *library.Item) string { var parts []string for _, w := range render.Widths { actual := w if it.Meta.Width > 0 && w >= it.Meta.Width { actual = it.Meta.Width } parts = append(parts, fmt.Sprintf("%s %dw", imgURL(it, w), actual)) if actual != w { break } } return strings.Join(parts, ", ") } func aspect(it *library.Item) string { if it.Meta.Height == 0 { return "1" } return strconv.FormatFloat(float64(it.Meta.Width)/float64(it.Meta.Height), 'f', 4, 64) } func exifLine(it *library.Item) string { m := it.Meta var parts []string for _, s := range []string{m.Camera, m.Lens, m.Aperture, m.Shutter} { if s != "" { parts = append(parts, s) } } if m.ISO > 0 { parts = append(parts, "ISO "+strconv.Itoa(m.ISO)) } return strings.Join(parts, " · ") } #+end_src - [ ] Step 4: Implement =internal/web/server.go= #+begin_src go // Package web serves gallery pages and images. package web import ( "bytes" "embed" "fmt" "html/template" "io/fs" "log" "net/http" "os" "strconv" "strings" "gitbay.org/cmc/gallery/internal/library" "gitbay.org/cmc/gallery/internal/render" ) //go:embed templates/*.html var embeddedTemplates embed.FS //go:embed assets/theme.css var embeddedTheme []byte type Options struct { Title string ThemePath string // replaces the embedded theme.css Templates fs.FS // replaces the embedded templates; same file names } var pageNames = []string{"home", "album", "photo", "info", "error"} type server struct { store *library.Store rend *render.Renderer title string theme []byte pages map[string]*template.Template } type page struct { Title string Lib *library.Library Current string Album *library.Album Item *library.Item Index int Prev *library.Item Next *library.Item Code int Message string } func New(store *library.Store, rend *render.Renderer, opt Options) (http.Handler, error) { s := &server{store: store, rend: rend, title: opt.Title, theme: embeddedTheme, pages: map[string]*template.Template{}} if opt.ThemePath != "" { b, err := os.ReadFile(opt.ThemePath) if err != nil { return nil, err } s.theme = b } tfs := opt.Templates if tfs == nil { sub, err := fs.Sub(embeddedTemplates, "templates") if err != nil { return nil, err } tfs = sub } for _, name := range pageNames { t, err := template.New("base.html").Funcs(funcs).ParseFS(tfs, "base.html", name+".html") if err != nil { return nil, fmt.Errorf("template %s: %w", name, err) } s.pages[name] = t } mux := http.NewServeMux() mux.HandleFunc("GET /{$}", s.home) mux.HandleFunc("GET /info/{$}", s.info) mux.HandleFunc("GET /_/theme.css", s.themeCSS) mux.HandleFunc("GET /img/{album}/{file}/{width}", s.image) mux.HandleFunc("GET /{album}/{$}", s.album) mux.HandleFunc("GET /{album}/{file}/{$}", s.photo) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { s.error(w, http.StatusNotFound) }) return mux, nil } func (s *server) render(w http.ResponseWriter, name string, status int, p page) { p.Title = s.title if p.Lib == nil { p.Lib = s.store.Load() } var buf bytes.Buffer if err := s.pages[name].Execute(&buf, p); err != nil { log.Printf("render %s: %v", name, err) http.Error(w, "internal error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(status) buf.WriteTo(w) } func (s *server) error(w http.ResponseWriter, code int) { s.render(w, "error", code, page{Code: code, Message: http.StatusText(code)}) } func (s *server) home(w http.ResponseWriter, r *http.Request) { s.render(w, "home", http.StatusOK, page{}) } func (s *server) info(w http.ResponseWriter, r *http.Request) { lib := s.store.Load() if lib.About == "" { s.error(w, http.StatusNotFound) return } s.render(w, "info", http.StatusOK, page{Lib: lib, Current: "info"}) } func (s *server) album(w http.ResponseWriter, r *http.Request) { lib := s.store.Load() a := lib.Album(r.PathValue("album")) if a == nil { s.error(w, http.StatusNotFound) return } s.render(w, "album", http.StatusOK, page{Lib: lib, Current: a.Slug, Album: a}) } func (s *server) photo(w http.ResponseWriter, r *http.Request) { lib := s.store.Load() a := lib.Album(r.PathValue("album")) if a == nil { s.error(w, http.StatusNotFound) return } i, it := a.Item(r.PathValue("file")) if it == nil { s.error(w, http.StatusNotFound) return } p := page{Lib: lib, Current: a.Slug, Album: a, Item: it, Index: i} if i > 0 { p.Prev = a.Items[i-1] } if i < len(a.Items)-1 { p.Next = a.Items[i+1] } s.render(w, "photo", http.StatusOK, p) } func (s *server) image(w http.ResponseWriter, r *http.Request) { lib := s.store.Load() a := lib.Album(r.PathValue("album")) if a == nil { http.NotFound(w, r) return } _, it := a.Item(r.PathValue("file")) if it == nil { http.NotFound(w, r) return } ws, ok := strings.CutSuffix(r.PathValue("width"), ".jpg") n, err := strconv.Atoi(ws) if !ok || err != nil || !render.Allowed(n) { http.Error(w, "bad width", http.StatusBadRequest) return } p, err := s.rend.Get(it, n) if err != nil { log.Printf("render %s/%s@%d: %v", it.Album, it.Name, n, err) http.Error(w, "render failed", http.StatusInternalServerError) return } w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") w.Header().Set("Content-Type", "image/jpeg") http.ServeFile(w, r, p) } func (s *server) themeCSS(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/css; charset=utf-8") w.Header().Set("Cache-Control", "no-cache") w.Write(s.theme) } #+end_src - [ ] Step 5: Create the templates =internal/web/templates/base.html=: #+begin_src html {{block "title" .}}{{.Title}}{{end}}
{{template "content" .}}
#+end_src =internal/web/templates/home.html=: #+begin_src html {{define "content"}}
{{- range $a := .Lib.Albums}}{{$c := $a.Cover}} {{$a.Title}} {{$a.Title}}{{with $a.Year}} · {{.}}{{end}} {{- end}}
{{end}} #+end_src =internal/web/templates/album.html=: #+begin_src html {{define "title"}}{{.Album.Title}} · {{.Title}}{{end}} {{define "content"}}

{{.Album.Title}}

{{with .Album.Year}}{{.}} · {{end}}{{len .Album.Items}}
{{with .Album.Description}}
{{.}}
{{end}}
{{- range .Album.Items}} {{- end}}
{{end}} #+end_src =internal/web/templates/photo.html=: #+begin_src html {{define "title"}}{{.Item.Name}} · {{.Album.Title}} · {{.Title}}{{end}} {{define "bodyclass"}}photo{{end}} {{define "content"}}
{{with .Prev}}{{end}} {{with .Next}}{{end}}
{{.Item.Name}} — {{add .Index 1}} / {{len .Album.Items}} {{exif .Item}}
{{with .Prev}}{{end}} {{with .Next}}{{end}} {{end}} #+end_src =internal/web/templates/info.html=: #+begin_src html {{define "title"}}Info · {{.Title}}{{end}} {{define "content"}}
{{.Lib.About}}
{{end}} #+end_src =internal/web/templates/error.html=: #+begin_src html {{define "title"}}{{.Code}} · {{.Title}}{{end}} {{define "content"}}

{{.Code}} — {{.Message}}

{{end}} #+end_src - [ ] Step 6: Create =internal/web/assets/theme.css= #+begin_src css :root { --bg: #0a0a0a; --fg: #dddddd; --muted: #777777; --accent: #ffffff; --gap: 4px; --row-height: 320px; --font: "Helvetica Neue", Arial, sans-serif; --tracking: 0.25em; } html { box-sizing: border-box; } *, *::before, *::after { box-sizing: inherit; } html, body { margin: 0; background: var(--bg); color: var(--fg); font: 13px/1.5 var(--font); } a { color: inherit; text-decoration: none; } nav.site { display: flex; flex-wrap: wrap; gap: 8px 22px; padding: 18px 20px; font-size: 10px; letter-spacing: var(--tracking); text-transform: uppercase; color: var(--muted); } nav.site .brand { color: var(--accent); font-weight: 600; margin-right: auto; } nav.site a:hover, nav.site a.on { color: var(--accent); } .title { display: flex; justify-content: space-between; align-items: baseline; padding: 16px 20px 12px; font-size: 10px; letter-spacing: var(--tracking); text-transform: uppercase; color: var(--muted); } .title h1 { margin: 0; font: inherit; color: var(--fg); } .desc { padding: 0 20px 16px; max-width: 60ch; color: var(--muted); } .rows { display: flex; flex-wrap: wrap; gap: var(--gap); padding: 0 var(--gap) var(--gap); } .tile { display: block; position: relative; height: var(--row-height); width: calc(var(--row-height) * var(--ar)); max-width: 100%; } .tile img { display: block; width: 100%; height: 100%; object-fit: contain; } .captioned { row-gap: 36px; padding-top: 4px; padding-bottom: 40px; } .captioned .label { position: absolute; top: 100%; left: 0; padding-top: 8px; font-size: 10px; letter-spacing: var(--tracking); text-transform: uppercase; color: var(--muted); white-space: nowrap; } body.photo { display: flex; flex-direction: column; min-height: 100vh; } body.photo main { flex: 1; display: flex; flex-direction: column; } .stage { flex: 1; margin: 0; display: flex; align-items: center; justify-content: center; position: relative; padding: 0 48px; } .stage img { max-width: 100%; max-height: calc(100vh - 120px); width: auto; height: auto; } .stage .prev, .stage .next { position: absolute; top: 0; bottom: 0; width: 48px; display: flex; align-items: center; justify-content: center; font-size: 24px; color: var(--muted); } .stage .prev { left: 0; } .stage .next { right: 0; } .stage .prev:hover, .stage .next:hover { color: var(--accent); } .meta { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 16px; padding: 16px 20px 20px; font-size: 9px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--muted); } .meta span:first-child { color: var(--fg); } .info { padding: 0 20px 40px; max-width: 60ch; } .error { padding: 40px 20px; font-size: 10px; letter-spacing: var(--tracking); text-transform: uppercase; color: var(--muted); } @media (max-width: 600px) { :root { --row-height: 200px; } .stage { padding: 0; } .stage .prev, .stage .next { display: none; } } #+end_src - [ ] Step 7: Run the tests Run: =go test ./internal/web/ && go vet ./...= Expected: PASS. If =TestPages= fails on an HTML-escaped string (for example =·= or =—= rendered as an entity), fix the template output, not the test. - [ ] Step 8: Commit #+begin_src sh git add internal/web git commit -m "Add web handlers, templates and theme" #+end_src * Task 10: Page script (justified rows, keyboard, swipe) Files: - Create: =internal/web/assets/gallery.js= - Modify: =internal/web/server.go= (embed and route), =internal/web/templates/base.html= (script tag) - Test: =internal/web/server_test.go= (add =TestScript=) Interfaces: - Consumes: markup from Task 9: =.rows= containers of =.tile= elements with =data-ar=; =a[rel=prev]=, =a[rel=next]=, =#up= on the photo page; =--row-height= and =gap= from =theme.css=. - Produces: route =GET /_/gallery.js=. - [ ] Step 1: Add the failing test to =internal/web/server_test.go= #+begin_src go func TestScript(t *testing.T) { h := newServer(t, setup{}) rec := get(h, "/_/gallery.js") if rec.Code != 200 || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/javascript") { t.Fatalf("code %d type %q", rec.Code, rec.Header().Get("Content-Type")) } if !strings.Contains(get(h, "/tokyo/").Body.String(), ``) { t.Fatal("album page missing script tag") } } #+end_src - [ ] Step 2: Run it and confirm it fails Run: =go test ./internal/web/ -run Script= Expected: FAIL, code 404. - [ ] Step 3: Create =internal/web/assets/gallery.js= #+begin_src js (() => { // Justified rows: fill each row to the container width at roughly --row-height. const layout = (el) => { const cs = getComputedStyle(el); const gap = parseFloat(cs.columnGap) || 0; const target = parseFloat(cs.getPropertyValue('--row-height')) || 320; const width = el.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight); let row = [], sum = 0; const flush = (last) => { let h = (width - gap * (row.length - 1)) / sum; if (last && h > target) h = target; for (const t of row) { t.style.width = Math.floor(h * t.dataset.ar) + 'px'; t.style.height = Math.floor(h) + 'px'; } row = []; sum = 0; }; for (const t of el.children) { row.push(t); sum += +t.dataset.ar; if ((width - gap * (row.length - 1)) / sum <= target) flush(false); } if (row.length) flush(true); }; const layoutAll = () => document.querySelectorAll('.rows').forEach(layout); let timer; addEventListener('resize', () => { clearTimeout(timer); timer = setTimeout(layoutAll, 100); }); layoutAll(); const follow = (sel) => { const a = document.querySelector(sel); if (a) location.href = a.href; }; addEventListener('keydown', (e) => { if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return; if (e.key === 'ArrowLeft') follow('a[rel=prev]'); else if (e.key === 'ArrowRight') follow('a[rel=next]'); else if (e.key === 'Escape') follow('#up'); }); let x0 = null; addEventListener('touchstart', (e) => { x0 = e.touches[0].clientX; }, { passive: true }); addEventListener('touchend', (e) => { if (x0 === null) return; const dx = e.changedTouches[0].clientX - x0; x0 = null; if (Math.abs(dx) > 50) follow(dx > 0 ? 'a[rel=prev]' : 'a[rel=next]'); }, { passive: true }); })(); #+end_src - [ ] Step 4: Serve it In =internal/web/server.go=, below =embeddedTheme=: #+begin_src go //go:embed assets/gallery.js var embeddedScript []byte #+end_src Register the route after =/_/theme.css=: #+begin_src go mux.HandleFunc("GET /_/gallery.js", s.script) #+end_src Add the handler after =themeCSS=: #+begin_src go func (s *server) script(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/javascript; charset=utf-8") w.Header().Set("Cache-Control", "no-cache") w.Write(embeddedScript) } #+end_src In =internal/web/templates/base.html=, replace #+begin_src html #+end_src with #+begin_src html #+end_src - [ ] Step 5: Run the tests Run: =go test ./internal/web/ && go vet ./...= Expected: PASS. - [ ] Step 6: Commit #+begin_src sh git add internal/web git commit -m "Add justified row layout and photo navigation script" #+end_src * Task 11: Command, wiring and local run Files: - Create: =cmd/gallery/main.go= - Modify: =README.org= Interfaces: - Consumes: =format.CheckTools=, =format.Default= (Task 3); =library.OpenMetaCache=, =library.Scan=, =library.Store=, =library.Watch= (Tasks 5-7); =render.New=, =(*Renderer).Prune= (Task 8); =web.New=, =web.Options= (Task 9) - Produces: the =gallery= binary with flags =-photos -cache -addr -theme -templates -title= and =GALLERY_*= environment fallbacks. - [ ] Step 1: Implement =cmd/gallery/main.go= #+begin_src go package main import ( "context" "errors" "flag" "fmt" "log" "net/http" "os" "os/signal" "path/filepath" "runtime" "syscall" "time" "gitbay.org/cmc/gallery/internal/format" "gitbay.org/cmc/gallery/internal/library" "gitbay.org/cmc/gallery/internal/render" "gitbay.org/cmc/gallery/internal/web" ) func env(key, def string) string { if v := os.Getenv(key); v != "" { return v } return def } func main() { photos := flag.String("photos", env("GALLERY_PHOTOS", ""), "photo root (required)") cache := flag.String("cache", env("GALLERY_CACHE", ""), "cache directory (required)") addr := flag.String("addr", env("GALLERY_ADDR", ":8080"), "listen address") theme := flag.String("theme", env("GALLERY_THEME", ""), "replacement theme.css") templates := flag.String("templates", env("GALLERY_TEMPLATES", ""), "replacement template directory") title := flag.String("title", env("GALLERY_TITLE", "gallery"), "site name") flag.Parse() if *photos == "" || *cache == "" { fmt.Fprintln(os.Stderr, "gallery: -photos and -cache are required") flag.Usage() os.Exit(2) } if err := format.CheckTools(); err != nil { log.Fatal(err) } reg := format.Default() meta, err := library.OpenMetaCache(filepath.Join(*cache, "meta.json")) if err != nil { log.Fatal(err) } rend := render.New(*cache, runtime.NumCPU()) store := &library.Store{} rescan := func() error { lib, err := library.Scan(*photos, reg, meta) if err != nil { return err } store.Set(lib) if err := rend.Prune(lib); err != nil { log.Printf("prune: %v", err) } log.Printf("library: %d albums", len(lib.Albums)) return nil } if err := rescan(); err != nil { log.Fatal(err) } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() onChange := func() { if err := rescan(); err != nil { log.Printf("rescan: %v", err) } } if err := library.Watch(ctx, *photos, 500*time.Millisecond, onChange); err != nil { log.Fatal(err) } opt := web.Options{Title: *title, ThemePath: *theme} if *templates != "" { opt.Templates = os.DirFS(*templates) } h, err := web.New(store, rend, opt) if err != nil { log.Fatal(err) } srv := &http.Server{Addr: *addr, Handler: h, ReadHeaderTimeout: 10 * time.Second} go func() { <-ctx.Done() sctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() srv.Shutdown(sctx) }() log.Printf("listening on %s", *addr) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatal(err) } } #+end_src - [ ] Step 2: Build and run against sample photos #+begin_src sh go build -o gallery ./cmd/gallery mkdir -p photos/tokyo photos/iceland for i in 1 2 3 4 5; do magick -size $((400 + i * 150))x600 plasma: photos/tokyo/t$i.jpg; done for i in 1 2 3; do magick -size 900x$((400 + i * 120)) gradient:navy-orange photos/iceland/i$i.png; done printf '#+title: Tokyo\n#+date: 2025-03\n\nA /quiet/ week.\n' > photos/tokyo/album.org printf 'Photographs by me.\n' > photos/about.org ./gallery -photos ./photos -cache ./cache -addr 127.0.0.1:8080 & #+end_src Expected log: =library: 2 albums= and =listening on 127.0.0.1:8080=. - [ ] Step 3: Check the endpoints #+begin_src sh curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/tokyo/ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/tokyo/t1.jpg/ curl -s -D - -o /tmp/g.jpg http://127.0.0.1:8080/img/tokyo/t1.jpg/480.jpg | grep -i cache-control magick identify /tmp/g.jpg #+end_src Expected: =200= three times; =Cache-Control: public, max-age=31536000, immutable=; identify reports =JPEG 480x...=. - [ ] Step 4: Check the watcher #+begin_src sh magick -size 800x500 plasma: photos/tokyo/t6.jpg sleep 1 curl -s http://127.0.0.1:8080/tokyo/ | grep -c 'href="/tokyo/t6.jpg/"' #+end_src Expected: =1=, and a second =library: 2 albums= log line. - [ ] Step 5: Check the pages in a browser Open =http://127.0.0.1:8080/=, =/tokyo/= and =/tokyo/t1.jpg/=, then: - Rows fill the width edge to edge with 4 px gaps; last row is not stretched; nothing is cropped; resizing the window re-flows rows. - On the photo page, =←= =→= move between photos, =Esc= returns to the album, the first photo has no previous arrow, the last has no next. - At 375 px wide, rows are shorter and the arrows are hidden. Stop the server (=kill %1=). - [ ] Step 6: Document usage in =README.org= Replace the file with: #+begin_src org ,#+title: gallery Self-hosted photo gallery. Albums are folders; resized copies are generated on request with ImageMagick and cached on disk. ,* Requirements Go 1.27, ImageMagick 7 (=magick=), exiftool. ,* Photos ,#+begin_example photos/ about.org optional; rendered as the Info page tokyo/ album.org optional DSCF0412.jpg ... ,#+end_example =album.org= keywords, all optional: | Keyword | Meaning | Default | |------------+-------------------------------------------+--------------------| | =#+title= | album title | folder name | | =#+date= | =YYYY=, =YYYY-MM= or =YYYY-MM-DD= | newest photo | | =#+cover= | file name of the cover photo | first photo | | =#+order= | integer; ordered albums come first | unset | | =#+sort= | =taken= or =name= | =taken= | Text below the keywords is shown as the album description. ,* Run ,#+begin_src sh go build -o gallery ./cmd/gallery ./gallery -photos ./photos -cache ./cache -addr :8080 ,#+end_src | Flag | Env | Default | |--------------+---------------------+-----------| | =-photos= | =GALLERY_PHOTOS= | required | | =-cache= | =GALLERY_CACHE= | required | | =-addr= | =GALLERY_ADDR= | =:8080= | | =-theme= | =GALLERY_THEME= | embedded | | =-templates= | =GALLERY_TEMPLATES= | embedded | | =-title= | =GALLERY_TITLE= | =gallery= | =-theme= replaces [[file:internal/web/assets/theme.css]]. =-templates= points at a directory with the same file names as [[file:internal/web/templates]]. See [[file:docs/specs/2026-09-25-gallery-design.org][the design spec]]. #+end_src (The commas before =#+= and =*= at line start are org escapes inside this plan; the real =README.org= has none.) - [ ] Step 7: Run the full suite and commit #+begin_src sh go test -race ./... && go vet ./... git add cmd README.org git commit -m "Add gallery command" #+end_src * Task 12: Docker image and homelab deploy Files: - Create: =Dockerfile=, =compose.yml=, =.dockerignore= - Modify: =README.org= (Deploy section) Interfaces: - Consumes: =cmd/gallery= (Task 11) - Produces: container =gallery= on homelab at =127.0.0.1:8003=. - [ ] Step 1: Create =Dockerfile= #+begin_src dockerfile FROM golang:1-alpine AS build WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /gallery ./cmd/gallery FROM alpine:3 RUN apk add --no-cache imagemagick imagemagick-jpeg imagemagick-heic imagemagick-webp exiftool COPY --from=build /gallery /usr/local/bin/gallery ENV GALLERY_PHOTOS=/photos GALLERY_CACHE=/cache GALLERY_ADDR=:8080 EXPOSE 8080 ENTRYPOINT ["gallery"] #+end_src - [ ] Step 2: Create =.dockerignore= #+begin_example .git photos cache gallery .superpowers #+end_example - [ ] Step 3: Create =compose.yml= #+begin_src yaml services: gallery: build: . container_name: gallery restart: unless-stopped ports: - "127.0.0.1:8003:8080" volumes: - ${GALLERY_PHOTOS_DIR:-./photos}:/photos:ro - ./cache:/cache environment: GALLERY_TITLE: ${GALLERY_TITLE:-gallery} #+end_src - [ ] Step 4: Build and check the image locally #+begin_src sh docker build -t gallery . docker run --rm --entrypoint magick gallery -list format | grep -E '^ +(JPEG|PNG|HEIC|WEBP)' docker run --rm --entrypoint exiftool gallery -ver #+end_src Expected: rows for JPEG, PNG, HEIC and WEBP with =rw= mode, and an exiftool version. If PNG is missing, add =imagemagick-png= to the =apk add= line (the Alpine split varies by release) and rebuild. #+begin_src sh docker compose up -d --build curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8003/ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8003/img/tokyo/t1.jpg/960.jpg docker compose down #+end_src Expected: =200= twice (using the sample =photos/= from Task 11). - [ ] Step 5: Add a Deploy section to =README.org= Append: #+begin_src org ,* Deploy ,#+begin_src sh git clone https://gitbay.org/cmc/gallery.git ~/docker/gallery cd ~/docker/gallery GALLERY_PHOTOS_DIR=/path/to/photos docker compose up -d --build ,#+end_src The container listens on =127.0.0.1:8003=; put a reverse proxy in front of it. Photos are mounted read-only; derivatives and =meta.json= live in =./cache=. #+end_src - [ ] Step 6: Commit, push, open the MR #+begin_src sh go test -race ./... && go vet ./... git add Dockerfile .dockerignore compose.yml README.org git commit -m "Add Docker image and compose file" git push -u origin v1 gitbay mr create --source v1 --target main --title "gallery v1" --body "Folder-based albums, on-demand ImageMagick resize with disk cache, format registry (JPEG, PNG), dark justified-row theme, Docker/compose deploy." #+end_src - [ ] Step 7: Deploy to homelab (after the MR is merged) #+begin_src sh ssh homelab 'git clone https://gitbay.org/cmc/gallery.git ~/docker/gallery' #+end_src Then, on homelab, create =~/docker/gallery/.env= with =GALLERY_PHOTOS_DIR== (ask the user for the path), and run: #+begin_src sh ssh homelab 'cd ~/docker/gallery && docker compose up -d --build && sleep 3 && curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8003/' #+end_src Expected: =200=. The nginx site for the public hostname is out of scope for this plan; it needs the hostname from the user.