docs/plans/2026-09-25-gallery-v1.org
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.