Commit 308eaf81e2
Verified · cmc
Layout: unified · split
docs/plans/2026-09-25-gallery-v1.org added +3152
| @@ -0,0 +1,3152 @@ | |||
| 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 | ||
| 40 | go.mod, go.sum | ||
| 41 | cmd/gallery/main.go flags, env fallback, wiring, HTTP server | ||
| 42 | internal/format/format.go Kind, Meta, Format, Registry | ||
| 43 | internal/format/exif.go exiftool JSON → Meta (pure) | ||
| 44 | internal/format/magick.go Magick format, Default(), CheckTools() | ||
| 45 | internal/library/library.go Library, Album, Item, Store, sorting | ||
| 46 | internal/library/org.go parseOrg: keywords + body HTML | ||
| 47 | internal/library/metacache.go MetaCache (meta.json) | ||
| 48 | internal/library/scan.go Scan | ||
| 49 | internal/library/watch.go Watch (fsnotify, debounce) | ||
| 50 | internal/render/render.go Widths, Allowed, Renderer (Path, Get, Prune) | ||
| 51 | internal/web/server.go New, routes, handlers | ||
| 52 | internal/web/funcs.go template helpers | ||
| 53 | internal/web/templates/*.html base, home, album, photo, info, error | ||
| 54 | internal/web/assets/theme.css | ||
| 55 | internal/web/assets/gallery.js | ||
| 56 | Dockerfile, compose.yml | ||
| 57 | README.org | ||
| 58 | #+end_example | ||
| 59 | |||
| 60 | * Task 1: Module scaffold and format registry | ||
| 61 | |||
| 62 | Files: | ||
| 63 | - Create: =.gitignore=, =go.mod=, =internal/format/format.go= | ||
| 64 | - Test: =internal/format/format_test.go= | ||
| 65 | |||
| 66 | Interfaces: | ||
| 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 | |||
| 76 | Start after the spec MR (=spec= → =main=) is merged, so =v1= carries the spec | ||
| 77 | and this plan. | ||
| 78 | |||
| 79 | #+begin_src sh | ||
| 80 | git switch main && git pull && git switch -c v1 | ||
| 81 | go mod init gitbay.org/cmc/gallery | ||
| 82 | #+end_src | ||
| 83 | |||
| 84 | Create =.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 | ||
| 96 | package format | ||
| 97 | |||
| 98 | import ( | ||
| 99 | "io" | ||
| 100 | "path/filepath" | ||
| 101 | "strings" | ||
| 102 | "testing" | ||
| 103 | ) | ||
| 104 | |||
| 105 | type fake struct{ name, ext string } | ||
| 106 | |||
| 107 | func (f fake) Name() string { return f.name } | ||
| 108 | func (f fake) Match(p string) bool { return strings.EqualFold(filepath.Ext(p), f.ext) } | ||
| 109 | func (fake) Metadata(string) (Meta, error) { return Meta{}, nil } | ||
| 110 | func (fake) Resize(string, int, io.Writer) error { return nil } | ||
| 111 | func (fake) Kind() Kind { return KindImage } | ||
| 112 | |||
| 113 | func 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 | |||
| 130 | func 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 | |||
| 144 | Run: =go test ./internal/format/= | ||
| 145 | Expected: 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. | ||
| 151 | package format | ||
| 152 | |||
| 153 | import ( | ||
| 154 | "io" | ||
| 155 | "path/filepath" | ||
| 156 | "strings" | ||
| 157 | "time" | ||
| 158 | ) | ||
| 159 | |||
| 160 | type Kind int | ||
| 161 | |||
| 162 | const ( | ||
| 163 | KindImage Kind = iota | ||
| 164 | ) | ||
| 165 | |||
| 166 | // Meta holds display metadata. Width and Height are after EXIF orientation. | ||
| 167 | type 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 | |||
| 176 | type 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. | ||
| 185 | type Registry struct { | ||
| 186 | formats []Format | ||
| 187 | } | ||
| 188 | |||
| 189 | func (r *Registry) Register(f Format) { | ||
| 190 | r.formats = append(r.formats, f) | ||
| 191 | } | ||
| 192 | |||
| 193 | func (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 | |||
| 202 | func 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 | |||
| 215 | Run: =go test ./internal/format/ && go vet ./...= | ||
| 216 | Expected: PASS. | ||
| 217 | |||
| 218 | - [ ] Step 6: Commit | ||
| 219 | |||
| 220 | #+begin_src sh | ||
| 221 | git add .gitignore go.mod internal/format | ||
| 222 | git commit -m "Add format registry" | ||
| 223 | #+end_src | ||
| 224 | |||
| 225 | * Task 2: EXIF parsing | ||
| 226 | |||
| 227 | Files: | ||
| 228 | - Create: =internal/format/exif.go= | ||
| 229 | - Test: =internal/format/exif_test.go= | ||
| 230 | |||
| 231 | Interfaces: | ||
| 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 | ||
| 239 | package format | ||
| 240 | |||
| 241 | import ( | ||
| 242 | "testing" | ||
| 243 | "time" | ||
| 244 | ) | ||
| 245 | |||
| 246 | func 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 | |||
| 272 | func 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 | |||
| 284 | func 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 | |||
| 305 | func 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 | |||
| 315 | func 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 | |||
| 326 | Run: =go test ./internal/format/ -run Exif= | ||
| 327 | Expected: FAIL, =undefined: parseExif=. | ||
| 328 | |||
| 329 | - [ ] Step 3: Implement =internal/format/exif.go= | ||
| 330 | |||
| 331 | #+begin_src go | ||
| 332 | package format | ||
| 333 | |||
| 334 | import ( | ||
| 335 | "encoding/json" | ||
| 336 | "errors" | ||
| 337 | "fmt" | ||
| 338 | "math" | ||
| 339 | "strconv" | ||
| 340 | "strings" | ||
| 341 | "time" | ||
| 342 | ) | ||
| 343 | |||
| 344 | type 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. | ||
| 359 | func 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. | ||
| 388 | func 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 | |||
| 399 | func 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 | |||
| 414 | func 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 | |||
| 428 | Run: =go test ./internal/format/ && go vet ./...= | ||
| 429 | Expected: PASS. | ||
| 430 | |||
| 431 | - [ ] Step 5: Commit | ||
| 432 | |||
| 433 | #+begin_src sh | ||
| 434 | git add internal/format | ||
| 435 | git commit -m "Parse exiftool metadata" | ||
| 436 | #+end_src | ||
| 437 | |||
| 438 | * Task 3: ImageMagick format and tool check | ||
| 439 | |||
| 440 | Files: | ||
| 441 | - Create: =internal/format/magick.go= | ||
| 442 | - Test: =internal/format/magick_test.go= | ||
| 443 | |||
| 444 | Interfaces: | ||
| 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 | |||
| 453 | These run the real tools and skip when either is missing. | ||
| 454 | |||
| 455 | #+begin_src go | ||
| 456 | package format | ||
| 457 | |||
| 458 | import ( | ||
| 459 | "bytes" | ||
| 460 | "fmt" | ||
| 461 | "image/jpeg" | ||
| 462 | "os/exec" | ||
| 463 | "path/filepath" | ||
| 464 | "testing" | ||
| 465 | "time" | ||
| 466 | ) | ||
| 467 | |||
| 468 | func requireTools(t *testing.T) { | ||
| 469 | t.Helper() | ||
| 470 | if err := CheckTools(); err != nil { | ||
| 471 | t.Skip(err) | ||
| 472 | } | ||
| 473 | } | ||
| 474 | |||
| 475 | func 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 | |||
| 482 | func 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 | |||
| 489 | func 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 | |||
| 502 | func 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 | |||
| 523 | func 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 | |||
| 541 | func 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 | |||
| 557 | func 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 | |||
| 565 | func 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 | |||
| 580 | Run: =go test ./internal/format/= | ||
| 581 | Expected: FAIL, =undefined: CheckTools=, =undefined: NewMagick=, =undefined: Default=. | ||
| 582 | |||
| 583 | - [ ] Step 3: Implement =internal/format/magick.go= | ||
| 584 | |||
| 585 | #+begin_src go | ||
| 586 | package format | ||
| 587 | |||
| 588 | import ( | ||
| 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. | ||
| 598 | type Magick struct { | ||
| 599 | name string | ||
| 600 | exts []string | ||
| 601 | } | ||
| 602 | |||
| 603 | func NewMagick(name string, exts ...string) *Magick { | ||
| 604 | return &Magick{name: name, exts: exts} | ||
| 605 | } | ||
| 606 | |||
| 607 | func (m *Magick) Name() string { return m.name } | ||
| 608 | func (m *Magick) Match(path string) bool { return hasExt(path, m.exts) } | ||
| 609 | func (m *Magick) Kind() Kind { return KindImage } | ||
| 610 | |||
| 611 | func (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 | |||
| 624 | func (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. | ||
| 638 | func 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. | ||
| 646 | func 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 | |||
| 658 | Run: =go test -v ./internal/format/ && go vet ./...= | ||
| 659 | Expected: PASS, with the =TestMagick*= tests reported as run (not skipped) on | ||
| 660 | a machine with =magick= and =exiftool=. | ||
| 661 | |||
| 662 | - [ ] Step 5: Commit | ||
| 663 | |||
| 664 | #+begin_src sh | ||
| 665 | git add internal/format | ||
| 666 | git commit -m "Add ImageMagick-backed JPEG and PNG formats" | ||
| 667 | #+end_src | ||
| 668 | |||
| 669 | * Task 4: Org parsing | ||
| 670 | |||
| 671 | Files: | ||
| 672 | - Create: =internal/library/org.go= | ||
| 673 | - Test: =internal/library/org_test.go= | ||
| 674 | |||
| 675 | Interfaces: | ||
| 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 | ||
| 684 | go 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 | ||
| 690 | package library | ||
| 691 | |||
| 692 | import ( | ||
| 693 | "strings" | ||
| 694 | "testing" | ||
| 695 | ) | ||
| 696 | |||
| 697 | func 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 | |||
| 713 | func 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 | |||
| 726 | Run: =go test ./internal/library/= | ||
| 727 | Expected: FAIL, =undefined: parseOrg=. | ||
| 728 | |||
| 729 | - [ ] Step 4: Implement =internal/library/org.go= | ||
| 730 | |||
| 731 | #+begin_src go | ||
| 732 | package library | ||
| 733 | |||
| 734 | import ( | ||
| 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. | ||
| 743 | func 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 | |||
| 770 | Run: =go mod tidy && go test ./internal/library/ && go vet ./...= | ||
| 771 | Expected: PASS. | ||
| 772 | |||
| 773 | - [ ] Step 6: Commit | ||
| 774 | |||
| 775 | #+begin_src sh | ||
| 776 | git add go.mod go.sum internal/library | ||
| 777 | git commit -m "Parse org keywords and body" | ||
| 778 | #+end_src | ||
| 779 | |||
| 780 | * Task 5: Metadata cache | ||
| 781 | |||
| 782 | Files: | ||
| 783 | - Create: =internal/library/metacache.go= | ||
| 784 | - Test: =internal/library/metacache_test.go= | ||
| 785 | |||
| 786 | Interfaces: | ||
| 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 | ||
| 798 | package library | ||
| 799 | |||
| 800 | import ( | ||
| 801 | "io/fs" | ||
| 802 | "os" | ||
| 803 | "path/filepath" | ||
| 804 | "testing" | ||
| 805 | "time" | ||
| 806 | |||
| 807 | "gitbay.org/cmc/gallery/internal/format" | ||
| 808 | ) | ||
| 809 | |||
| 810 | func 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 | |||
| 823 | func 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 | |||
| 850 | func 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 | |||
| 872 | func 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 | |||
| 887 | Run: =go test ./internal/library/ -run MetaCache= | ||
| 888 | Expected: FAIL, =undefined: OpenMetaCache=. | ||
| 889 | |||
| 890 | - [ ] Step 3: Implement =internal/library/metacache.go= | ||
| 891 | |||
| 892 | #+begin_src go | ||
| 893 | package library | ||
| 894 | |||
| 895 | import ( | ||
| 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 | |||
| 906 | type 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. | ||
| 913 | type MetaCache struct { | ||
| 914 | path string | ||
| 915 | entries map[string]metaEntry | ||
| 916 | } | ||
| 917 | |||
| 918 | func 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 | |||
| 934 | func (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 | |||
| 942 | func (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 | |||
| 946 | func (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 | |||
| 969 | Run: =go test ./internal/library/ && go vet ./...= | ||
| 970 | Expected: PASS. | ||
| 971 | |||
| 972 | - [ ] Step 5: Commit | ||
| 973 | |||
| 974 | #+begin_src sh | ||
| 975 | git add internal/library | ||
| 976 | git commit -m "Add metadata cache" | ||
| 977 | #+end_src | ||
| 978 | |||
| 979 | * Task 6: Library model and scan | ||
| 980 | |||
| 981 | Files: | ||
| 982 | - Create: =internal/library/library.go=, =internal/library/scan.go= | ||
| 983 | - Test: =internal/library/scan_test.go= | ||
| 984 | |||
| 985 | Interfaces: | ||
| 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 | ||
| 999 | package library | ||
| 1000 | |||
| 1001 | import ( | ||
| 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 | |||
| 1013 | type fakeFormat struct { | ||
| 1014 | meta map[string]format.Meta // by base name | ||
| 1015 | fail map[string]bool | ||
| 1016 | calls int | ||
| 1017 | } | ||
| 1018 | |||
| 1019 | func (f *fakeFormat) Name() string { return "fake" } | ||
| 1020 | func (f *fakeFormat) Match(p string) bool { return strings.EqualFold(filepath.Ext(p), ".jpg") } | ||
| 1021 | func (f *fakeFormat) Kind() format.Kind { return format.KindImage } | ||
| 1022 | func (f *fakeFormat) Resize(string, int, io.Writer) error { return nil } | ||
| 1023 | func (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 | |||
| 1035 | func day(n int) time.Time { return time.Date(2025, 3, n, 0, 0, 0, 0, time.UTC) } | ||
| 1036 | |||
| 1037 | func 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 | |||
| 1052 | func 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 | |||
| 1069 | func 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 | |||
| 1077 | func 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 | |||
| 1085 | func 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 | |||
| 1126 | func 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 | |||
| 1151 | func 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 | |||
| 1169 | func 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 | |||
| 1179 | func 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 | |||
| 1187 | func 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 | |||
| 1204 | func 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 | |||
| 1216 | func 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 | |||
| 1231 | Run: =go test ./internal/library/= | ||
| 1232 | Expected: 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. | ||
| 1238 | package library | ||
| 1239 | |||
| 1240 | import ( | ||
| 1241 | "html/template" | ||
| 1242 | "sort" | ||
| 1243 | "strconv" | ||
| 1244 | "sync/atomic" | ||
| 1245 | "time" | ||
| 1246 | |||
| 1247 | "gitbay.org/cmc/gallery/internal/format" | ||
| 1248 | ) | ||
| 1249 | |||
| 1250 | type Library struct { | ||
| 1251 | Albums []*Album | ||
| 1252 | About template.HTML | ||
| 1253 | bySlug map[string]*Album | ||
| 1254 | } | ||
| 1255 | |||
| 1256 | func (l *Library) Album(slug string) *Album { | ||
| 1257 | return l.bySlug[slug] | ||
| 1258 | } | ||
| 1259 | |||
| 1260 | type 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 | |||
| 1272 | func (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. | ||
| 1281 | func (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 | |||
| 1291 | func (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 | |||
| 1306 | type 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. | ||
| 1317 | type Store struct { | ||
| 1318 | p atomic.Pointer[Library] | ||
| 1319 | } | ||
| 1320 | |||
| 1321 | func (s *Store) Load() *Library { return s.p.Load() } | ||
| 1322 | func (s *Store) Set(l *Library) { s.p.Store(l) } | ||
| 1323 | |||
| 1324 | // sortAlbums puts albums with #+order first (ascending), then the rest newest first. | ||
| 1325 | func 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 | |||
| 1343 | func 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 | ||
| 1357 | package library | ||
| 1358 | |||
| 1359 | import ( | ||
| 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. | ||
| 1370 | var 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. | ||
| 1373 | func 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 | |||
| 1415 | func 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 | |||
| 1503 | Run: =go test ./internal/library/ && go vet ./...= | ||
| 1504 | Expected: PASS. | ||
| 1505 | |||
| 1506 | - [ ] Step 6: Commit | ||
| 1507 | |||
| 1508 | #+begin_src sh | ||
| 1509 | git add internal/library | ||
| 1510 | git commit -m "Scan photo folders into albums" | ||
| 1511 | #+end_src | ||
| 1512 | |||
| 1513 | * Task 7: Filesystem watcher | ||
| 1514 | |||
| 1515 | Files: | ||
| 1516 | - Create: =internal/library/watch.go= | ||
| 1517 | - Test: =internal/library/watch_test.go= | ||
| 1518 | |||
| 1519 | Interfaces: | ||
| 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 | ||
| 1528 | go 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 | ||
| 1534 | package library | ||
| 1535 | |||
| 1536 | import ( | ||
| 1537 | "context" | ||
| 1538 | "os" | ||
| 1539 | "path/filepath" | ||
| 1540 | "testing" | ||
| 1541 | "time" | ||
| 1542 | ) | ||
| 1543 | |||
| 1544 | func 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 | |||
| 1553 | func 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 | |||
| 1583 | Run: =go test ./internal/library/ -run Watch= | ||
| 1584 | Expected: FAIL, =undefined: Watch=. | ||
| 1585 | |||
| 1586 | - [ ] Step 4: Implement =internal/library/watch.go= | ||
| 1587 | |||
| 1588 | #+begin_src go | ||
| 1589 | package library | ||
| 1590 | |||
| 1591 | import ( | ||
| 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. | ||
| 1603 | func 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 | |||
| 1645 | func 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 | |||
| 1669 | Run: =go mod tidy && go test -race ./internal/library/ && go vet ./...= | ||
| 1670 | Expected: PASS. | ||
| 1671 | |||
| 1672 | - [ ] Step 6: Commit | ||
| 1673 | |||
| 1674 | #+begin_src sh | ||
| 1675 | git add go.mod go.sum internal/library | ||
| 1676 | git commit -m "Rescan on filesystem changes" | ||
| 1677 | #+end_src | ||
| 1678 | |||
| 1679 | * Task 8: Renderer and derivative cache | ||
| 1680 | |||
| 1681 | Files: | ||
| 1682 | - Create: =internal/render/render.go= | ||
| 1683 | - Test: =internal/render/render_test.go= | ||
| 1684 | |||
| 1685 | Interfaces: | ||
| 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 | ||
| 1697 | go 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 | ||
| 1703 | package render | ||
| 1704 | |||
| 1705 | import ( | ||
| 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 | |||
| 1719 | type fakeFormat struct { | ||
| 1720 | calls atomic.Int32 | ||
| 1721 | width atomic.Int32 | ||
| 1722 | fail bool | ||
| 1723 | } | ||
| 1724 | |||
| 1725 | func (f *fakeFormat) Name() string { return "fake" } | ||
| 1726 | func (f *fakeFormat) Match(string) bool { return true } | ||
| 1727 | func (f *fakeFormat) Kind() format.Kind { return format.KindImage } | ||
| 1728 | func (f *fakeFormat) Metadata(string) (format.Meta, error) { return format.Meta{}, nil } | ||
| 1729 | func (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 | |||
| 1741 | func 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 | |||
| 1750 | func 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 | |||
| 1763 | func 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 | |||
| 1794 | func 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 | |||
| 1800 | func 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 | |||
| 1812 | func 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 | |||
| 1829 | func 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 | |||
| 1843 | func 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 | |||
| 1873 | Run: =go test ./internal/render/= | ||
| 1874 | Expected: 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. | ||
| 1880 | package render | ||
| 1881 | |||
| 1882 | import ( | ||
| 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 | |||
| 1894 | var Widths = []int{480, 960, 1600, 2400} | ||
| 1895 | |||
| 1896 | func Allowed(w int) bool { return slices.Contains(Widths, w) } | ||
| 1897 | |||
| 1898 | type Renderer struct { | ||
| 1899 | root string | ||
| 1900 | sem chan struct{} | ||
| 1901 | group singleflight.Group | ||
| 1902 | } | ||
| 1903 | |||
| 1904 | func New(cacheDir string, concurrency int) *Renderer { | ||
| 1905 | return &Renderer{ | ||
| 1906 | root: filepath.Join(cacheDir, "img"), | ||
| 1907 | sem: make(chan struct{}, concurrency), | ||
| 1908 | } | ||
| 1909 | } | ||
| 1910 | |||
| 1911 | func (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 | |||
| 1915 | func (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. | ||
| 1920 | func (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 | |||
| 1942 | func (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. | ||
| 1965 | func (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 | |||
| 1989 | Run: =go mod tidy && go test -race ./internal/render/ && go vet ./...= | ||
| 1990 | Expected: PASS. | ||
| 1991 | |||
| 1992 | - [ ] Step 6: Commit | ||
| 1993 | |||
| 1994 | #+begin_src sh | ||
| 1995 | git add go.mod go.sum internal/render | ||
| 1996 | git commit -m "Add derivative renderer with disk cache" | ||
| 1997 | #+end_src | ||
| 1998 | |||
| 1999 | * Task 9: Web server, templates and theme | ||
| 2000 | |||
| 2001 | Files: | ||
| 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 | |||
| 2007 | Interfaces: | ||
| 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 | ||
| 2022 | package web | ||
| 2023 | |||
| 2024 | import ( | ||
| 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 | |||
| 2040 | type fakeFormat struct{ fail bool } | ||
| 2041 | |||
| 2042 | func (fakeFormat) Name() string { return "fake" } | ||
| 2043 | func (fakeFormat) Match(p string) bool { return strings.HasSuffix(p, ".jpg") } | ||
| 2044 | func (fakeFormat) Kind() format.Kind { return format.KindImage } | ||
| 2045 | func (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 | } | ||
| 2052 | func (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 | |||
| 2060 | type setup struct { | ||
| 2061 | fail bool | ||
| 2062 | about bool | ||
| 2063 | opt Options | ||
| 2064 | } | ||
| 2065 | |||
| 2066 | func 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 | |||
| 2104 | func 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 | |||
| 2110 | func 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 | |||
| 2146 | func 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 | |||
| 2156 | func 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 | |||
| 2187 | func 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 | |||
| 2194 | func 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 | |||
| 2208 | func 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 | |||
| 2226 | Run: =go test ./internal/web/= | ||
| 2227 | Expected: FAIL, =undefined: New=, =undefined: Options=. | ||
| 2228 | |||
| 2229 | - [ ] Step 3: Implement =internal/web/funcs.go= | ||
| 2230 | |||
| 2231 | #+begin_src go | ||
| 2232 | package web | ||
| 2233 | |||
| 2234 | import ( | ||
| 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 | |||
| 2245 | var 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 | |||
| 2255 | func albumURL(a *library.Album) string { | ||
| 2256 | return "/" + url.PathEscape(a.Slug) + "/" | ||
| 2257 | } | ||
| 2258 | |||
| 2259 | func photoURL(it *library.Item) string { | ||
| 2260 | return "/" + url.PathEscape(it.Album) + "/" + url.PathEscape(it.Name) + "/" | ||
| 2261 | } | ||
| 2262 | |||
| 2263 | func 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. | ||
| 2268 | func 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 | |||
| 2283 | func 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 | |||
| 2290 | func 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. | ||
| 2309 | package web | ||
| 2310 | |||
| 2311 | import ( | ||
| 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 | ||
| 2328 | var embeddedTemplates embed.FS | ||
| 2329 | |||
| 2330 | //go:embed assets/theme.css | ||
| 2331 | var embeddedTheme []byte | ||
| 2332 | |||
| 2333 | type 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 | |||
| 2339 | var pageNames = []string{"home", "album", "photo", "info", "error"} | ||
| 2340 | |||
| 2341 | type server struct { | ||
| 2342 | store *library.Store | ||
| 2343 | rend *render.Renderer | ||
| 2344 | title string | ||
| 2345 | theme []byte | ||
| 2346 | pages map[string]*template.Template | ||
| 2347 | } | ||
| 2348 | |||
| 2349 | type 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 | |||
| 2362 | func 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 | |||
| 2398 | func (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 | |||
| 2414 | func (s *server) error(w http.ResponseWriter, code int) { | ||
| 2415 | s.render(w, "error", code, page{Code: code, Message: http.StatusText(code)}) | ||
| 2416 | } | ||
| 2417 | |||
| 2418 | func (s *server) home(w http.ResponseWriter, r *http.Request) { | ||
| 2419 | s.render(w, "home", http.StatusOK, page{}) | ||
| 2420 | } | ||
| 2421 | |||
| 2422 | func (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 | |||
| 2431 | func (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 | |||
| 2441 | func (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 | |||
| 2463 | func (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 | |||
| 2492 | func (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 | } | ||
| 2610 | html { box-sizing: border-box; } *, *::before, *::after { box-sizing: inherit; } | ||
| 2611 | html, body { margin: 0; background: var(--bg); color: var(--fg); font: 13px/1.5 var(--font); } | ||
| 2612 | a { color: inherit; text-decoration: none; } | ||
| 2613 | |||
| 2614 | nav.site { display: flex; flex-wrap: wrap; gap: 8px 22px; padding: 18px 20px; font-size: 10px; letter-spacing: var(--tracking); text-transform: uppercase; color: var(--muted); } | ||
| 2615 | nav.site .brand { color: var(--accent); font-weight: 600; margin-right: auto; } | ||
| 2616 | nav.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 | |||
| 2628 | body.photo { display: flex; flex-direction: column; min-height: 100vh; } | ||
| 2629 | body.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 | |||
| 2651 | Run: =go test ./internal/web/ && go vet ./...= | ||
| 2652 | Expected: 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 | ||
| 2658 | git add internal/web | ||
| 2659 | git commit -m "Add web handlers, templates and theme" | ||
| 2660 | #+end_src | ||
| 2661 | |||
| 2662 | * Task 10: Page script (justified rows, keyboard, swipe) | ||
| 2663 | |||
| 2664 | Files: | ||
| 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 | |||
| 2669 | Interfaces: | ||
| 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 | ||
| 2678 | func 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 | |||
| 2692 | Run: =go test ./internal/web/ -run Script= | ||
| 2693 | Expected: 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 | |||
| 2749 | In =internal/web/server.go=, below =embeddedTheme=: | ||
| 2750 | |||
| 2751 | #+begin_src go | ||
| 2752 | //go:embed assets/gallery.js | ||
| 2753 | var embeddedScript []byte | ||
| 2754 | #+end_src | ||
| 2755 | |||
| 2756 | Register the route after =/_/theme.css=: | ||
| 2757 | |||
| 2758 | #+begin_src go | ||
| 2759 | mux.HandleFunc("GET /_/gallery.js", s.script) | ||
| 2760 | #+end_src | ||
| 2761 | |||
| 2762 | Add the handler after =themeCSS=: | ||
| 2763 | |||
| 2764 | #+begin_src go | ||
| 2765 | func (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 | |||
| 2772 | In =internal/web/templates/base.html=, replace | ||
| 2773 | |||
| 2774 | #+begin_src html | ||
| 2775 | <link rel="stylesheet" href="/_/theme.css"> | ||
| 2776 | #+end_src | ||
| 2777 | |||
| 2778 | with | ||
| 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 | |||
| 2787 | Run: =go test ./internal/web/ && go vet ./...= | ||
| 2788 | Expected: PASS. | ||
| 2789 | |||
| 2790 | - [ ] Step 6: Commit | ||
| 2791 | |||
| 2792 | #+begin_src sh | ||
| 2793 | git add internal/web | ||
| 2794 | git commit -m "Add justified row layout and photo navigation script" | ||
| 2795 | #+end_src | ||
| 2796 | |||
| 2797 | * Task 11: Command, wiring and local run | ||
| 2798 | |||
| 2799 | Files: | ||
| 2800 | - Create: =cmd/gallery/main.go= | ||
| 2801 | - Modify: =README.org= | ||
| 2802 | |||
| 2803 | Interfaces: | ||
| 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 | ||
| 2814 | package main | ||
| 2815 | |||
| 2816 | import ( | ||
| 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 | |||
| 2836 | func env(key, def string) string { | ||
| 2837 | if v := os.Getenv(key); v != "" { | ||
| 2838 | return v | ||
| 2839 | } | ||
| 2840 | return def | ||
| 2841 | } | ||
| 2842 | |||
| 2843 | func 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 | ||
| 2920 | go build -o gallery ./cmd/gallery | ||
| 2921 | mkdir -p photos/tokyo photos/iceland | ||
| 2922 | for i in 1 2 3 4 5; do magick -size $((400 + i * 150))x600 plasma: photos/tokyo/t$i.jpg; done | ||
| 2923 | for i in 1 2 3; do magick -size 900x$((400 + i * 120)) gradient:navy-orange photos/iceland/i$i.png; done | ||
| 2924 | printf '#+title: Tokyo\n#+date: 2025-03\n\nA /quiet/ week.\n' > photos/tokyo/album.org | ||
| 2925 | printf 'Photographs by me.\n' > photos/about.org | ||
| 2926 | ./gallery -photos ./photos -cache ./cache -addr 127.0.0.1:8080 & | ||
| 2927 | #+end_src | ||
| 2928 | |||
| 2929 | Expected log: =library: 2 albums= and =listening on 127.0.0.1:8080=. | ||
| 2930 | |||
| 2931 | - [ ] Step 3: Check the endpoints | ||
| 2932 | |||
| 2933 | #+begin_src sh | ||
| 2934 | curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/ | ||
| 2935 | curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/tokyo/ | ||
| 2936 | curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/tokyo/t1.jpg/ | ||
| 2937 | curl -s -D - -o /tmp/g.jpg http://127.0.0.1:8080/img/tokyo/t1.jpg/480.jpg | grep -i cache-control | ||
| 2938 | magick identify /tmp/g.jpg | ||
| 2939 | #+end_src | ||
| 2940 | |||
| 2941 | Expected: =200= three times; =Cache-Control: public, max-age=31536000, | ||
| 2942 | immutable=; identify reports =JPEG 480x...=. | ||
| 2943 | |||
| 2944 | - [ ] Step 4: Check the watcher | ||
| 2945 | |||
| 2946 | #+begin_src sh | ||
| 2947 | magick -size 800x500 plasma: photos/tokyo/t6.jpg | ||
| 2948 | sleep 1 | ||
| 2949 | curl -s http://127.0.0.1:8080/tokyo/ | grep -c 'href="/tokyo/t6.jpg/"' | ||
| 2950 | #+end_src | ||
| 2951 | |||
| 2952 | Expected: =1=, and a second =library: 2 albums= log line. | ||
| 2953 | |||
| 2954 | - [ ] Step 5: Check the pages in a browser | ||
| 2955 | |||
| 2956 | Open =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 | |||
| 2963 | Stop the server (=kill %1=). | ||
| 2964 | |||
| 2965 | - [ ] Step 6: Document usage in =README.org= | ||
| 2966 | |||
| 2967 | Replace the file with: | ||
| 2968 | |||
| 2969 | #+begin_src org | ||
| 2970 | ,#+title: gallery | ||
| 2971 | |||
| 2972 | Self-hosted photo gallery. Albums are folders; resized copies are generated on | ||
| 2973 | request with ImageMagick and cached on disk. | ||
| 2974 | |||
| 2975 | ,* Requirements | ||
| 2976 | |||
| 2977 | Go 1.27, ImageMagick 7 (=magick=), exiftool. | ||
| 2978 | |||
| 2979 | ,* Photos | ||
| 2980 | |||
| 2981 | ,#+begin_example | ||
| 2982 | photos/ | ||
| 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 | |||
| 3000 | Text below the keywords is shown as the album description. | ||
| 3001 | |||
| 3002 | ,* Run | ||
| 3003 | |||
| 3004 | ,#+begin_src sh | ||
| 3005 | go 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 | ||
| 3019 | at a directory with the same file names as [[file:internal/web/templates]]. | ||
| 3020 | |||
| 3021 | See [[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 | ||
| 3025 | plan; the real =README.org= has none.) | ||
| 3026 | |||
| 3027 | - [ ] Step 7: Run the full suite and commit | ||
| 3028 | |||
| 3029 | #+begin_src sh | ||
| 3030 | go test -race ./... && go vet ./... | ||
| 3031 | git add cmd README.org | ||
| 3032 | git commit -m "Add gallery command" | ||
| 3033 | #+end_src | ||
| 3034 | |||
| 3035 | * Task 12: Docker image and homelab deploy | ||
| 3036 | |||
| 3037 | Files: | ||
| 3038 | - Create: =Dockerfile=, =compose.yml=, =.dockerignore= | ||
| 3039 | - Modify: =README.org= (Deploy section) | ||
| 3040 | |||
| 3041 | Interfaces: | ||
| 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 | ||
| 3048 | FROM golang:1-alpine AS build | ||
| 3049 | WORKDIR /src | ||
| 3050 | COPY go.mod go.sum ./ | ||
| 3051 | RUN go mod download | ||
| 3052 | COPY . . | ||
| 3053 | RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /gallery ./cmd/gallery | ||
| 3054 | |||
| 3055 | FROM alpine:3 | ||
| 3056 | RUN apk add --no-cache imagemagick imagemagick-jpeg imagemagick-heic imagemagick-webp exiftool | ||
| 3057 | COPY --from=build /gallery /usr/local/bin/gallery | ||
| 3058 | ENV GALLERY_PHOTOS=/photos GALLERY_CACHE=/cache GALLERY_ADDR=:8080 | ||
| 3059 | EXPOSE 8080 | ||
| 3060 | ENTRYPOINT ["gallery"] | ||
| 3061 | #+end_src | ||
| 3062 | |||
| 3063 | - [ ] Step 2: Create =.dockerignore= | ||
| 3064 | |||
| 3065 | #+begin_example | ||
| 3066 | .git | ||
| 3067 | photos | ||
| 3068 | cache | ||
| 3069 | gallery | ||
| 3070 | .superpowers | ||
| 3071 | #+end_example | ||
| 3072 | |||
| 3073 | - [ ] Step 3: Create =compose.yml= | ||
| 3074 | |||
| 3075 | #+begin_src yaml | ||
| 3076 | services: | ||
| 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 | ||
| 3093 | docker build -t gallery . | ||
| 3094 | docker run --rm --entrypoint magick gallery -list format | grep -E '^ +(JPEG|PNG|HEIC|WEBP)' | ||
| 3095 | docker run --rm --entrypoint exiftool gallery -ver | ||
| 3096 | #+end_src | ||
| 3097 | |||
| 3098 | Expected: rows for JPEG, PNG, HEIC and WEBP with =rw= mode, and an exiftool | ||
| 3099 | version. If PNG is missing, add =imagemagick-png= to the =apk add= line (the | ||
| 3100 | Alpine split varies by release) and rebuild. | ||
| 3101 | |||
| 3102 | #+begin_src sh | ||
| 3103 | docker compose up -d --build | ||
| 3104 | curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8003/ | ||
| 3105 | curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8003/img/tokyo/t1.jpg/960.jpg | ||
| 3106 | docker compose down | ||
| 3107 | #+end_src | ||
| 3108 | |||
| 3109 | Expected: =200= twice (using the sample =photos/= from Task 11). | ||
| 3110 | |||
| 3111 | - [ ] Step 5: Add a Deploy section to =README.org= | ||
| 3112 | |||
| 3113 | Append: | ||
| 3114 | |||
| 3115 | #+begin_src org | ||
| 3116 | ,* Deploy | ||
| 3117 | |||
| 3118 | ,#+begin_src sh | ||
| 3119 | git clone https://gitbay.org/cmc/gallery.git ~/docker/gallery | ||
| 3120 | cd ~/docker/gallery | ||
| 3121 | GALLERY_PHOTOS_DIR=/path/to/photos docker compose up -d --build | ||
| 3122 | ,#+end_src | ||
| 3123 | |||
| 3124 | The container listens on =127.0.0.1:8003=; put a reverse proxy in front of it. | ||
| 3125 | Photos 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 | ||
| 3131 | go test -race ./... && go vet ./... | ||
| 3132 | git add Dockerfile .dockerignore compose.yml README.org | ||
| 3133 | git commit -m "Add Docker image and compose file" | ||
| 3134 | git push -u origin v1 | ||
| 3135 | gitbay mr create --source v1 --target main --title "gallery v1" --body "Folder-based albums, on-demand ImageMagick resize with disk cache, format registry (JPEG, PNG), dark justified-row theme, Docker/compose deploy." | ||
| 3136 | #+end_src | ||
| 3137 | |||
| 3138 | - [ ] Step 7: Deploy to homelab (after the MR is merged) | ||
| 3139 | |||
| 3140 | #+begin_src sh | ||
| 3141 | ssh homelab 'git clone https://gitbay.org/cmc/gallery.git ~/docker/gallery' | ||
| 3142 | #+end_src | ||
| 3143 | |||
| 3144 | Then, 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 | ||
| 3148 | ssh homelab 'cd ~/docker/gallery && docker compose up -d --build && sleep 3 && curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8003/' | ||
| 3149 | #+end_src | ||
| 3150 | |||
| 3151 | Expected: =200=. The nginx site for the public hostname is out of scope for | ||
| 3152 | this plan; it needs the hostname from the user. | ||
docs/specs/2026-09-25-gallery-design.org +4 −3
| @@ -117,8 +117,8 @@ type Format interface { | |||
| 117 | size. Only new or changed files are passed to =exiftool=. | 117 | size. Only new or changed files are passed to =exiftool=. |
| 118 | - The library is an immutable snapshot swapped atomically behind an | 118 | - The library is an immutable snapshot swapped atomically behind an |
| 119 | =atomic.Pointer=. =fsnotify= watches the root and each album directory; | 119 | =atomic.Pointer=. =fsnotify= watches the root and each album directory; |
| 120 | events are debounced (500 ms) and trigger a rescan of the affected album, or | 120 | events are debounced (500 ms) and trigger a full rescan. Rescans are cheap |
| 121 | of the root when albums are added or removed. | 121 | because unchanged files are served from the metadata cache. |
| 122 | - A file that fails metadata extraction is logged and excluded. | 122 | - A file that fails metadata extraction is logged and excluded. |
| 123 | 123 | ||
| 124 | ** Rendering and cache | 124 | ** Rendering and cache |
| @@ -172,7 +172,8 @@ photo page. | |||
| 172 | 172 | ||
| 173 | ** Justified rows | 173 | ** Justified rows |
| 174 | 174 | ||
| 175 | - The server emits each item's aspect ratio as =--ar= and =flex-grow= inline. | 175 | - The server emits each item's aspect ratio as =--ar= (inline style) and |
| 176 | =data-ar=. | ||
| 176 | - Base CSS: =display: flex; flex-wrap: wrap;= with each tile's width set from | 177 | - Base CSS: =display: flex; flex-wrap: wrap;= with each tile's width set from |
| 177 | =--row-height × --ar=, so layout is uncropped and stable without JS. | 178 | =--row-height × --ar=, so layout is uncropped and stable without JS. |
| 178 | - A script (target: under 50 lines) partitions items into rows whose summed | 179 | - A script (target: under 50 lines) partitions items into rows whose summed |