| @@ -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. |