gallery v1 !2
37 files changed, +2521 −0
Layout: unified · split
.dockerignore added +5
| @@ -0,0 +1,5 @@ | |||
| 1 | .git | ||
| 2 | photos | ||
| 3 | cache | ||
| 4 | gallery | ||
| 5 | .superpowers | ||
.gitignore added +4
| @@ -0,0 +1,4 @@ | |||
| 1 | /photos/ | ||
| 2 | /cache/ | ||
| 3 | /gallery | ||
| 4 | .superpowers/ | ||
Dockerfile added +13
| @@ -0,0 +1,13 @@ | |||
| 1 | FROM golang:1-alpine AS build | ||
| 2 | WORKDIR /src | ||
| 3 | COPY go.mod go.sum ./ | ||
| 4 | RUN go mod download | ||
| 5 | COPY . . | ||
| 6 | RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /gallery ./cmd/gallery | ||
| 7 | |||
| 8 | FROM alpine:3 | ||
| 9 | RUN apk add --no-cache imagemagick imagemagick-jpeg imagemagick-heic imagemagick-webp exiftool | ||
| 10 | COPY --from=build /gallery /usr/local/bin/gallery | ||
| 11 | ENV GALLERY_PHOTOS=/photos GALLERY_CACHE=/cache GALLERY_ADDR=:8080 | ||
| 12 | EXPOSE 8080 | ||
| 13 | ENTRYPOINT ["gallery"] | ||
README.org +57
| @@ -3,4 +3,61 @@ | |||
| 3 | Self-hosted photo gallery. Albums are folders; resized copies are generated on | 3 | Self-hosted photo gallery. Albums are folders; resized copies are generated on |
| 4 | request with ImageMagick and cached on disk. | 4 | request with ImageMagick and cached on disk. |
| 5 | 5 | ||
| 6 | * Requirements | ||
| 7 | |||
| 8 | Go 1.27, ImageMagick 7 (=magick=), exiftool. | ||
| 9 | |||
| 10 | * Photos | ||
| 11 | |||
| 12 | #+begin_example | ||
| 13 | photos/ | ||
| 14 | about.org optional; rendered as the Info page | ||
| 15 | tokyo/ | ||
| 16 | album.org optional | ||
| 17 | DSCF0412.jpg | ||
| 18 | ... | ||
| 19 | #+end_example | ||
| 20 | |||
| 21 | =album.org= keywords, all optional: | ||
| 22 | |||
| 23 | | Keyword | Meaning | Default | | ||
| 24 | |------------+-------------------------------------------+--------------------| | ||
| 25 | | =#+title= | album title | folder name | | ||
| 26 | | =#+date= | =YYYY=, =YYYY-MM= or =YYYY-MM-DD= | newest photo | | ||
| 27 | | =#+cover= | file name of the cover photo | first photo | | ||
| 28 | | =#+order= | integer; ordered albums come first | unset | | ||
| 29 | | =#+sort= | =taken= or =name= | =taken= | | ||
| 30 | |||
| 31 | Text below the keywords is shown as the album description. | ||
| 32 | |||
| 33 | * Run | ||
| 34 | |||
| 35 | #+begin_src sh | ||
| 36 | go build -o gallery ./cmd/gallery | ||
| 37 | ./gallery -photos ./photos -cache ./cache -addr :8080 | ||
| 38 | #+end_src | ||
| 39 | |||
| 40 | | Flag | Env | Default | | ||
| 41 | |--------------+---------------------+-----------| | ||
| 42 | | =-photos= | =GALLERY_PHOTOS= | required | | ||
| 43 | | =-cache= | =GALLERY_CACHE= | required | | ||
| 44 | | =-addr= | =GALLERY_ADDR= | =:8080= | | ||
| 45 | | =-theme= | =GALLERY_THEME= | embedded | | ||
| 46 | | =-templates= | =GALLERY_TEMPLATES= | embedded | | ||
| 47 | | =-title= | =GALLERY_TITLE= | =gallery= | | ||
| 48 | |||
| 49 | =-theme= replaces [[file:internal/web/assets/theme.css]]. =-templates= points | ||
| 50 | at a directory with the same file names as [[file:internal/web/templates]]. | ||
| 51 | |||
| 6 | See [[file:docs/specs/2026-09-25-gallery-design.org][the design spec]]. | 52 | See [[file:docs/specs/2026-09-25-gallery-design.org][the design spec]]. |
| 53 | |||
| 54 | * Deploy | ||
| 55 | |||
| 56 | #+begin_src sh | ||
| 57 | git clone https://gitbay.org/cmc/gallery.git ~/docker/gallery | ||
| 58 | cd ~/docker/gallery | ||
| 59 | GALLERY_PHOTOS_DIR=/path/to/photos docker compose up -d --build | ||
| 60 | #+end_src | ||
| 61 | |||
| 62 | The container listens on =127.0.0.1:8003=; put a reverse proxy in front of it. | ||
| 63 | Photos are mounted read-only; derivatives and =meta.json= live in =./cache=. | ||
cmd/gallery/main.go added +106
| @@ -0,0 +1,106 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "errors" | ||
| 6 | "flag" | ||
| 7 | "fmt" | ||
| 8 | "log" | ||
| 9 | "net/http" | ||
| 10 | "os" | ||
| 11 | "os/signal" | ||
| 12 | "path/filepath" | ||
| 13 | "runtime" | ||
| 14 | "syscall" | ||
| 15 | "time" | ||
| 16 | |||
| 17 | "gitbay.org/cmc/gallery/internal/format" | ||
| 18 | "gitbay.org/cmc/gallery/internal/library" | ||
| 19 | "gitbay.org/cmc/gallery/internal/render" | ||
| 20 | "gitbay.org/cmc/gallery/internal/web" | ||
| 21 | ) | ||
| 22 | |||
| 23 | func env(key, def string) string { | ||
| 24 | if v := os.Getenv(key); v != "" { | ||
| 25 | return v | ||
| 26 | } | ||
| 27 | return def | ||
| 28 | } | ||
| 29 | |||
| 30 | func main() { | ||
| 31 | photos := flag.String("photos", env("GALLERY_PHOTOS", ""), "photo root (required)") | ||
| 32 | cache := flag.String("cache", env("GALLERY_CACHE", ""), "cache directory (required)") | ||
| 33 | addr := flag.String("addr", env("GALLERY_ADDR", ":8080"), "listen address") | ||
| 34 | theme := flag.String("theme", env("GALLERY_THEME", ""), "replacement theme.css") | ||
| 35 | templates := flag.String("templates", env("GALLERY_TEMPLATES", ""), "replacement template directory") | ||
| 36 | title := flag.String("title", env("GALLERY_TITLE", "gallery"), "site name") | ||
| 37 | flag.Parse() | ||
| 38 | if *photos == "" || *cache == "" { | ||
| 39 | fmt.Fprintln(os.Stderr, "gallery: -photos and -cache are required") | ||
| 40 | flag.Usage() | ||
| 41 | os.Exit(2) | ||
| 42 | } | ||
| 43 | if err := format.CheckTools(); err != nil { | ||
| 44 | log.Fatal(err) | ||
| 45 | } | ||
| 46 | |||
| 47 | reg := format.Default() | ||
| 48 | meta, err := library.OpenMetaCache(filepath.Join(*cache, "meta.json")) | ||
| 49 | if err != nil { | ||
| 50 | log.Fatal(err) | ||
| 51 | } | ||
| 52 | rend := render.New(*cache, runtime.NumCPU()) | ||
| 53 | store := &library.Store{} | ||
| 54 | rescan := func() error { | ||
| 55 | lib, err := library.Scan(*photos, reg, meta) | ||
| 56 | if err != nil { | ||
| 57 | return err | ||
| 58 | } | ||
| 59 | store.Set(lib) | ||
| 60 | if lib.Incomplete { | ||
| 61 | log.Printf("scan incomplete: skipping prune") | ||
| 62 | } else if err := rend.Prune(lib); err != nil { | ||
| 63 | log.Printf("prune: %v", err) | ||
| 64 | } | ||
| 65 | log.Printf("library: %d albums", len(lib.Albums)) | ||
| 66 | return nil | ||
| 67 | } | ||
| 68 | if err := rescan(); err != nil { | ||
| 69 | log.Fatal(err) | ||
| 70 | } | ||
| 71 | |||
| 72 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) | ||
| 73 | defer stop() | ||
| 74 | onChange := func() { | ||
| 75 | if err := rescan(); err != nil { | ||
| 76 | log.Printf("rescan: %v", err) | ||
| 77 | } | ||
| 78 | } | ||
| 79 | if err := library.Watch(ctx, *photos, 500*time.Millisecond, onChange); err != nil { | ||
| 80 | log.Fatal(err) | ||
| 81 | } | ||
| 82 | |||
| 83 | opt := web.Options{Title: *title, ThemePath: *theme} | ||
| 84 | if *templates != "" { | ||
| 85 | opt.Templates = os.DirFS(*templates) | ||
| 86 | } | ||
| 87 | h, err := web.New(store, rend, opt) | ||
| 88 | if err != nil { | ||
| 89 | log.Fatal(err) | ||
| 90 | } | ||
| 91 | |||
| 92 | srv := &http.Server{Addr: *addr, Handler: h, ReadHeaderTimeout: 10 * time.Second} | ||
| 93 | done := make(chan struct{}) | ||
| 94 | go func() { | ||
| 95 | <-ctx.Done() | ||
| 96 | sctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | ||
| 97 | defer cancel() | ||
| 98 | srv.Shutdown(sctx) | ||
| 99 | close(done) | ||
| 100 | }() | ||
| 101 | log.Printf("listening on %s", *addr) | ||
| 102 | if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { | ||
| 103 | log.Fatal(err) | ||
| 104 | } | ||
| 105 | <-done | ||
| 106 | } | ||
compose.yml added +12
| @@ -0,0 +1,12 @@ | |||
| 1 | services: | ||
| 2 | gallery: | ||
| 3 | build: . | ||
| 4 | container_name: gallery | ||
| 5 | restart: unless-stopped | ||
| 6 | ports: | ||
| 7 | - "127.0.0.1:8003:8080" | ||
| 8 | volumes: | ||
| 9 | - ${GALLERY_PHOTOS_DIR:-./photos}:/photos:ro | ||
| 10 | - ./cache:/cache | ||
| 11 | environment: | ||
| 12 | GALLERY_TITLE: ${GALLERY_TITLE:-gallery} | ||
go.mod added +14
| @@ -0,0 +1,14 @@ | |||
| 1 | module gitbay.org/cmc/gallery | ||
| 2 | |||
| 3 | go 1.27.1 | ||
| 4 | |||
| 5 | require ( | ||
| 6 | github.com/fsnotify/fsnotify v1.10.1 | ||
| 7 | github.com/niklasfasching/go-org v1.9.1 | ||
| 8 | golang.org/x/sync v0.23.0 | ||
| 9 | ) | ||
| 10 | |||
| 11 | require ( | ||
| 12 | golang.org/x/net v0.38.0 // indirect | ||
| 13 | golang.org/x/sys v0.31.0 // indirect | ||
| 14 | ) | ||
go.sum added +12
| @@ -0,0 +1,12 @@ | |||
| 1 | github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= | ||
| 2 | github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= | ||
| 3 | github.com/niklasfasching/go-org v1.9.1 h1:/3s4uTPOF06pImGa2Yvlp24yKXZoTYM+nsIlMzfpg/0= | ||
| 4 | github.com/niklasfasching/go-org v1.9.1/go.mod h1:ZAGFFkWvUQcpazmi/8nHqwvARpr1xpb+Es67oUGX/48= | ||
| 5 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | ||
| 6 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= | ||
| 7 | golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= | ||
| 8 | golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= | ||
| 9 | golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= | ||
| 10 | golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= | ||
| 11 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= | ||
| 12 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= | ||
internal/format/exif.go added +92
| @@ -0,0 +1,92 @@ | |||
| 1 | package format | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "encoding/json" | ||
| 5 | "errors" | ||
| 6 | "fmt" | ||
| 7 | "math" | ||
| 8 | "strconv" | ||
| 9 | "strings" | ||
| 10 | "time" | ||
| 11 | ) | ||
| 12 | |||
| 13 | type exifRecord struct { | ||
| 14 | ImageWidth int | ||
| 15 | ImageHeight int | ||
| 16 | Orientation int | ||
| 17 | DateTimeOriginal string | ||
| 18 | CreateDate string | ||
| 19 | Make string | ||
| 20 | Model string | ||
| 21 | LensModel string | ||
| 22 | FNumber float64 | ||
| 23 | ExposureTime float64 | ||
| 24 | ISO int | ||
| 25 | } | ||
| 26 | |||
| 27 | // parseExif reads `exiftool -json -n` output for a single file. | ||
| 28 | func parseExif(data []byte) (Meta, error) { | ||
| 29 | var recs []exifRecord | ||
| 30 | if err := json.Unmarshal(data, &recs); err != nil { | ||
| 31 | return Meta{}, fmt.Errorf("exiftool output: %w", err) | ||
| 32 | } | ||
| 33 | if len(recs) != 1 { | ||
| 34 | return Meta{}, fmt.Errorf("exiftool output: %d records, want 1", len(recs)) | ||
| 35 | } | ||
| 36 | e := recs[0] | ||
| 37 | if e.ImageWidth == 0 || e.ImageHeight == 0 { | ||
| 38 | return Meta{}, errors.New("exiftool output: no image dimensions") | ||
| 39 | } | ||
| 40 | m := Meta{Width: e.ImageWidth, Height: e.ImageHeight, Lens: e.LensModel, ISO: e.ISO} | ||
| 41 | if e.Orientation >= 5 && e.Orientation <= 8 { | ||
| 42 | m.Width, m.Height = m.Height, m.Width | ||
| 43 | } | ||
| 44 | m.Taken = exifTime(e.DateTimeOriginal) | ||
| 45 | if m.Taken.IsZero() { | ||
| 46 | m.Taken = exifTime(e.CreateDate) | ||
| 47 | } | ||
| 48 | m.Camera = camera(e.Make, e.Model) | ||
| 49 | if e.FNumber > 0 { | ||
| 50 | m.Aperture = "f/" + strconv.FormatFloat(e.FNumber, 'f', -1, 64) | ||
| 51 | } | ||
| 52 | m.Shutter = shutter(e.ExposureTime) | ||
| 53 | return m, nil | ||
| 54 | } | ||
| 55 | |||
| 56 | // exifTime parses "YYYY:MM:DD HH:MM:SS", ignoring any trailing subseconds or zone. | ||
| 57 | func exifTime(s string) time.Time { | ||
| 58 | if len(s) < 19 { | ||
| 59 | return time.Time{} | ||
| 60 | } | ||
| 61 | t, err := time.Parse("2006:01:02 15:04:05", s[:19]) | ||
| 62 | if err != nil { | ||
| 63 | return time.Time{} | ||
| 64 | } | ||
| 65 | return t | ||
| 66 | } | ||
| 67 | |||
| 68 | func camera(maker, model string) string { | ||
| 69 | maker, model = strings.TrimSpace(maker), strings.TrimSpace(model) | ||
| 70 | if maker == "" { | ||
| 71 | return model | ||
| 72 | } | ||
| 73 | if model == "" { | ||
| 74 | return maker | ||
| 75 | } | ||
| 76 | brand := strings.ToLower(strings.Fields(maker)[0]) | ||
| 77 | if strings.Contains(strings.ToLower(model), brand) { | ||
| 78 | return model | ||
| 79 | } | ||
| 80 | return maker + " " + model | ||
| 81 | } | ||
| 82 | |||
| 83 | func shutter(sec float64) string { | ||
| 84 | switch { | ||
| 85 | case sec <= 0: | ||
| 86 | return "" | ||
| 87 | case sec >= 1: | ||
| 88 | return strconv.FormatFloat(sec, 'f', -1, 64) + "s" | ||
| 89 | default: | ||
| 90 | return "1/" + strconv.Itoa(int(math.Round(1/sec))) | ||
| 91 | } | ||
| 92 | } | ||
internal/format/exif_test.go added +83
| @@ -0,0 +1,83 @@ | |||
| 1 | package format | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "testing" | ||
| 5 | "time" | ||
| 6 | ) | ||
| 7 | |||
| 8 | func TestParseExifFull(t *testing.T) { | ||
| 9 | data := []byte(`[{ | ||
| 10 | "SourceFile": "a.jpg", | ||
| 11 | "ImageWidth": 6240, "ImageHeight": 4160, "Orientation": 1, | ||
| 12 | "DateTimeOriginal": "2025:03:14 23:10:05", | ||
| 13 | "Make": "FUJIFILM", "Model": "X-T5", "LensModel": "XF23mmF2 R WR", | ||
| 14 | "FNumber": 2, "ExposureTime": 0.008, "ISO": 1600 | ||
| 15 | }]`) | ||
| 16 | m, err := parseExif(data) | ||
| 17 | if err != nil { | ||
| 18 | t.Fatal(err) | ||
| 19 | } | ||
| 20 | want := Meta{ | ||
| 21 | Width: 6240, Height: 4160, | ||
| 22 | Taken: time.Date(2025, 3, 14, 23, 10, 5, 0, time.UTC), | ||
| 23 | Camera: "FUJIFILM X-T5", | ||
| 24 | Lens: "XF23mmF2 R WR", | ||
| 25 | Aperture: "f/2", | ||
| 26 | Shutter: "1/125", | ||
| 27 | ISO: 1600, | ||
| 28 | } | ||
| 29 | if m != want { | ||
| 30 | t.Fatalf("got %+v\nwant %+v", m, want) | ||
| 31 | } | ||
| 32 | } | ||
| 33 | |||
| 34 | func TestParseExifRotatedSwapsDimensions(t *testing.T) { | ||
| 35 | for _, o := range []string{"5", "6", "7", "8"} { | ||
| 36 | m, err := parseExif([]byte(`[{"ImageWidth": 300, "ImageHeight": 200, "Orientation": ` + o + `}]`)) | ||
| 37 | if err != nil { | ||
| 38 | t.Fatal(err) | ||
| 39 | } | ||
| 40 | if m.Width != 200 || m.Height != 300 { | ||
| 41 | t.Errorf("orientation %s: got %dx%d, want 200x300", o, m.Width, m.Height) | ||
| 42 | } | ||
| 43 | } | ||
| 44 | } | ||
| 45 | |||
| 46 | func TestParseExifFallbacks(t *testing.T) { | ||
| 47 | m, err := parseExif([]byte(`[{ | ||
| 48 | "ImageWidth": 10, "ImageHeight": 10, | ||
| 49 | "CreateDate": "2024:01:02 03:04:05+01:00", | ||
| 50 | "Make": "Canon", "Model": "Canon EOS R6", | ||
| 51 | "FNumber": 5.6, "ExposureTime": 2 | ||
| 52 | }]`)) | ||
| 53 | if err != nil { | ||
| 54 | t.Fatal(err) | ||
| 55 | } | ||
| 56 | if want := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC); !m.Taken.Equal(want) { | ||
| 57 | t.Errorf("Taken = %v, want %v", m.Taken, want) | ||
| 58 | } | ||
| 59 | if m.Camera != "Canon EOS R6" { | ||
| 60 | t.Errorf("Camera = %q", m.Camera) | ||
| 61 | } | ||
| 62 | if m.Aperture != "f/5.6" || m.Shutter != "2s" { | ||
| 63 | t.Errorf("Aperture %q Shutter %q", m.Aperture, m.Shutter) | ||
| 64 | } | ||
| 65 | } | ||
| 66 | |||
| 67 | func TestParseExifMinimal(t *testing.T) { | ||
| 68 | m, err := parseExif([]byte(`[{"ImageWidth": 120, "ImageHeight": 80}]`)) | ||
| 69 | if err != nil { | ||
| 70 | t.Fatal(err) | ||
| 71 | } | ||
| 72 | if (m != Meta{Width: 120, Height: 80}) { | ||
| 73 | t.Fatalf("got %+v", m) | ||
| 74 | } | ||
| 75 | } | ||
| 76 | |||
| 77 | func TestParseExifErrors(t *testing.T) { | ||
| 78 | for _, in := range []string{`not json`, `[]`, `[{}, {}]`, `[{"ImageWidth": 10}]`} { | ||
| 79 | if _, err := parseExif([]byte(in)); err == nil { | ||
| 80 | t.Errorf("%s: want error", in) | ||
| 81 | } | ||
| 82 | } | ||
| 83 | } | ||
internal/format/format.go added +61
| @@ -0,0 +1,61 @@ | |||
| 1 | // Package format describes the file types the gallery can show. | ||
| 2 | package format | ||
| 3 | |||
| 4 | import ( | ||
| 5 | "io" | ||
| 6 | "path/filepath" | ||
| 7 | "strings" | ||
| 8 | "time" | ||
| 9 | ) | ||
| 10 | |||
| 11 | type Kind int | ||
| 12 | |||
| 13 | const ( | ||
| 14 | KindImage Kind = iota | ||
| 15 | ) | ||
| 16 | |||
| 17 | // Meta holds display metadata. Width and Height are after EXIF orientation. | ||
| 18 | type Meta struct { | ||
| 19 | Width, Height int | ||
| 20 | Taken time.Time | ||
| 21 | Camera, Lens string | ||
| 22 | Aperture string | ||
| 23 | Shutter string | ||
| 24 | ISO int | ||
| 25 | } | ||
| 26 | |||
| 27 | type Format interface { | ||
| 28 | Name() string | ||
| 29 | Match(path string) bool | ||
| 30 | Metadata(path string) (Meta, error) | ||
| 31 | Resize(src string, width int, dst io.Writer) error | ||
| 32 | Kind() Kind | ||
| 33 | } | ||
| 34 | |||
| 35 | // Registry resolves a path to the first registered Format that matches it. | ||
| 36 | type Registry struct { | ||
| 37 | formats []Format | ||
| 38 | } | ||
| 39 | |||
| 40 | func (r *Registry) Register(f Format) { | ||
| 41 | r.formats = append(r.formats, f) | ||
| 42 | } | ||
| 43 | |||
| 44 | func (r *Registry) Lookup(path string) Format { | ||
| 45 | for _, f := range r.formats { | ||
| 46 | if f.Match(path) { | ||
| 47 | return f | ||
| 48 | } | ||
| 49 | } | ||
| 50 | return nil | ||
| 51 | } | ||
| 52 | |||
| 53 | func hasExt(path string, exts []string) bool { | ||
| 54 | ext := strings.ToLower(filepath.Ext(path)) | ||
| 55 | for _, e := range exts { | ||
| 56 | if ext == e { | ||
| 57 | return true | ||
| 58 | } | ||
| 59 | } | ||
| 60 | return false | ||
| 61 | } | ||
internal/format/format_test.go added +44
| @@ -0,0 +1,44 @@ | |||
| 1 | package format | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "io" | ||
| 5 | "path/filepath" | ||
| 6 | "strings" | ||
| 7 | "testing" | ||
| 8 | ) | ||
| 9 | |||
| 10 | type fake struct{ name, ext string } | ||
| 11 | |||
| 12 | func (f fake) Name() string { return f.name } | ||
| 13 | func (f fake) Match(p string) bool { return strings.EqualFold(filepath.Ext(p), f.ext) } | ||
| 14 | func (fake) Metadata(string) (Meta, error) { return Meta{}, nil } | ||
| 15 | func (fake) Resize(string, int, io.Writer) error { return nil } | ||
| 16 | func (fake) Kind() Kind { return KindImage } | ||
| 17 | |||
| 18 | func TestRegistryFirstMatchWins(t *testing.T) { | ||
| 19 | var r Registry | ||
| 20 | r.Register(fake{"a", ".jpg"}) | ||
| 21 | r.Register(fake{"b", ".jpg"}) | ||
| 22 | r.Register(fake{"c", ".png"}) | ||
| 23 | |||
| 24 | if got := r.Lookup("x/IMG.JPG"); got == nil || got.Name() != "a" { | ||
| 25 | t.Fatalf("jpg: got %v, want a", got) | ||
| 26 | } | ||
| 27 | if got := r.Lookup("x.png"); got == nil || got.Name() != "c" { | ||
| 28 | t.Fatalf("png: got %v, want c", got) | ||
| 29 | } | ||
| 30 | if got := r.Lookup("notes.txt"); got != nil { | ||
| 31 | t.Fatalf("txt: got %v, want nil", got) | ||
| 32 | } | ||
| 33 | } | ||
| 34 | |||
| 35 | func TestHasExt(t *testing.T) { | ||
| 36 | exts := []string{".jpg", ".jpeg"} | ||
| 37 | for p, want := range map[string]bool{ | ||
| 38 | "a.jpg": true, "a.JPEG": true, "a.png": false, "jpg": false, "a.jpg.txt": false, | ||
| 39 | } { | ||
| 40 | if got := hasExt(p, exts); got != want { | ||
| 41 | t.Errorf("hasExt(%q) = %v, want %v", p, got, want) | ||
| 42 | } | ||
| 43 | } | ||
| 44 | } | ||
internal/format/magick.go added +68
| @@ -0,0 +1,68 @@ | |||
| 1 | package format | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "fmt" | ||
| 6 | "io" | ||
| 7 | "os/exec" | ||
| 8 | "strconv" | ||
| 9 | "strings" | ||
| 10 | ) | ||
| 11 | |||
| 12 | // Magick reads metadata with exiftool and resizes with ImageMagick. | ||
| 13 | type Magick struct { | ||
| 14 | name string | ||
| 15 | exts []string | ||
| 16 | } | ||
| 17 | |||
| 18 | func NewMagick(name string, exts ...string) *Magick { | ||
| 19 | return &Magick{name: name, exts: exts} | ||
| 20 | } | ||
| 21 | |||
| 22 | func (m *Magick) Name() string { return m.name } | ||
| 23 | func (m *Magick) Match(path string) bool { return hasExt(path, m.exts) } | ||
| 24 | func (m *Magick) Kind() Kind { return KindImage } | ||
| 25 | |||
| 26 | func (m *Magick) Metadata(path string) (Meta, error) { | ||
| 27 | out, err := exec.Command("exiftool", "-json", "-n", | ||
| 28 | "-ImageWidth", "-ImageHeight", "-Orientation", | ||
| 29 | "-DateTimeOriginal", "-CreateDate", | ||
| 30 | "-Make", "-Model", "-LensModel", | ||
| 31 | "-FNumber", "-ExposureTime", "-ISO", | ||
| 32 | path).Output() | ||
| 33 | if err != nil { | ||
| 34 | return Meta{}, fmt.Errorf("exiftool %s: %w", path, err) | ||
| 35 | } | ||
| 36 | return parseExif(out) | ||
| 37 | } | ||
| 38 | |||
| 39 | func (m *Magick) Resize(src string, width int, dst io.Writer) error { | ||
| 40 | cmd := exec.Command("magick", src, | ||
| 41 | "-auto-orient", "-resize", strconv.Itoa(width)+"x", | ||
| 42 | "-strip", "-quality", "82", "jpg:-") | ||
| 43 | var stderr bytes.Buffer | ||
| 44 | cmd.Stdout = dst | ||
| 45 | cmd.Stderr = &stderr | ||
| 46 | if err := cmd.Run(); err != nil { | ||
| 47 | return fmt.Errorf("magick %s: %w: %s", src, err, strings.TrimSpace(stderr.String())) | ||
| 48 | } | ||
| 49 | return nil | ||
| 50 | } | ||
| 51 | |||
| 52 | // Default returns the formats supported in v1. | ||
| 53 | func Default() *Registry { | ||
| 54 | r := &Registry{} | ||
| 55 | r.Register(NewMagick("jpeg", ".jpg", ".jpeg")) | ||
| 56 | r.Register(NewMagick("png", ".png")) | ||
| 57 | return r | ||
| 58 | } | ||
| 59 | |||
| 60 | // CheckTools reports the first required external tool missing from PATH. | ||
| 61 | func CheckTools() error { | ||
| 62 | for _, tool := range []string{"magick", "exiftool"} { | ||
| 63 | if _, err := exec.LookPath(tool); err != nil { | ||
| 64 | return fmt.Errorf("%s not found on PATH", tool) | ||
| 65 | } | ||
| 66 | } | ||
| 67 | return nil | ||
| 68 | } | ||
internal/format/magick_test.go added +120
| @@ -0,0 +1,120 @@ | |||
| 1 | package format | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "fmt" | ||
| 6 | "image/jpeg" | ||
| 7 | "os/exec" | ||
| 8 | "path/filepath" | ||
| 9 | "testing" | ||
| 10 | "time" | ||
| 11 | ) | ||
| 12 | |||
| 13 | func requireTools(t *testing.T) { | ||
| 14 | t.Helper() | ||
| 15 | if err := CheckTools(); err != nil { | ||
| 16 | t.Skip(err) | ||
| 17 | } | ||
| 18 | } | ||
| 19 | |||
| 20 | func makeImage(t *testing.T, name string, w, h int) string { | ||
| 21 | t.Helper() | ||
| 22 | p := filepath.Join(t.TempDir(), name) | ||
| 23 | run(t, "magick", "-size", fmt.Sprintf("%dx%d", w, h), "xc:gray", p) | ||
| 24 | return p | ||
| 25 | } | ||
| 26 | |||
| 27 | func run(t *testing.T, name string, args ...string) { | ||
| 28 | t.Helper() | ||
| 29 | if out, err := exec.Command(name, args...).CombinedOutput(); err != nil { | ||
| 30 | t.Fatalf("%s %v: %v\n%s", name, args, err, out) | ||
| 31 | } | ||
| 32 | } | ||
| 33 | |||
| 34 | func resizedSize(t *testing.T, f Format, src string, w int) (int, int) { | ||
| 35 | t.Helper() | ||
| 36 | var buf bytes.Buffer | ||
| 37 | if err := f.Resize(src, w, &buf); err != nil { | ||
| 38 | t.Fatal(err) | ||
| 39 | } | ||
| 40 | cfg, err := jpeg.DecodeConfig(&buf) | ||
| 41 | if err != nil { | ||
| 42 | t.Fatalf("output is not JPEG: %v", err) | ||
| 43 | } | ||
| 44 | return cfg.Width, cfg.Height | ||
| 45 | } | ||
| 46 | |||
| 47 | func TestMagickJPEG(t *testing.T) { | ||
| 48 | requireTools(t) | ||
| 49 | p := makeImage(t, "a.jpg", 300, 200) | ||
| 50 | run(t, "exiftool", "-overwrite_original", "-DateTimeOriginal=2025:03:14 23:10:05", "-Model=TestCam", p) | ||
| 51 | |||
| 52 | f := NewMagick("jpeg", ".jpg", ".jpeg") | ||
| 53 | m, err := f.Metadata(p) | ||
| 54 | if err != nil { | ||
| 55 | t.Fatal(err) | ||
| 56 | } | ||
| 57 | if m.Width != 300 || m.Height != 200 || m.Camera != "TestCam" { | ||
| 58 | t.Fatalf("got %+v", m) | ||
| 59 | } | ||
| 60 | if want := time.Date(2025, 3, 14, 23, 10, 5, 0, time.UTC); !m.Taken.Equal(want) { | ||
| 61 | t.Fatalf("Taken = %v", m.Taken) | ||
| 62 | } | ||
| 63 | if w, h := resizedSize(t, f, p, 150); w != 150 || h != 100 { | ||
| 64 | t.Fatalf("resized to %dx%d, want 150x100", w, h) | ||
| 65 | } | ||
| 66 | } | ||
| 67 | |||
| 68 | func TestMagickOrientation(t *testing.T) { | ||
| 69 | requireTools(t) | ||
| 70 | p := makeImage(t, "r.jpg", 300, 200) | ||
| 71 | run(t, "exiftool", "-overwrite_original", "-n", "-Orientation=6", p) | ||
| 72 | |||
| 73 | f := NewMagick("jpeg", ".jpg") | ||
| 74 | m, err := f.Metadata(p) | ||
| 75 | if err != nil { | ||
| 76 | t.Fatal(err) | ||
| 77 | } | ||
| 78 | if m.Width != 200 || m.Height != 300 { | ||
| 79 | t.Fatalf("metadata %dx%d, want 200x300", m.Width, m.Height) | ||
| 80 | } | ||
| 81 | if w, h := resizedSize(t, f, p, 100); w != 100 || h != 150 { | ||
| 82 | t.Fatalf("resized to %dx%d, want 100x150", w, h) | ||
| 83 | } | ||
| 84 | } | ||
| 85 | |||
| 86 | func TestMagickPNG(t *testing.T) { | ||
| 87 | requireTools(t) | ||
| 88 | p := makeImage(t, "a.png", 120, 80) | ||
| 89 | f := NewMagick("png", ".png") | ||
| 90 | m, err := f.Metadata(p) | ||
| 91 | if err != nil { | ||
| 92 | t.Fatal(err) | ||
| 93 | } | ||
| 94 | if m.Width != 120 || m.Height != 80 { | ||
| 95 | t.Fatalf("got %+v", m) | ||
| 96 | } | ||
| 97 | if w, h := resizedSize(t, f, p, 60); w != 60 || h != 40 { | ||
| 98 | t.Fatalf("resized to %dx%d, want 60x40", w, h) | ||
| 99 | } | ||
| 100 | } | ||
| 101 | |||
| 102 | func TestMagickResizeError(t *testing.T) { | ||
| 103 | requireTools(t) | ||
| 104 | var buf bytes.Buffer | ||
| 105 | if err := NewMagick("jpeg", ".jpg").Resize(filepath.Join(t.TempDir(), "missing.jpg"), 100, &buf); err == nil { | ||
| 106 | t.Fatal("want error") | ||
| 107 | } | ||
| 108 | } | ||
| 109 | |||
| 110 | func TestDefaultRegistry(t *testing.T) { | ||
| 111 | r := Default() | ||
| 112 | for path, want := range map[string]string{"a.JPG": "jpeg", "a.jpeg": "jpeg", "a.png": "png"} { | ||
| 113 | if f := r.Lookup(path); f == nil || f.Name() != want { | ||
| 114 | t.Errorf("%s: got %v, want %s", path, f, want) | ||
| 115 | } | ||
| 116 | } | ||
| 117 | if r.Lookup("a.heic") != nil { | ||
| 118 | t.Error("heic should not match in v1") | ||
| 119 | } | ||
| 120 | } | ||
internal/library/library.go added +117
| @@ -0,0 +1,117 @@ | |||
| 1 | // Package library turns the photo folder tree into albums and items. | ||
| 2 | package library | ||
| 3 | |||
| 4 | import ( | ||
| 5 | "html/template" | ||
| 6 | "sort" | ||
| 7 | "strconv" | ||
| 8 | "sync/atomic" | ||
| 9 | "time" | ||
| 10 | |||
| 11 | "gitbay.org/cmc/gallery/internal/format" | ||
| 12 | ) | ||
| 13 | |||
| 14 | type Library struct { | ||
| 15 | Albums []*Album | ||
| 16 | About template.HTML | ||
| 17 | Incomplete bool // set when the scan found zero albums or an album directory failed to read | ||
| 18 | bySlug map[string]*Album | ||
| 19 | } | ||
| 20 | |||
| 21 | func (l *Library) Album(slug string) *Album { | ||
| 22 | return l.bySlug[slug] | ||
| 23 | } | ||
| 24 | |||
| 25 | type Album struct { | ||
| 26 | Slug string | ||
| 27 | Title string | ||
| 28 | Date string | ||
| 29 | Description template.HTML | ||
| 30 | Order int | ||
| 31 | HasOrder bool | ||
| 32 | Cover *Item | ||
| 33 | Items []*Item | ||
| 34 | byName map[string]int | ||
| 35 | } | ||
| 36 | |||
| 37 | func (a *Album) Item(name string) (int, *Item) { | ||
| 38 | i, ok := a.byName[name] | ||
| 39 | if !ok { | ||
| 40 | return -1, nil | ||
| 41 | } | ||
| 42 | return i, a.Items[i] | ||
| 43 | } | ||
| 44 | |||
| 45 | // Year is the album's #+date year, else the cover's taken year. | ||
| 46 | func (a *Album) Year() string { | ||
| 47 | if len(a.Date) >= 4 { | ||
| 48 | return a.Date[:4] | ||
| 49 | } | ||
| 50 | if a.Cover != nil && !a.Cover.Taken.IsZero() { | ||
| 51 | return strconv.Itoa(a.Cover.Taken.Year()) | ||
| 52 | } | ||
| 53 | return "" | ||
| 54 | } | ||
| 55 | |||
| 56 | func (a *Album) sortTime() time.Time { | ||
| 57 | for _, layout := range []string{"2006-01-02", "2006-01", "2006"} { | ||
| 58 | if t, err := time.Parse(layout, a.Date); err == nil { | ||
| 59 | return t | ||
| 60 | } | ||
| 61 | } | ||
| 62 | var newest time.Time | ||
| 63 | for _, it := range a.Items { | ||
| 64 | if it.Taken.After(newest) { | ||
| 65 | newest = it.Taken | ||
| 66 | } | ||
| 67 | } | ||
| 68 | return newest | ||
| 69 | } | ||
| 70 | |||
| 71 | type Item struct { | ||
| 72 | Album string | ||
| 73 | Name string | ||
| 74 | Path string | ||
| 75 | ModTime time.Time | ||
| 76 | Size int64 | ||
| 77 | Taken time.Time // EXIF date if set, else ModTime. | ||
| 78 | Meta format.Meta | ||
| 79 | Format format.Format | ||
| 80 | } | ||
| 81 | |||
| 82 | // Store holds the current Library snapshot. | ||
| 83 | type Store struct { | ||
| 84 | p atomic.Pointer[Library] | ||
| 85 | } | ||
| 86 | |||
| 87 | func (s *Store) Load() *Library { return s.p.Load() } | ||
| 88 | func (s *Store) Set(l *Library) { s.p.Store(l) } | ||
| 89 | |||
| 90 | // sortAlbums puts albums with #+order first (ascending), then the rest newest first. | ||
| 91 | func sortAlbums(as []*Album) { | ||
| 92 | sort.SliceStable(as, func(i, j int) bool { | ||
| 93 | a, b := as[i], as[j] | ||
| 94 | if a.HasOrder != b.HasOrder { | ||
| 95 | return a.HasOrder | ||
| 96 | } | ||
| 97 | if a.HasOrder && a.Order != b.Order { | ||
| 98 | return a.Order < b.Order | ||
| 99 | } | ||
| 100 | if !a.HasOrder { | ||
| 101 | if ta, tb := a.sortTime(), b.sortTime(); !ta.Equal(tb) { | ||
| 102 | return ta.After(tb) | ||
| 103 | } | ||
| 104 | } | ||
| 105 | return a.Slug < b.Slug | ||
| 106 | }) | ||
| 107 | } | ||
| 108 | |||
| 109 | func sortItems(items []*Item, by string) { | ||
| 110 | sort.SliceStable(items, func(i, j int) bool { | ||
| 111 | a, b := items[i], items[j] | ||
| 112 | if by == "taken" && !a.Taken.Equal(b.Taken) { | ||
| 113 | return a.Taken.Before(b.Taken) | ||
| 114 | } | ||
| 115 | return a.Name < b.Name | ||
| 116 | }) | ||
| 117 | } | ||
internal/library/metacache.go added +72
| @@ -0,0 +1,72 @@ | |||
| 1 | package library | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "encoding/json" | ||
| 5 | "errors" | ||
| 6 | "io/fs" | ||
| 7 | "log" | ||
| 8 | "os" | ||
| 9 | "path/filepath" | ||
| 10 | |||
| 11 | "gitbay.org/cmc/gallery/internal/format" | ||
| 12 | ) | ||
| 13 | |||
| 14 | type metaEntry struct { | ||
| 15 | ModTime int64 `json:"mtime"` | ||
| 16 | Size int64 `json:"size"` | ||
| 17 | Meta format.Meta `json:"meta"` | ||
| 18 | } | ||
| 19 | |||
| 20 | // MetaCache persists extracted metadata keyed by album-relative path. | ||
| 21 | type MetaCache struct { | ||
| 22 | path string | ||
| 23 | entries map[string]metaEntry | ||
| 24 | } | ||
| 25 | |||
| 26 | func OpenMetaCache(path string) (*MetaCache, error) { | ||
| 27 | c := &MetaCache{path: path, entries: map[string]metaEntry{}} | ||
| 28 | data, err := os.ReadFile(path) | ||
| 29 | if errors.Is(err, fs.ErrNotExist) { | ||
| 30 | return c, nil | ||
| 31 | } | ||
| 32 | if err != nil { | ||
| 33 | return nil, err | ||
| 34 | } | ||
| 35 | if err := json.Unmarshal(data, &c.entries); err != nil { | ||
| 36 | log.Printf("meta cache %s unreadable, starting empty: %v", path, err) | ||
| 37 | c.entries = map[string]metaEntry{} | ||
| 38 | } | ||
| 39 | return c, nil | ||
| 40 | } | ||
| 41 | |||
| 42 | func (c *MetaCache) get(rel string, fi fs.FileInfo) (format.Meta, bool) { | ||
| 43 | e, ok := c.entries[rel] | ||
| 44 | if !ok || e.ModTime != fi.ModTime().UnixNano() || e.Size != fi.Size() { | ||
| 45 | return format.Meta{}, false | ||
| 46 | } | ||
| 47 | return e.Meta, true | ||
| 48 | } | ||
| 49 | |||
| 50 | func (c *MetaCache) put(rel string, fi fs.FileInfo, m format.Meta) { | ||
| 51 | c.entries[rel] = metaEntry{ModTime: fi.ModTime().UnixNano(), Size: fi.Size(), Meta: m} | ||
| 52 | } | ||
| 53 | |||
| 54 | func (c *MetaCache) save(keep map[string]bool) error { | ||
| 55 | for k := range c.entries { | ||
| 56 | if !keep[k] { | ||
| 57 | delete(c.entries, k) | ||
| 58 | } | ||
| 59 | } | ||
| 60 | data, err := json.Marshal(c.entries) | ||
| 61 | if err != nil { | ||
| 62 | return err | ||
| 63 | } | ||
| 64 | if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil { | ||
| 65 | return err | ||
| 66 | } | ||
| 67 | tmp := c.path + ".tmp" | ||
| 68 | if err := os.WriteFile(tmp, data, 0o644); err != nil { | ||
| 69 | return err | ||
| 70 | } | ||
| 71 | return os.Rename(tmp, c.path) | ||
| 72 | } | ||
internal/library/metacache_test.go added +85
| @@ -0,0 +1,85 @@ | |||
| 1 | package library | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "io/fs" | ||
| 5 | "os" | ||
| 6 | "path/filepath" | ||
| 7 | "testing" | ||
| 8 | "time" | ||
| 9 | |||
| 10 | "gitbay.org/cmc/gallery/internal/format" | ||
| 11 | ) | ||
| 12 | |||
| 13 | func statFile(t *testing.T, body string) (string, fs.FileInfo) { | ||
| 14 | t.Helper() | ||
| 15 | p := filepath.Join(t.TempDir(), "a.jpg") | ||
| 16 | if err := os.WriteFile(p, []byte(body), 0o644); err != nil { | ||
| 17 | t.Fatal(err) | ||
| 18 | } | ||
| 19 | fi, err := os.Stat(p) | ||
| 20 | if err != nil { | ||
| 21 | t.Fatal(err) | ||
| 22 | } | ||
| 23 | return p, fi | ||
| 24 | } | ||
| 25 | |||
| 26 | func TestMetaCacheRoundTrip(t *testing.T) { | ||
| 27 | path := filepath.Join(t.TempDir(), "sub", "meta.json") | ||
| 28 | c, err := OpenMetaCache(path) | ||
| 29 | if err != nil { | ||
| 30 | t.Fatal(err) | ||
| 31 | } | ||
| 32 | _, fi := statFile(t, "x") | ||
| 33 | m := format.Meta{Width: 3, Height: 2, Camera: "X-T5", Taken: time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC)} | ||
| 34 | c.put("tokyo/a.jpg", fi, m) | ||
| 35 | c.put("gone/b.jpg", fi, m) | ||
| 36 | if err := c.save(map[string]bool{"tokyo/a.jpg": true}); err != nil { | ||
| 37 | t.Fatal(err) | ||
| 38 | } | ||
| 39 | |||
| 40 | c2, err := OpenMetaCache(path) | ||
| 41 | if err != nil { | ||
| 42 | t.Fatal(err) | ||
| 43 | } | ||
| 44 | got, ok := c2.get("tokyo/a.jpg", fi) | ||
| 45 | if !ok || !got.Taken.Equal(m.Taken) || got.Camera != "X-T5" || got.Width != 3 { | ||
| 46 | t.Fatalf("get = %+v, %v", got, ok) | ||
| 47 | } | ||
| 48 | if _, ok := c2.get("gone/b.jpg", fi); ok { | ||
| 49 | t.Fatal("entry outside keep survived save") | ||
| 50 | } | ||
| 51 | } | ||
| 52 | |||
| 53 | func TestMetaCacheMissOnChange(t *testing.T) { | ||
| 54 | c, _ := OpenMetaCache(filepath.Join(t.TempDir(), "meta.json")) | ||
| 55 | p, fi := statFile(t, "x") | ||
| 56 | c.put("a.jpg", fi, format.Meta{Width: 1, Height: 1}) | ||
| 57 | |||
| 58 | if err := os.WriteFile(p, []byte("longer"), 0o644); err != nil { | ||
| 59 | t.Fatal(err) | ||
| 60 | } | ||
| 61 | fi2, _ := os.Stat(p) | ||
| 62 | if _, ok := c.get("a.jpg", fi2); ok { | ||
| 63 | t.Fatal("size change should miss") | ||
| 64 | } | ||
| 65 | |||
| 66 | later := fi.ModTime().Add(time.Hour) | ||
| 67 | os.WriteFile(p, []byte("x"), 0o644) | ||
| 68 | os.Chtimes(p, later, later) | ||
| 69 | fi3, _ := os.Stat(p) | ||
| 70 | if _, ok := c.get("a.jpg", fi3); ok { | ||
| 71 | t.Fatal("mtime change should miss") | ||
| 72 | } | ||
| 73 | } | ||
| 74 | |||
| 75 | func TestMetaCacheCorruptStartsEmpty(t *testing.T) { | ||
| 76 | path := filepath.Join(t.TempDir(), "meta.json") | ||
| 77 | os.WriteFile(path, []byte("{nope"), 0o644) | ||
| 78 | c, err := OpenMetaCache(path) | ||
| 79 | if err != nil { | ||
| 80 | t.Fatal(err) | ||
| 81 | } | ||
| 82 | if len(c.entries) != 0 { | ||
| 83 | t.Fatalf("entries = %v", c.entries) | ||
| 84 | } | ||
| 85 | } | ||
internal/library/org.go added +34
| @@ -0,0 +1,34 @@ | |||
| 1 | package library | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "html/template" | ||
| 5 | "strings" | ||
| 6 | |||
| 7 | "github.com/niklasfasching/go-org/org" | ||
| 8 | ) | ||
| 9 | |||
| 10 | // parseOrg splits "#+KEY: value" lines out as lower-cased keywords and | ||
| 11 | // renders the rest of the document to HTML. | ||
| 12 | func parseOrg(src []byte, path string) (map[string]string, template.HTML, error) { | ||
| 13 | kw := map[string]string{} | ||
| 14 | var body []string | ||
| 15 | for _, line := range strings.Split(string(src), "\n") { | ||
| 16 | t := strings.TrimSpace(line) | ||
| 17 | if rest, ok := strings.CutPrefix(t, "#+"); ok { | ||
| 18 | if k, v, ok := strings.Cut(rest, ":"); ok && k != "" && !strings.ContainsAny(k, " \t") { | ||
| 19 | kw[strings.ToLower(k)] = strings.TrimSpace(v) | ||
| 20 | continue | ||
| 21 | } | ||
| 22 | } | ||
| 23 | body = append(body, line) | ||
| 24 | } | ||
| 25 | text := strings.TrimSpace(strings.Join(body, "\n")) | ||
| 26 | if text == "" { | ||
| 27 | return kw, "", nil | ||
| 28 | } | ||
| 29 | out, err := org.New().Parse(strings.NewReader(text), path).Write(org.NewHTMLWriter()) | ||
| 30 | if err != nil { | ||
| 31 | return nil, "", err | ||
| 32 | } | ||
| 33 | return kw, template.HTML(out), nil | ||
| 34 | } | ||
internal/library/org_test.go added +32
| @@ -0,0 +1,32 @@ | |||
| 1 | package library | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "strings" | ||
| 5 | "testing" | ||
| 6 | ) | ||
| 7 | |||
| 8 | func TestParseOrg(t *testing.T) { | ||
| 9 | src := "#+title: Tokyo: nights\n#+COVER: a.jpg\n\n#+begin_quote\nA /quiet/ week.\n#+end_quote\n" | ||
| 10 | kw, html, err := parseOrg([]byte(src), "album.org") | ||
| 11 | if err != nil { | ||
| 12 | t.Fatal(err) | ||
| 13 | } | ||
| 14 | if len(kw) != 2 || kw["title"] != "Tokyo: nights" || kw["cover"] != "a.jpg" { | ||
| 15 | t.Fatalf("keywords = %v", kw) | ||
| 16 | } | ||
| 17 | for _, want := range []string{"<blockquote>", "<em>quiet</em>"} { | ||
| 18 | if !strings.Contains(string(html), want) { | ||
| 19 | t.Errorf("html missing %q:\n%s", want, html) | ||
| 20 | } | ||
| 21 | } | ||
| 22 | } | ||
| 23 | |||
| 24 | func TestParseOrgKeywordsOnly(t *testing.T) { | ||
| 25 | kw, html, err := parseOrg([]byte("#+title: x\n\n"), "album.org") | ||
| 26 | if err != nil { | ||
| 27 | t.Fatal(err) | ||
| 28 | } | ||
| 29 | if kw["title"] != "x" || html != "" { | ||
| 30 | t.Fatalf("kw=%v html=%q", kw, html) | ||
| 31 | } | ||
| 32 | } | ||
internal/library/scan.go added +150
| @@ -0,0 +1,150 @@ | |||
| 1 | package library | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "log" | ||
| 5 | "os" | ||
| 6 | "path/filepath" | ||
| 7 | "strconv" | ||
| 8 | "strings" | ||
| 9 | |||
| 10 | "gitbay.org/cmc/gallery/internal/format" | ||
| 11 | ) | ||
| 12 | |||
| 13 | // Slugs that would collide with fixed routes. | ||
| 14 | var reserved = map[string]bool{"img": true, "info": true, "_": true} | ||
| 15 | |||
| 16 | // Scan reads every album under root. Unreadable albums and files are logged and skipped. | ||
| 17 | func Scan(root string, reg *format.Registry, cache *MetaCache) (*Library, error) { | ||
| 18 | entries, err := os.ReadDir(root) | ||
| 19 | if err != nil { | ||
| 20 | return nil, err | ||
| 21 | } | ||
| 22 | lib := &Library{bySlug: map[string]*Album{}} | ||
| 23 | keep := map[string]bool{} | ||
| 24 | for _, e := range entries { | ||
| 25 | name := e.Name() | ||
| 26 | if !e.IsDir() || strings.HasPrefix(name, ".") { | ||
| 27 | continue | ||
| 28 | } | ||
| 29 | if reserved[name] { | ||
| 30 | log.Printf("skipping album %q: reserved name", name) | ||
| 31 | continue | ||
| 32 | } | ||
| 33 | a, err := scanAlbum(root, name, reg, cache, keep) | ||
| 34 | if err != nil { | ||
| 35 | log.Printf("album %s: %v", name, err) | ||
| 36 | lib.Incomplete = true | ||
| 37 | continue | ||
| 38 | } | ||
| 39 | if len(a.Items) == 0 { | ||
| 40 | continue | ||
| 41 | } | ||
| 42 | lib.Albums = append(lib.Albums, a) | ||
| 43 | lib.bySlug[name] = a | ||
| 44 | } | ||
| 45 | sortAlbums(lib.Albums) | ||
| 46 | if len(lib.Albums) == 0 { | ||
| 47 | lib.Incomplete = true | ||
| 48 | } | ||
| 49 | |||
| 50 | if src, err := os.ReadFile(filepath.Join(root, "about.org")); err == nil { | ||
| 51 | if _, html, err := parseOrg(src, "about.org"); err != nil { | ||
| 52 | log.Printf("about.org: %v", err) | ||
| 53 | } else { | ||
| 54 | lib.About = html | ||
| 55 | } | ||
| 56 | } | ||
| 57 | if lib.Incomplete { | ||
| 58 | log.Printf("scan incomplete: keeping existing meta cache") | ||
| 59 | } else if err := cache.save(keep); err != nil { | ||
| 60 | log.Printf("meta cache: %v", err) | ||
| 61 | } | ||
| 62 | return lib, nil | ||
| 63 | } | ||
| 64 | |||
| 65 | func scanAlbum(root, slug string, reg *format.Registry, cache *MetaCache, keep map[string]bool) (*Album, error) { | ||
| 66 | dir := filepath.Join(root, slug) | ||
| 67 | entries, err := os.ReadDir(dir) | ||
| 68 | if err != nil { | ||
| 69 | return nil, err | ||
| 70 | } | ||
| 71 | a := &Album{Slug: slug, Title: slug, byName: map[string]int{}} | ||
| 72 | sortBy, cover := "taken", "" | ||
| 73 | |||
| 74 | if src, err := os.ReadFile(filepath.Join(dir, "album.org")); err == nil { | ||
| 75 | kw, desc, err := parseOrg(src, filepath.Join(slug, "album.org")) | ||
| 76 | if err != nil { | ||
| 77 | log.Printf("%s/album.org: %v", slug, err) | ||
| 78 | } else { | ||
| 79 | if v := kw["title"]; v != "" { | ||
| 80 | a.Title = v | ||
| 81 | } | ||
| 82 | a.Date, a.Description, cover = kw["date"], desc, kw["cover"] | ||
| 83 | if v := kw["order"]; v != "" { | ||
| 84 | if n, err := strconv.Atoi(v); err == nil { | ||
| 85 | a.Order, a.HasOrder = n, true | ||
| 86 | } else { | ||
| 87 | log.Printf("%s/album.org: bad order %q", slug, v) | ||
| 88 | } | ||
| 89 | } | ||
| 90 | switch v := kw["sort"]; v { | ||
| 91 | case "", "taken": | ||
| 92 | case "name": | ||
| 93 | sortBy = v | ||
| 94 | default: | ||
| 95 | log.Printf("%s/album.org: bad sort %q", slug, v) | ||
| 96 | } | ||
| 97 | } | ||
| 98 | } | ||
| 99 | |||
| 100 | for _, e := range entries { | ||
| 101 | name := e.Name() | ||
| 102 | if e.IsDir() || strings.HasPrefix(name, ".") { | ||
| 103 | continue | ||
| 104 | } | ||
| 105 | path := filepath.Join(dir, name) | ||
| 106 | f := reg.Lookup(path) | ||
| 107 | if f == nil { | ||
| 108 | continue | ||
| 109 | } | ||
| 110 | fi, err := e.Info() | ||
| 111 | if err != nil { | ||
| 112 | log.Printf("%s/%s: %v", slug, name, err) | ||
| 113 | continue | ||
| 114 | } | ||
| 115 | rel := slug + "/" + name | ||
| 116 | m, ok := cache.get(rel, fi) | ||
| 117 | if !ok { | ||
| 118 | if m, err = f.Metadata(path); err != nil { | ||
| 119 | log.Printf("%s: %v", rel, err) | ||
| 120 | continue | ||
| 121 | } | ||
| 122 | cache.put(rel, fi, m) | ||
| 123 | } | ||
| 124 | keep[rel] = true | ||
| 125 | taken := m.Taken | ||
| 126 | if taken.IsZero() { | ||
| 127 | taken = fi.ModTime() | ||
| 128 | } | ||
| 129 | a.Items = append(a.Items, &Item{ | ||
| 130 | Album: slug, Name: name, Path: path, | ||
| 131 | ModTime: fi.ModTime(), Size: fi.Size(), | ||
| 132 | Taken: taken, | ||
| 133 | Meta: m, Format: f, | ||
| 134 | }) | ||
| 135 | } | ||
| 136 | |||
| 137 | sortItems(a.Items, sortBy) | ||
| 138 | for i, it := range a.Items { | ||
| 139 | a.byName[it.Name] = i | ||
| 140 | } | ||
| 141 | if i, ok := a.byName[cover]; ok { | ||
| 142 | a.Cover = a.Items[i] | ||
| 143 | } else if len(a.Items) > 0 { | ||
| 144 | if cover != "" { | ||
| 145 | log.Printf("%s/album.org: cover %q not found", slug, cover) | ||
| 146 | } | ||
| 147 | a.Cover = a.Items[0] | ||
| 148 | } | ||
| 149 | return a, nil | ||
| 150 | } | ||
internal/library/scan_test.go added +264
| @@ -0,0 +1,264 @@ | |||
| 1 | package library | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "errors" | ||
| 5 | "io" | ||
| 6 | "os" | ||
| 7 | "path/filepath" | ||
| 8 | "strings" | ||
| 9 | "testing" | ||
| 10 | "time" | ||
| 11 | |||
| 12 | "gitbay.org/cmc/gallery/internal/format" | ||
| 13 | ) | ||
| 14 | |||
| 15 | type fakeFormat struct { | ||
| 16 | meta map[string]format.Meta // by base name | ||
| 17 | fail map[string]bool | ||
| 18 | calls int | ||
| 19 | } | ||
| 20 | |||
| 21 | func (f *fakeFormat) Name() string { return "fake" } | ||
| 22 | func (f *fakeFormat) Match(p string) bool { return strings.EqualFold(filepath.Ext(p), ".jpg") } | ||
| 23 | func (f *fakeFormat) Kind() format.Kind { return format.KindImage } | ||
| 24 | func (f *fakeFormat) Resize(string, int, io.Writer) error { return nil } | ||
| 25 | func (f *fakeFormat) Metadata(p string) (format.Meta, error) { | ||
| 26 | f.calls++ | ||
| 27 | name := filepath.Base(p) | ||
| 28 | if f.fail[name] { | ||
| 29 | return format.Meta{}, errors.New("bad file") | ||
| 30 | } | ||
| 31 | if m, ok := f.meta[name]; ok { | ||
| 32 | return m, nil | ||
| 33 | } | ||
| 34 | return format.Meta{Width: 300, Height: 200}, nil | ||
| 35 | } | ||
| 36 | |||
| 37 | func day(n int) time.Time { return time.Date(2025, 3, n, 0, 0, 0, 0, time.UTC) } | ||
| 38 | |||
| 39 | func writeTree(t *testing.T, files map[string]string) string { | ||
| 40 | t.Helper() | ||
| 41 | root := t.TempDir() | ||
| 42 | for rel, body := range files { | ||
| 43 | p := filepath.Join(root, rel) | ||
| 44 | if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { | ||
| 45 | t.Fatal(err) | ||
| 46 | } | ||
| 47 | if err := os.WriteFile(p, []byte(body), 0o644); err != nil { | ||
| 48 | t.Fatal(err) | ||
| 49 | } | ||
| 50 | } | ||
| 51 | return root | ||
| 52 | } | ||
| 53 | |||
| 54 | func scanWith(t *testing.T, root string, f *fakeFormat, cache *MetaCache) *Library { | ||
| 55 | t.Helper() | ||
| 56 | if cache == nil { | ||
| 57 | var err error | ||
| 58 | if cache, err = OpenMetaCache(filepath.Join(t.TempDir(), "meta.json")); err != nil { | ||
| 59 | t.Fatal(err) | ||
| 60 | } | ||
| 61 | } | ||
| 62 | reg := &format.Registry{} | ||
| 63 | reg.Register(f) | ||
| 64 | lib, err := Scan(root, reg, cache) | ||
| 65 | if err != nil { | ||
| 66 | t.Fatal(err) | ||
| 67 | } | ||
| 68 | return lib | ||
| 69 | } | ||
| 70 | |||
| 71 | func names(items []*Item) string { | ||
| 72 | var s []string | ||
| 73 | for _, it := range items { | ||
| 74 | s = append(s, it.Name) | ||
| 75 | } | ||
| 76 | return strings.Join(s, ",") | ||
| 77 | } | ||
| 78 | |||
| 79 | func slugs(as []*Album) string { | ||
| 80 | var s []string | ||
| 81 | for _, a := range as { | ||
| 82 | s = append(s, a.Slug) | ||
| 83 | } | ||
| 84 | return strings.Join(s, ",") | ||
| 85 | } | ||
| 86 | |||
| 87 | func TestScanSkipsAndSorts(t *testing.T) { | ||
| 88 | root := writeTree(t, map[string]string{ | ||
| 89 | "tokyo/b.jpg": "x", | ||
| 90 | "tokyo/a.jpg": "x", | ||
| 91 | "tokyo/.hidden.jpg": "x", | ||
| 92 | "tokyo/notes.txt": "x", | ||
| 93 | "tokyo/sub/c.jpg": "x", | ||
| 94 | ".git/x.jpg": "x", | ||
| 95 | "img/x.jpg": "x", | ||
| 96 | "info/x.jpg": "x", | ||
| 97 | "_/x.jpg": "x", | ||
| 98 | "empty/notes.txt": "x", | ||
| 99 | "loose.jpg": "x", | ||
| 100 | }) | ||
| 101 | f := &fakeFormat{meta: map[string]format.Meta{ | ||
| 102 | "a.jpg": {Width: 300, Height: 200, Taken: day(2)}, | ||
| 103 | "b.jpg": {Width: 300, Height: 200, Taken: day(1)}, | ||
| 104 | }} | ||
| 105 | lib := scanWith(t, root, f, nil) | ||
| 106 | |||
| 107 | if got := slugs(lib.Albums); got != "tokyo" { | ||
| 108 | t.Fatalf("albums = %s", got) | ||
| 109 | } | ||
| 110 | a := lib.Album("tokyo") | ||
| 111 | if got := names(a.Items); got != "b.jpg,a.jpg" { | ||
| 112 | t.Fatalf("items = %s", got) | ||
| 113 | } | ||
| 114 | if a.Title != "tokyo" || a.Cover != a.Items[0] || a.Year() != "2025" { | ||
| 115 | t.Fatalf("title %q cover %v year %q", a.Title, a.Cover, a.Year()) | ||
| 116 | } | ||
| 117 | if i, it := a.Item("a.jpg"); i != 1 || it.Path != filepath.Join(root, "tokyo", "a.jpg") { | ||
| 118 | t.Fatalf("Item(a.jpg) = %d, %+v", i, it) | ||
| 119 | } | ||
| 120 | if i, it := a.Item("nope.jpg"); i != -1 || it != nil { | ||
| 121 | t.Fatal("Item(nope.jpg) should be absent") | ||
| 122 | } | ||
| 123 | if lib.Album("nope") != nil { | ||
| 124 | t.Fatal("Album(nope) should be nil") | ||
| 125 | } | ||
| 126 | } | ||
| 127 | |||
| 128 | func TestScanAlbumOrg(t *testing.T) { | ||
| 129 | root := writeTree(t, map[string]string{ | ||
| 130 | "tokyo/album.org": "#+title: Tokyo\n#+date: 2024-11\n#+cover: b.jpg\n#+sort: name\n\nA /quiet/ week.\n", | ||
| 131 | "tokyo/b.jpg": "x", | ||
| 132 | "tokyo/a.jpg": "x", | ||
| 133 | }) | ||
| 134 | f := &fakeFormat{meta: map[string]format.Meta{ | ||
| 135 | "a.jpg": {Width: 1, Height: 1, Taken: day(2)}, | ||
| 136 | "b.jpg": {Width: 1, Height: 1, Taken: day(1)}, | ||
| 137 | }} | ||
| 138 | a := scanWith(t, root, f, nil).Album("tokyo") | ||
| 139 | if a.Title != "Tokyo" || a.Date != "2024-11" || a.Year() != "2024" { | ||
| 140 | t.Fatalf("title %q date %q year %q", a.Title, a.Date, a.Year()) | ||
| 141 | } | ||
| 142 | if got := names(a.Items); got != "a.jpg,b.jpg" { | ||
| 143 | t.Fatalf("sort name: items = %s", got) | ||
| 144 | } | ||
| 145 | if a.Cover.Name != "b.jpg" { | ||
| 146 | t.Fatalf("cover = %s", a.Cover.Name) | ||
| 147 | } | ||
| 148 | if !strings.Contains(string(a.Description), "<em>quiet</em>") { | ||
| 149 | t.Fatalf("description = %s", a.Description) | ||
| 150 | } | ||
| 151 | } | ||
| 152 | |||
| 153 | func TestScanAlbumOrdering(t *testing.T) { | ||
| 154 | root := writeTree(t, map[string]string{ | ||
| 155 | "a/album.org": "#+order: 2\n", | ||
| 156 | "a/x.jpg": "x", | ||
| 157 | "b/album.org": "#+order: 1\n", | ||
| 158 | "b/x.jpg": "x", | ||
| 159 | "c/album.org": "#+date: 2024\n", | ||
| 160 | "c/x.jpg": "x", | ||
| 161 | "d/album.org": "#+date: 2025-06\n", | ||
| 162 | "d/x.jpg": "x", | ||
| 163 | "e/x.jpg": "x", // no album.org; newest item taken is day(1) of 2025-03 | ||
| 164 | }) | ||
| 165 | f := &fakeFormat{meta: map[string]format.Meta{"x.jpg": {Width: 1, Height: 1, Taken: day(1)}}} | ||
| 166 | if got := slugs(scanWith(t, root, f, nil).Albums); got != "b,a,d,e,c" { | ||
| 167 | t.Fatalf("albums = %s", got) | ||
| 168 | } | ||
| 169 | } | ||
| 170 | |||
| 171 | func TestScanTakenFallsBackToModTime(t *testing.T) { | ||
| 172 | root := writeTree(t, map[string]string{"tokyo/a.jpg": "x"}) | ||
| 173 | mt := time.Date(2023, 5, 6, 7, 8, 9, 0, time.UTC) | ||
| 174 | os.Chtimes(filepath.Join(root, "tokyo", "a.jpg"), mt, mt) | ||
| 175 | a := scanWith(t, root, &fakeFormat{}, nil).Album("tokyo") | ||
| 176 | if !a.Items[0].Taken.Equal(mt) { | ||
| 177 | t.Fatalf("Taken = %v, want %v", a.Items[0].Taken, mt) | ||
| 178 | } | ||
| 179 | if !a.Items[0].Meta.Taken.IsZero() { | ||
| 180 | t.Fatalf("Meta.Taken = %v, want zero", a.Items[0].Meta.Taken) | ||
| 181 | } | ||
| 182 | } | ||
| 183 | |||
| 184 | func TestScanSkipsFailedMetadata(t *testing.T) { | ||
| 185 | root := writeTree(t, map[string]string{"tokyo/a.jpg": "x", "tokyo/bad.jpg": "x"}) | ||
| 186 | a := scanWith(t, root, &fakeFormat{fail: map[string]bool{"bad.jpg": true}}, nil).Album("tokyo") | ||
| 187 | if got := names(a.Items); got != "a.jpg" { | ||
| 188 | t.Fatalf("items = %s", got) | ||
| 189 | } | ||
| 190 | } | ||
| 191 | |||
| 192 | func TestScanUsesMetaCache(t *testing.T) { | ||
| 193 | root := writeTree(t, map[string]string{"tokyo/a.jpg": "x", "tokyo/b.jpg": "x"}) | ||
| 194 | cache, _ := OpenMetaCache(filepath.Join(t.TempDir(), "meta.json")) | ||
| 195 | f := &fakeFormat{} | ||
| 196 | scanWith(t, root, f, cache) | ||
| 197 | scanWith(t, root, f, cache) | ||
| 198 | if f.calls != 2 { | ||
| 199 | t.Fatalf("calls after two scans = %d, want 2", f.calls) | ||
| 200 | } | ||
| 201 | later := time.Now().Add(time.Hour) | ||
| 202 | os.Chtimes(filepath.Join(root, "tokyo", "a.jpg"), later, later) | ||
| 203 | scanWith(t, root, f, cache) | ||
| 204 | if f.calls != 3 { | ||
| 205 | t.Fatalf("calls after touch = %d, want 3", f.calls) | ||
| 206 | } | ||
| 207 | } | ||
| 208 | |||
| 209 | func TestScanAbout(t *testing.T) { | ||
| 210 | root := writeTree(t, map[string]string{"about.org": "Hello /there/.\n", "tokyo/a.jpg": "x"}) | ||
| 211 | lib := scanWith(t, root, &fakeFormat{}, nil) | ||
| 212 | if !strings.Contains(string(lib.About), "<em>there</em>") { | ||
| 213 | t.Fatalf("About = %s", lib.About) | ||
| 214 | } | ||
| 215 | root2 := writeTree(t, map[string]string{"tokyo/a.jpg": "x"}) | ||
| 216 | if lib := scanWith(t, root2, &fakeFormat{}, nil); lib.About != "" { | ||
| 217 | t.Fatalf("About without file = %q", lib.About) | ||
| 218 | } | ||
| 219 | } | ||
| 220 | |||
| 221 | func TestScanIncomplete(t *testing.T) { | ||
| 222 | root := writeTree(t, map[string]string{"tokyo/a.jpg": "x"}) | ||
| 223 | cachePath := filepath.Join(t.TempDir(), "meta.json") | ||
| 224 | cache, err := OpenMetaCache(cachePath) | ||
| 225 | if err != nil { | ||
| 226 | t.Fatal(err) | ||
| 227 | } | ||
| 228 | f := &fakeFormat{} | ||
| 229 | |||
| 230 | lib := scanWith(t, root, f, cache) | ||
| 231 | if lib.Incomplete { | ||
| 232 | t.Fatal("normal scan should not be Incomplete") | ||
| 233 | } | ||
| 234 | if f.calls != 1 { | ||
| 235 | t.Fatalf("calls = %d, want 1", f.calls) | ||
| 236 | } | ||
| 237 | |||
| 238 | empty := t.TempDir() | ||
| 239 | lib2 := scanWith(t, empty, f, cache) | ||
| 240 | if !lib2.Incomplete { | ||
| 241 | t.Fatal("empty root scan should be Incomplete") | ||
| 242 | } | ||
| 243 | |||
| 244 | // The cache entry from the first scan must have survived the incomplete scan. | ||
| 245 | lib3 := scanWith(t, root, f, cache) | ||
| 246 | if lib3.Incomplete { | ||
| 247 | t.Fatal("rescan of the normal root should not be Incomplete") | ||
| 248 | } | ||
| 249 | if f.calls != 1 { | ||
| 250 | t.Fatalf("calls after rescan = %d, want 1 (cache entry should have survived the incomplete scan)", f.calls) | ||
| 251 | } | ||
| 252 | } | ||
| 253 | |||
| 254 | func TestStore(t *testing.T) { | ||
| 255 | var s Store | ||
| 256 | if s.Load() != nil { | ||
| 257 | t.Fatal("empty store should load nil") | ||
| 258 | } | ||
| 259 | lib := &Library{} | ||
| 260 | s.Set(lib) | ||
| 261 | if s.Load() != lib { | ||
| 262 | t.Fatal("Load after Set") | ||
| 263 | } | ||
| 264 | } | ||
internal/library/watch.go added +73
| @@ -0,0 +1,73 @@ | |||
| 1 | package library | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "log" | ||
| 6 | "os" | ||
| 7 | "path/filepath" | ||
| 8 | "strings" | ||
| 9 | "time" | ||
| 10 | |||
| 11 | "github.com/fsnotify/fsnotify" | ||
| 12 | ) | ||
| 13 | |||
| 14 | // Watch calls onChange after filesystem activity under root has been quiet for debounce. | ||
| 15 | func Watch(ctx context.Context, root string, debounce time.Duration, onChange func()) error { | ||
| 16 | w, err := fsnotify.NewWatcher() | ||
| 17 | if err != nil { | ||
| 18 | return err | ||
| 19 | } | ||
| 20 | if err := addTree(w, root); err != nil { | ||
| 21 | w.Close() | ||
| 22 | return err | ||
| 23 | } | ||
| 24 | go func() { | ||
| 25 | defer w.Close() | ||
| 26 | timer := time.NewTimer(debounce) | ||
| 27 | timer.Stop() | ||
| 28 | for { | ||
| 29 | select { | ||
| 30 | case <-ctx.Done(): | ||
| 31 | return | ||
| 32 | case ev, ok := <-w.Events: | ||
| 33 | if !ok { | ||
| 34 | return | ||
| 35 | } | ||
| 36 | if ev.Has(fsnotify.Create) { | ||
| 37 | if fi, err := os.Stat(ev.Name); err == nil && fi.IsDir() { | ||
| 38 | if err := w.Add(ev.Name); err != nil { | ||
| 39 | log.Printf("watch %s: %v", ev.Name, err) | ||
| 40 | } | ||
| 41 | } | ||
| 42 | } | ||
| 43 | timer.Reset(debounce) | ||
| 44 | case err, ok := <-w.Errors: | ||
| 45 | if !ok { | ||
| 46 | return | ||
| 47 | } | ||
| 48 | log.Printf("watch: %v", err) | ||
| 49 | case <-timer.C: | ||
| 50 | onChange() | ||
| 51 | } | ||
| 52 | } | ||
| 53 | }() | ||
| 54 | return nil | ||
| 55 | } | ||
| 56 | |||
| 57 | func addTree(w *fsnotify.Watcher, root string) error { | ||
| 58 | if err := w.Add(root); err != nil { | ||
| 59 | return err | ||
| 60 | } | ||
| 61 | entries, err := os.ReadDir(root) | ||
| 62 | if err != nil { | ||
| 63 | return err | ||
| 64 | } | ||
| 65 | for _, e := range entries { | ||
| 66 | if e.IsDir() && !strings.HasPrefix(e.Name(), ".") { | ||
| 67 | if err := w.Add(filepath.Join(root, e.Name())); err != nil { | ||
| 68 | return err | ||
| 69 | } | ||
| 70 | } | ||
| 71 | } | ||
| 72 | return nil | ||
| 73 | } | ||
internal/library/watch_test.go added +45
| @@ -0,0 +1,45 @@ | |||
| 1 | package library | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "os" | ||
| 6 | "path/filepath" | ||
| 7 | "testing" | ||
| 8 | "time" | ||
| 9 | ) | ||
| 10 | |||
| 11 | func expectCall(t *testing.T, ch <-chan struct{}, what string) { | ||
| 12 | t.Helper() | ||
| 13 | select { | ||
| 14 | case <-ch: | ||
| 15 | case <-time.After(3 * time.Second): | ||
| 16 | t.Fatalf("no onChange after %s", what) | ||
| 17 | } | ||
| 18 | } | ||
| 19 | |||
| 20 | func TestWatchDebouncesAndFollowsNewDirs(t *testing.T) { | ||
| 21 | root := writeTree(t, map[string]string{"tokyo/a.jpg": "x"}) | ||
| 22 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 23 | defer cancel() | ||
| 24 | |||
| 25 | calls := make(chan struct{}, 10) | ||
| 26 | if err := Watch(ctx, root, 100*time.Millisecond, func() { calls <- struct{}{} }); err != nil { | ||
| 27 | t.Fatal(err) | ||
| 28 | } | ||
| 29 | |||
| 30 | for _, n := range []string{"b.jpg", "c.jpg", "d.jpg"} { | ||
| 31 | os.WriteFile(filepath.Join(root, "tokyo", n), []byte("x"), 0o644) | ||
| 32 | } | ||
| 33 | expectCall(t, calls, "writes in existing album") | ||
| 34 | select { | ||
| 35 | case <-calls: | ||
| 36 | t.Fatal("burst of writes produced more than one call") | ||
| 37 | case <-time.After(300 * time.Millisecond): | ||
| 38 | } | ||
| 39 | |||
| 40 | os.Mkdir(filepath.Join(root, "iceland"), 0o755) | ||
| 41 | expectCall(t, calls, "new album dir") | ||
| 42 | time.Sleep(150 * time.Millisecond) // let the watcher add the new dir | ||
| 43 | os.WriteFile(filepath.Join(root, "iceland", "a.jpg"), []byte("x"), 0o644) | ||
| 44 | expectCall(t, calls, "write in new album") | ||
| 45 | } | ||
internal/render/render.go added +111
| @@ -0,0 +1,111 @@ | |||
| 1 | // Package render produces and caches resized copies of library items. | ||
| 2 | package render | ||
| 3 | |||
| 4 | import ( | ||
| 5 | "fmt" | ||
| 6 | "log" | ||
| 7 | "os" | ||
| 8 | "path/filepath" | ||
| 9 | "slices" | ||
| 10 | "strconv" | ||
| 11 | |||
| 12 | "golang.org/x/sync/singleflight" | ||
| 13 | |||
| 14 | "gitbay.org/cmc/gallery/internal/library" | ||
| 15 | ) | ||
| 16 | |||
| 17 | var Widths = []int{480, 960, 1600, 2400} | ||
| 18 | |||
| 19 | func Allowed(w int) bool { return slices.Contains(Widths, w) } | ||
| 20 | |||
| 21 | type Renderer struct { | ||
| 22 | root string | ||
| 23 | sem chan struct{} | ||
| 24 | group singleflight.Group | ||
| 25 | } | ||
| 26 | |||
| 27 | func New(cacheDir string, concurrency int) *Renderer { | ||
| 28 | return &Renderer{ | ||
| 29 | root: filepath.Join(cacheDir, "img"), | ||
| 30 | sem: make(chan struct{}, concurrency), | ||
| 31 | } | ||
| 32 | } | ||
| 33 | |||
| 34 | func (r *Renderer) dir(it *library.Item) string { | ||
| 35 | return filepath.Join(r.root, it.Album, it.Name, fmt.Sprintf("%d-%d", it.ModTime.UnixNano(), it.Size)) | ||
| 36 | } | ||
| 37 | |||
| 38 | func (r *Renderer) Path(it *library.Item, w int) string { | ||
| 39 | return filepath.Join(r.dir(it), strconv.Itoa(w)+".jpg") | ||
| 40 | } | ||
| 41 | |||
| 42 | // Get returns the cached derivative for it at width w, generating it if needed. | ||
| 43 | func (r *Renderer) Get(it *library.Item, w int) (string, error) { | ||
| 44 | if !Allowed(w) { | ||
| 45 | return "", fmt.Errorf("width %d not allowed", w) | ||
| 46 | } | ||
| 47 | p := r.Path(it, w) | ||
| 48 | if _, err := os.Stat(p); err == nil { | ||
| 49 | return p, nil | ||
| 50 | } | ||
| 51 | _, err, _ := r.group.Do(p, func() (any, error) { | ||
| 52 | if _, err := os.Stat(p); err == nil { | ||
| 53 | return nil, nil | ||
| 54 | } | ||
| 55 | r.sem <- struct{}{} | ||
| 56 | defer func() { <-r.sem }() | ||
| 57 | return nil, r.generate(it, w, p) | ||
| 58 | }) | ||
| 59 | if err != nil { | ||
| 60 | return "", err | ||
| 61 | } | ||
| 62 | return p, nil | ||
| 63 | } | ||
| 64 | |||
| 65 | func (r *Renderer) generate(it *library.Item, w int, dst string) error { | ||
| 66 | if it.Meta.Width > 0 && w > it.Meta.Width { | ||
| 67 | w = it.Meta.Width | ||
| 68 | } | ||
| 69 | if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { | ||
| 70 | return err | ||
| 71 | } | ||
| 72 | tmp, err := os.CreateTemp(filepath.Dir(dst), ".tmp-*") | ||
| 73 | if err != nil { | ||
| 74 | return err | ||
| 75 | } | ||
| 76 | defer os.Remove(tmp.Name()) | ||
| 77 | if err := it.Format.Resize(it.Path, w, tmp); err != nil { | ||
| 78 | tmp.Close() | ||
| 79 | return err | ||
| 80 | } | ||
| 81 | if err := tmp.Close(); err != nil { | ||
| 82 | return err | ||
| 83 | } | ||
| 84 | return os.Rename(tmp.Name(), dst) | ||
| 85 | } | ||
| 86 | |||
| 87 | // Prune removes derivative directories for items no longer in lib or whose source changed. | ||
| 88 | func (r *Renderer) Prune(lib *library.Library) error { | ||
| 89 | keep := map[string]bool{} | ||
| 90 | for _, a := range lib.Albums { | ||
| 91 | for _, it := range a.Items { | ||
| 92 | keep[r.dir(it)] = true | ||
| 93 | } | ||
| 94 | } | ||
| 95 | versions, err := filepath.Glob(filepath.Join(r.root, "*", "*", "*")) | ||
| 96 | if err != nil { | ||
| 97 | return err | ||
| 98 | } | ||
| 99 | var firstErr error | ||
| 100 | for _, v := range versions { | ||
| 101 | if !keep[v] { | ||
| 102 | if err := os.RemoveAll(v); err != nil { | ||
| 103 | log.Printf("prune %s: %v", v, err) | ||
| 104 | if firstErr == nil { | ||
| 105 | firstErr = err | ||
| 106 | } | ||
| 107 | } | ||
| 108 | } | ||
| 109 | } | ||
| 110 | return firstErr | ||
| 111 | } | ||
internal/render/render_test.go added +166
| @@ -0,0 +1,166 @@ | |||
| 1 | package render | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "errors" | ||
| 5 | "io" | ||
| 6 | "os" | ||
| 7 | "path/filepath" | ||
| 8 | "sync" | ||
| 9 | "sync/atomic" | ||
| 10 | "testing" | ||
| 11 | "time" | ||
| 12 | |||
| 13 | "gitbay.org/cmc/gallery/internal/format" | ||
| 14 | "gitbay.org/cmc/gallery/internal/library" | ||
| 15 | ) | ||
| 16 | |||
| 17 | type fakeFormat struct { | ||
| 18 | calls atomic.Int32 | ||
| 19 | width atomic.Int32 | ||
| 20 | fail bool | ||
| 21 | } | ||
| 22 | |||
| 23 | func (f *fakeFormat) Name() string { return "fake" } | ||
| 24 | func (f *fakeFormat) Match(string) bool { return true } | ||
| 25 | func (f *fakeFormat) Kind() format.Kind { return format.KindImage } | ||
| 26 | func (f *fakeFormat) Metadata(string) (format.Meta, error) { return format.Meta{}, nil } | ||
| 27 | func (f *fakeFormat) Resize(_ string, w int, dst io.Writer) error { | ||
| 28 | f.calls.Add(1) | ||
| 29 | f.width.Store(int32(w)) | ||
| 30 | time.Sleep(20 * time.Millisecond) | ||
| 31 | io.WriteString(dst, "partial") | ||
| 32 | if f.fail { | ||
| 33 | return errors.New("magick failed") | ||
| 34 | } | ||
| 35 | io.WriteString(dst, "-done") | ||
| 36 | return nil | ||
| 37 | } | ||
| 38 | |||
| 39 | func item(f format.Format) *library.Item { | ||
| 40 | return &library.Item{ | ||
| 41 | Album: "tokyo", Name: "a.jpg", Path: "/src/a.jpg", | ||
| 42 | ModTime: time.Unix(100, 0), Size: 42, | ||
| 43 | Meta: format.Meta{Width: 3000, Height: 2000}, | ||
| 44 | Format: f, | ||
| 45 | } | ||
| 46 | } | ||
| 47 | |||
| 48 | func TestAllowed(t *testing.T) { | ||
| 49 | for _, w := range Widths { | ||
| 50 | if !Allowed(w) { | ||
| 51 | t.Errorf("%d should be allowed", w) | ||
| 52 | } | ||
| 53 | } | ||
| 54 | for _, w := range []int{0, 1, 961, 4000} { | ||
| 55 | if Allowed(w) { | ||
| 56 | t.Errorf("%d should not be allowed", w) | ||
| 57 | } | ||
| 58 | } | ||
| 59 | } | ||
| 60 | |||
| 61 | func TestGetGeneratesOnce(t *testing.T) { | ||
| 62 | f := &fakeFormat{} | ||
| 63 | r := New(t.TempDir(), 4) | ||
| 64 | it := item(f) | ||
| 65 | |||
| 66 | var wg sync.WaitGroup | ||
| 67 | for range 10 { | ||
| 68 | wg.Add(1) | ||
| 69 | go func() { | ||
| 70 | defer wg.Done() | ||
| 71 | if _, err := r.Get(it, 960); err != nil { | ||
| 72 | t.Error(err) | ||
| 73 | } | ||
| 74 | }() | ||
| 75 | } | ||
| 76 | wg.Wait() | ||
| 77 | p, err := r.Get(it, 960) | ||
| 78 | if err != nil { | ||
| 79 | t.Fatal(err) | ||
| 80 | } | ||
| 81 | if n := f.calls.Load(); n != 1 { | ||
| 82 | t.Fatalf("Resize calls = %d, want 1", n) | ||
| 83 | } | ||
| 84 | if b, _ := os.ReadFile(p); string(b) != "partial-done" { | ||
| 85 | t.Fatalf("content = %q", b) | ||
| 86 | } | ||
| 87 | if p != r.Path(it, 960) { | ||
| 88 | t.Fatalf("Get path %s != Path %s", p, r.Path(it, 960)) | ||
| 89 | } | ||
| 90 | } | ||
| 91 | |||
| 92 | func TestGetRejectsWidth(t *testing.T) { | ||
| 93 | if _, err := New(t.TempDir(), 1).Get(item(&fakeFormat{}), 961); err == nil { | ||
| 94 | t.Fatal("want error") | ||
| 95 | } | ||
| 96 | } | ||
| 97 | |||
| 98 | func TestGetClampsToSource(t *testing.T) { | ||
| 99 | f := &fakeFormat{} | ||
| 100 | it := item(f) | ||
| 101 | it.Meta.Width = 700 | ||
| 102 | if _, err := New(t.TempDir(), 1).Get(it, 960); err != nil { | ||
| 103 | t.Fatal(err) | ||
| 104 | } | ||
| 105 | if w := f.width.Load(); w != 700 { | ||
| 106 | t.Fatalf("resized to %d, want 700", w) | ||
| 107 | } | ||
| 108 | } | ||
| 109 | |||
| 110 | func TestGetFailureLeavesNothing(t *testing.T) { | ||
| 111 | f := &fakeFormat{fail: true} | ||
| 112 | r := New(t.TempDir(), 1) | ||
| 113 | it := item(f) | ||
| 114 | if _, err := r.Get(it, 480); err == nil { | ||
| 115 | t.Fatal("want error") | ||
| 116 | } | ||
| 117 | entries, _ := os.ReadDir(filepath.Dir(r.Path(it, 480))) | ||
| 118 | if len(entries) != 0 { | ||
| 119 | t.Fatalf("left behind: %v", entries) | ||
| 120 | } | ||
| 121 | r.Get(it, 480) | ||
| 122 | if n := f.calls.Load(); n != 2 { | ||
| 123 | t.Fatalf("failure was cached: calls = %d", n) | ||
| 124 | } | ||
| 125 | } | ||
| 126 | |||
| 127 | func TestPathChangesWithSource(t *testing.T) { | ||
| 128 | r := New("/cache", 1) | ||
| 129 | a := item(nil) | ||
| 130 | b := item(nil) | ||
| 131 | b.ModTime = time.Unix(200, 0) | ||
| 132 | if r.Path(a, 480) == r.Path(b, 480) { | ||
| 133 | t.Fatal("path must change with mtime") | ||
| 134 | } | ||
| 135 | want := filepath.Join("/cache", "img", "tokyo", "a.jpg", "100000000000-42", "480.jpg") | ||
| 136 | if got := r.Path(a, 480); got != want { | ||
| 137 | t.Fatalf("Path = %s, want %s", got, want) | ||
| 138 | } | ||
| 139 | } | ||
| 140 | |||
| 141 | func TestPrune(t *testing.T) { | ||
| 142 | f := &fakeFormat{} | ||
| 143 | r := New(t.TempDir(), 1) | ||
| 144 | cur := item(f) | ||
| 145 | old := item(f) | ||
| 146 | old.ModTime = time.Unix(50, 0) | ||
| 147 | gone := item(f) | ||
| 148 | gone.Name = "gone.jpg" | ||
| 149 | for _, it := range []*library.Item{cur, old, gone} { | ||
| 150 | if _, err := r.Get(it, 480); err != nil { | ||
| 151 | t.Fatal(err) | ||
| 152 | } | ||
| 153 | } | ||
| 154 | lib := &library.Library{Albums: []*library.Album{{Slug: "tokyo", Items: []*library.Item{cur}}}} | ||
| 155 | if err := r.Prune(lib); err != nil { | ||
| 156 | t.Fatal(err) | ||
| 157 | } | ||
| 158 | if _, err := os.Stat(r.Path(cur, 480)); err != nil { | ||
| 159 | t.Fatalf("current derivative removed: %v", err) | ||
| 160 | } | ||
| 161 | for _, it := range []*library.Item{old, gone} { | ||
| 162 | if _, err := os.Stat(filepath.Dir(r.Path(it, 480))); !os.IsNotExist(err) { | ||
| 163 | t.Fatalf("stale dir kept: %s", filepath.Dir(r.Path(it, 480))) | ||
| 164 | } | ||
| 165 | } | ||
| 166 | } | ||
internal/web/assets/gallery.js added +47
| @@ -0,0 +1,47 @@ | |||
| 1 | (() => { | ||
| 2 | // Justified rows: fill each row to the container width at roughly --row-height. | ||
| 3 | const layout = (el) => { | ||
| 4 | const cs = getComputedStyle(el); | ||
| 5 | const gap = parseFloat(cs.columnGap) || 0; | ||
| 6 | const target = parseFloat(cs.getPropertyValue('--row-height')) || 320; | ||
| 7 | const width = el.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight); | ||
| 8 | let row = [], sum = 0; | ||
| 9 | const flush = (last) => { | ||
| 10 | let h = (width - gap * (row.length - 1)) / sum; | ||
| 11 | if (last && h > target) h = target; | ||
| 12 | for (const t of row) { | ||
| 13 | t.style.width = Math.floor(h * t.dataset.ar) + 'px'; | ||
| 14 | t.style.height = Math.floor(h) + 'px'; | ||
| 15 | } | ||
| 16 | row = []; | ||
| 17 | sum = 0; | ||
| 18 | }; | ||
| 19 | for (const t of el.children) { | ||
| 20 | row.push(t); | ||
| 21 | sum += +t.dataset.ar; | ||
| 22 | if ((width - gap * (row.length - 1)) / sum <= target) flush(false); | ||
| 23 | } | ||
| 24 | if (row.length) flush(true); | ||
| 25 | }; | ||
| 26 | const layoutAll = () => document.querySelectorAll('.rows').forEach(layout); | ||
| 27 | let timer; | ||
| 28 | addEventListener('resize', () => { clearTimeout(timer); timer = setTimeout(layoutAll, 100); }); | ||
| 29 | layoutAll(); | ||
| 30 | |||
| 31 | const follow = (sel) => { const a = document.querySelector(sel); if (a) location.href = a.href; }; | ||
| 32 | addEventListener('keydown', (e) => { | ||
| 33 | if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return; | ||
| 34 | if (e.key === 'ArrowLeft') follow('a[rel=prev]'); | ||
| 35 | else if (e.key === 'ArrowRight') follow('a[rel=next]'); | ||
| 36 | else if (e.key === 'Escape') follow('#up'); | ||
| 37 | }); | ||
| 38 | |||
| 39 | let x0 = null; | ||
| 40 | addEventListener('touchstart', (e) => { x0 = e.touches[0].clientX; }, { passive: true }); | ||
| 41 | addEventListener('touchend', (e) => { | ||
| 42 | if (x0 === null) return; | ||
| 43 | const dx = e.changedTouches[0].clientX - x0; | ||
| 44 | x0 = null; | ||
| 45 | if (Math.abs(dx) > 50) follow(dx > 0 ? 'a[rel=prev]' : 'a[rel=next]'); | ||
| 46 | }, { passive: true }); | ||
| 47 | })(); | ||
internal/web/assets/theme.css added +47
| @@ -0,0 +1,47 @@ | |||
| 1 | :root { | ||
| 2 | --bg: #0a0a0a; | ||
| 3 | --fg: #dddddd; | ||
| 4 | --muted: #777777; | ||
| 5 | --accent: #ffffff; | ||
| 6 | --gap: 4px; | ||
| 7 | --row-height: 320px; | ||
| 8 | --font: "Helvetica Neue", Arial, sans-serif; | ||
| 9 | --tracking: 0.25em; | ||
| 10 | } | ||
| 11 | html { box-sizing: border-box; } *, *::before, *::after { box-sizing: inherit; } | ||
| 12 | html, body { margin: 0; background: var(--bg); color: var(--fg); font: 13px/1.5 var(--font); } | ||
| 13 | a { color: inherit; text-decoration: none; } | ||
| 14 | |||
| 15 | 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); } | ||
| 16 | nav.site .brand { color: var(--accent); font-weight: 600; margin-right: auto; } | ||
| 17 | nav.site a:hover, nav.site a.on { color: var(--accent); } | ||
| 18 | |||
| 19 | .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); } | ||
| 20 | .title h1 { margin: 0; font: inherit; color: var(--fg); } | ||
| 21 | .desc { padding: 0 20px 16px; max-width: 60ch; color: var(--muted); } | ||
| 22 | |||
| 23 | .rows { display: flex; flex-wrap: wrap; gap: var(--gap); padding: 0 var(--gap) var(--gap); } | ||
| 24 | .tile { display: block; position: relative; height: var(--row-height); width: calc(var(--row-height) * var(--ar)); max-width: 100%; } | ||
| 25 | .tile img { display: block; width: 100%; height: 100%; object-fit: contain; } | ||
| 26 | .captioned { row-gap: 36px; padding-top: 4px; padding-bottom: 40px; } | ||
| 27 | .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; } | ||
| 28 | |||
| 29 | body.photo { display: flex; flex-direction: column; min-height: 100vh; } | ||
| 30 | body.photo main { flex: 1; display: flex; flex-direction: column; } | ||
| 31 | .stage { flex: 1; margin: 0; display: flex; align-items: center; justify-content: center; position: relative; padding: 0 48px; } | ||
| 32 | .stage img { max-width: 100%; max-height: calc(100vh - 120px); width: auto; height: auto; } | ||
| 33 | .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); } | ||
| 34 | .stage .prev { left: 0; } | ||
| 35 | .stage .next { right: 0; } | ||
| 36 | .stage .prev:hover, .stage .next:hover { color: var(--accent); } | ||
| 37 | .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); } | ||
| 38 | .meta span:first-child { color: var(--fg); } | ||
| 39 | |||
| 40 | .info { padding: 0 20px 40px; max-width: 60ch; } | ||
| 41 | .error { padding: 40px 20px; font-size: 10px; letter-spacing: var(--tracking); text-transform: uppercase; color: var(--muted); } | ||
| 42 | |||
| 43 | @media (max-width: 600px) { | ||
| 44 | :root { --row-height: 200px; } | ||
| 45 | .stage { padding: 0; } | ||
| 46 | .stage .prev, .stage .next { display: none; } | ||
| 47 | } | ||
internal/web/funcs.go added +75
| @@ -0,0 +1,75 @@ | |||
| 1 | package web | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | "html/template" | ||
| 6 | "net/url" | ||
| 7 | "strconv" | ||
| 8 | "strings" | ||
| 9 | |||
| 10 | "gitbay.org/cmc/gallery/internal/library" | ||
| 11 | "gitbay.org/cmc/gallery/internal/render" | ||
| 12 | ) | ||
| 13 | |||
| 14 | var funcs = template.FuncMap{ | ||
| 15 | "albumURL": albumURL, | ||
| 16 | "photoURL": photoURL, | ||
| 17 | "imgURL": imgURL, | ||
| 18 | "srcset": srcset, | ||
| 19 | "ar": aspect, | ||
| 20 | "exif": exifLine, | ||
| 21 | "add": func(a, b int) int { return a + b }, | ||
| 22 | } | ||
| 23 | |||
| 24 | func albumURL(a *library.Album) string { | ||
| 25 | return "/" + url.PathEscape(a.Slug) + "/" | ||
| 26 | } | ||
| 27 | |||
| 28 | func photoURL(it *library.Item) string { | ||
| 29 | return "/" + url.PathEscape(it.Album) + "/" + url.PathEscape(it.Name) + "/" | ||
| 30 | } | ||
| 31 | |||
| 32 | func imgURL(it *library.Item, w int) string { | ||
| 33 | return fmt.Sprintf("/img/%s/%s/%d.jpg?v=%d", url.PathEscape(it.Album), url.PathEscape(it.Name), w, it.ModTime.UnixNano()) | ||
| 34 | } | ||
| 35 | |||
| 36 | // srcset lists allowed widths up to the first one that reaches the source width. | ||
| 37 | func srcset(it *library.Item) string { | ||
| 38 | var parts []string | ||
| 39 | for _, w := range render.Widths { | ||
| 40 | actual := w | ||
| 41 | reached := it.Meta.Width > 0 && w >= it.Meta.Width | ||
| 42 | if reached { | ||
| 43 | actual = it.Meta.Width | ||
| 44 | } | ||
| 45 | parts = append(parts, fmt.Sprintf("%s %dw", imgURL(it, w), actual)) | ||
| 46 | if reached { | ||
| 47 | break | ||
| 48 | } | ||
| 49 | } | ||
| 50 | return strings.Join(parts, ", ") | ||
| 51 | } | ||
| 52 | |||
| 53 | func aspect(it *library.Item) string { | ||
| 54 | if it.Meta.Height == 0 { | ||
| 55 | return "1" | ||
| 56 | } | ||
| 57 | return strconv.FormatFloat(float64(it.Meta.Width)/float64(it.Meta.Height), 'f', 4, 64) | ||
| 58 | } | ||
| 59 | |||
| 60 | func exifLine(it *library.Item) string { | ||
| 61 | m := it.Meta | ||
| 62 | var parts []string | ||
| 63 | for _, s := range []string{m.Camera, m.Lens, m.Aperture, m.Shutter} { | ||
| 64 | if s != "" { | ||
| 65 | parts = append(parts, s) | ||
| 66 | } | ||
| 67 | } | ||
| 68 | if m.ISO > 0 { | ||
| 69 | parts = append(parts, "ISO "+strconv.Itoa(m.ISO)) | ||
| 70 | } | ||
| 71 | if !m.Taken.IsZero() { | ||
| 72 | parts = append(parts, m.Taken.Format("2006-01-02")) | ||
| 73 | } | ||
| 74 | return strings.Join(parts, " · ") | ||
| 75 | } | ||
internal/web/funcs_test.go added +35
| @@ -0,0 +1,35 @@ | |||
| 1 | package web | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "strings" | ||
| 5 | "testing" | ||
| 6 | "time" | ||
| 7 | |||
| 8 | "gitbay.org/cmc/gallery/internal/format" | ||
| 9 | "gitbay.org/cmc/gallery/internal/library" | ||
| 10 | ) | ||
| 11 | |||
| 12 | func TestExifLineDate(t *testing.T) { | ||
| 13 | it := &library.Item{Meta: format.Meta{Camera: "X-T5", Taken: time.Date(2025, 3, 14, 23, 10, 5, 0, time.UTC)}} | ||
| 14 | if got, want := exifLine(it), "X-T5 · 2025-03-14"; got != want { | ||
| 15 | t.Fatalf("exifLine = %q, want %q", got, want) | ||
| 16 | } | ||
| 17 | } | ||
| 18 | |||
| 19 | func TestExifLineNoDate(t *testing.T) { | ||
| 20 | it := &library.Item{Meta: format.Meta{Camera: "X-T5"}} | ||
| 21 | if got, want := exifLine(it), "X-T5"; got != want { | ||
| 22 | t.Fatalf("exifLine = %q, want %q", got, want) | ||
| 23 | } | ||
| 24 | } | ||
| 25 | |||
| 26 | func TestSrcsetStopsAtExactSourceWidth(t *testing.T) { | ||
| 27 | it := &library.Item{Album: "tokyo", Name: "a.jpg", Meta: format.Meta{Width: 960}} | ||
| 28 | got := srcset(it) | ||
| 29 | if n := strings.Count(got, "960w"); n != 1 { | ||
| 30 | t.Fatalf("srcset = %q, want exactly one 960w entry, got %d", got, n) | ||
| 31 | } | ||
| 32 | if n := strings.Count(got, ", "); n != 1 { | ||
| 33 | t.Fatalf("srcset = %q, want exactly two entries", got) | ||
| 34 | } | ||
| 35 | } | ||
internal/web/server.go added +199
| @@ -0,0 +1,199 @@ | |||
| 1 | // Package web serves gallery pages and images. | ||
| 2 | package web | ||
| 3 | |||
| 4 | import ( | ||
| 5 | "bytes" | ||
| 6 | "embed" | ||
| 7 | "fmt" | ||
| 8 | "html/template" | ||
| 9 | "io/fs" | ||
| 10 | "log" | ||
| 11 | "net/http" | ||
| 12 | "os" | ||
| 13 | "strconv" | ||
| 14 | "strings" | ||
| 15 | |||
| 16 | "gitbay.org/cmc/gallery/internal/library" | ||
| 17 | "gitbay.org/cmc/gallery/internal/render" | ||
| 18 | ) | ||
| 19 | |||
| 20 | //go:embed templates/*.html | ||
| 21 | var embeddedTemplates embed.FS | ||
| 22 | |||
| 23 | //go:embed assets/theme.css | ||
| 24 | var embeddedTheme []byte | ||
| 25 | |||
| 26 | //go:embed assets/gallery.js | ||
| 27 | var embeddedScript []byte | ||
| 28 | |||
| 29 | type Options struct { | ||
| 30 | Title string | ||
| 31 | ThemePath string // replaces the embedded theme.css | ||
| 32 | Templates fs.FS // replaces the embedded templates; same file names | ||
| 33 | } | ||
| 34 | |||
| 35 | var pageNames = []string{"home", "album", "photo", "info", "error"} | ||
| 36 | |||
| 37 | type server struct { | ||
| 38 | store *library.Store | ||
| 39 | rend *render.Renderer | ||
| 40 | title string | ||
| 41 | theme []byte | ||
| 42 | pages map[string]*template.Template | ||
| 43 | } | ||
| 44 | |||
| 45 | type page struct { | ||
| 46 | Title string | ||
| 47 | Lib *library.Library | ||
| 48 | Current string | ||
| 49 | Album *library.Album | ||
| 50 | Item *library.Item | ||
| 51 | Index int | ||
| 52 | Prev *library.Item | ||
| 53 | Next *library.Item | ||
| 54 | Code int | ||
| 55 | Message string | ||
| 56 | } | ||
| 57 | |||
| 58 | func New(store *library.Store, rend *render.Renderer, opt Options) (http.Handler, error) { | ||
| 59 | s := &server{store: store, rend: rend, title: opt.Title, theme: embeddedTheme, pages: map[string]*template.Template{}} | ||
| 60 | if opt.ThemePath != "" { | ||
| 61 | b, err := os.ReadFile(opt.ThemePath) | ||
| 62 | if err != nil { | ||
| 63 | return nil, err | ||
| 64 | } | ||
| 65 | s.theme = b | ||
| 66 | } | ||
| 67 | tfs := opt.Templates | ||
| 68 | if tfs == nil { | ||
| 69 | sub, err := fs.Sub(embeddedTemplates, "templates") | ||
| 70 | if err != nil { | ||
| 71 | return nil, err | ||
| 72 | } | ||
| 73 | tfs = sub | ||
| 74 | } | ||
| 75 | for _, name := range pageNames { | ||
| 76 | t, err := template.New("base.html").Funcs(funcs).ParseFS(tfs, "base.html", name+".html") | ||
| 77 | if err != nil { | ||
| 78 | return nil, fmt.Errorf("template %s: %w", name, err) | ||
| 79 | } | ||
| 80 | s.pages[name] = t | ||
| 81 | } | ||
| 82 | |||
| 83 | mux := http.NewServeMux() | ||
| 84 | mux.HandleFunc("GET /{$}", s.home) | ||
| 85 | mux.HandleFunc("GET /info/{$}", s.info) | ||
| 86 | mux.HandleFunc("GET /_/theme.css", s.themeCSS) | ||
| 87 | mux.HandleFunc("GET /_/gallery.js", s.script) | ||
| 88 | mux.HandleFunc("GET /img/{album}/{file}/{width}", s.image) | ||
| 89 | mux.HandleFunc("GET /{album}/{$}", s.album) | ||
| 90 | mux.HandleFunc("GET /{album}/{file}/{$}", s.photo) | ||
| 91 | mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { s.error(w, http.StatusNotFound) }) | ||
| 92 | return mux, nil | ||
| 93 | } | ||
| 94 | |||
| 95 | func (s *server) render(w http.ResponseWriter, name string, status int, p page) { | ||
| 96 | p.Title = s.title | ||
| 97 | if p.Lib == nil { | ||
| 98 | p.Lib = s.store.Load() | ||
| 99 | } | ||
| 100 | var buf bytes.Buffer | ||
| 101 | if err := s.pages[name].Execute(&buf, p); err != nil { | ||
| 102 | log.Printf("render %s: %v", name, err) | ||
| 103 | http.Error(w, "internal error", http.StatusInternalServerError) | ||
| 104 | return | ||
| 105 | } | ||
| 106 | w.Header().Set("Content-Type", "text/html; charset=utf-8") | ||
| 107 | w.WriteHeader(status) | ||
| 108 | buf.WriteTo(w) | ||
| 109 | } | ||
| 110 | |||
| 111 | func (s *server) error(w http.ResponseWriter, code int) { | ||
| 112 | s.render(w, "error", code, page{Code: code, Message: http.StatusText(code)}) | ||
| 113 | } | ||
| 114 | |||
| 115 | func (s *server) home(w http.ResponseWriter, r *http.Request) { | ||
| 116 | s.render(w, "home", http.StatusOK, page{}) | ||
| 117 | } | ||
| 118 | |||
| 119 | func (s *server) info(w http.ResponseWriter, r *http.Request) { | ||
| 120 | lib := s.store.Load() | ||
| 121 | if lib.About == "" { | ||
| 122 | s.error(w, http.StatusNotFound) | ||
| 123 | return | ||
| 124 | } | ||
| 125 | s.render(w, "info", http.StatusOK, page{Lib: lib, Current: "info"}) | ||
| 126 | } | ||
| 127 | |||
| 128 | func (s *server) album(w http.ResponseWriter, r *http.Request) { | ||
| 129 | lib := s.store.Load() | ||
| 130 | a := lib.Album(r.PathValue("album")) | ||
| 131 | if a == nil { | ||
| 132 | s.error(w, http.StatusNotFound) | ||
| 133 | return | ||
| 134 | } | ||
| 135 | s.render(w, "album", http.StatusOK, page{Lib: lib, Current: a.Slug, Album: a}) | ||
| 136 | } | ||
| 137 | |||
| 138 | func (s *server) photo(w http.ResponseWriter, r *http.Request) { | ||
| 139 | lib := s.store.Load() | ||
| 140 | a := lib.Album(r.PathValue("album")) | ||
| 141 | if a == nil { | ||
| 142 | s.error(w, http.StatusNotFound) | ||
| 143 | return | ||
| 144 | } | ||
| 145 | i, it := a.Item(r.PathValue("file")) | ||
| 146 | if it == nil { | ||
| 147 | s.error(w, http.StatusNotFound) | ||
| 148 | return | ||
| 149 | } | ||
| 150 | p := page{Lib: lib, Current: a.Slug, Album: a, Item: it, Index: i} | ||
| 151 | if i > 0 { | ||
| 152 | p.Prev = a.Items[i-1] | ||
| 153 | } | ||
| 154 | if i < len(a.Items)-1 { | ||
| 155 | p.Next = a.Items[i+1] | ||
| 156 | } | ||
| 157 | s.render(w, "photo", http.StatusOK, p) | ||
| 158 | } | ||
| 159 | |||
| 160 | func (s *server) image(w http.ResponseWriter, r *http.Request) { | ||
| 161 | lib := s.store.Load() | ||
| 162 | a := lib.Album(r.PathValue("album")) | ||
| 163 | if a == nil { | ||
| 164 | http.NotFound(w, r) | ||
| 165 | return | ||
| 166 | } | ||
| 167 | _, it := a.Item(r.PathValue("file")) | ||
| 168 | if it == nil { | ||
| 169 | http.NotFound(w, r) | ||
| 170 | return | ||
| 171 | } | ||
| 172 | ws, ok := strings.CutSuffix(r.PathValue("width"), ".jpg") | ||
| 173 | n, err := strconv.Atoi(ws) | ||
| 174 | if !ok || err != nil || !render.Allowed(n) { | ||
| 175 | http.Error(w, "bad width", http.StatusBadRequest) | ||
| 176 | return | ||
| 177 | } | ||
| 178 | p, err := s.rend.Get(it, n) | ||
| 179 | if err != nil { | ||
| 180 | log.Printf("render %s/%s@%d: %v", it.Album, it.Name, n, err) | ||
| 181 | http.Error(w, "render failed", http.StatusInternalServerError) | ||
| 182 | return | ||
| 183 | } | ||
| 184 | w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") | ||
| 185 | w.Header().Set("Content-Type", "image/jpeg") | ||
| 186 | http.ServeFile(w, r, p) | ||
| 187 | } | ||
| 188 | |||
| 189 | func (s *server) themeCSS(w http.ResponseWriter, r *http.Request) { | ||
| 190 | w.Header().Set("Content-Type", "text/css; charset=utf-8") | ||
| 191 | w.Header().Set("Cache-Control", "no-cache") | ||
| 192 | w.Write(s.theme) | ||
| 193 | } | ||
| 194 | |||
| 195 | func (s *server) script(w http.ResponseWriter, r *http.Request) { | ||
| 196 | w.Header().Set("Content-Type", "text/javascript; charset=utf-8") | ||
| 197 | w.Header().Set("Cache-Control", "no-cache") | ||
| 198 | w.Write(embeddedScript) | ||
| 199 | } | ||
internal/web/server_test.go added +211
| @@ -0,0 +1,211 @@ | |||
| 1 | package web | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "errors" | ||
| 5 | "io" | ||
| 6 | "net/http" | ||
| 7 | "net/http/httptest" | ||
| 8 | "os" | ||
| 9 | "path/filepath" | ||
| 10 | "strings" | ||
| 11 | "testing" | ||
| 12 | "time" | ||
| 13 | |||
| 14 | "gitbay.org/cmc/gallery/internal/format" | ||
| 15 | "gitbay.org/cmc/gallery/internal/library" | ||
| 16 | "gitbay.org/cmc/gallery/internal/render" | ||
| 17 | ) | ||
| 18 | |||
| 19 | type fakeFormat struct{ fail bool } | ||
| 20 | |||
| 21 | func (fakeFormat) Name() string { return "fake" } | ||
| 22 | func (fakeFormat) Match(p string) bool { return strings.HasSuffix(p, ".jpg") } | ||
| 23 | func (fakeFormat) Kind() format.Kind { return format.KindImage } | ||
| 24 | func (fakeFormat) Metadata(string) (format.Meta, error) { | ||
| 25 | return format.Meta{ | ||
| 26 | Width: 3000, Height: 2000, | ||
| 27 | Taken: time.Date(2025, 3, 14, 0, 0, 0, 0, time.UTC), | ||
| 28 | Camera: "X-T5", Aperture: "f/2", Shutter: "1/125", ISO: 1600, | ||
| 29 | }, nil | ||
| 30 | } | ||
| 31 | func (f fakeFormat) Resize(_ string, _ int, dst io.Writer) error { | ||
| 32 | if f.fail { | ||
| 33 | return errors.New("magick failed") | ||
| 34 | } | ||
| 35 | _, err := io.WriteString(dst, "JPEGDATA") | ||
| 36 | return err | ||
| 37 | } | ||
| 38 | |||
| 39 | type setup struct { | ||
| 40 | fail bool | ||
| 41 | about bool | ||
| 42 | opt Options | ||
| 43 | } | ||
| 44 | |||
| 45 | func newServer(t *testing.T, s setup) http.Handler { | ||
| 46 | t.Helper() | ||
| 47 | root := t.TempDir() | ||
| 48 | files := map[string]string{ | ||
| 49 | "tokyo/a.jpg": "x", | ||
| 50 | "tokyo/b.jpg": "x", | ||
| 51 | "tokyo/album.org": "#+title: Tokyo\n#+date: 2025-03\n", | ||
| 52 | } | ||
| 53 | if s.about { | ||
| 54 | files["about.org"] = "Hello /there/.\n" | ||
| 55 | } | ||
| 56 | for rel, body := range files { | ||
| 57 | p := filepath.Join(root, rel) | ||
| 58 | os.MkdirAll(filepath.Dir(p), 0o755) | ||
| 59 | os.WriteFile(p, []byte(body), 0o644) | ||
| 60 | } | ||
| 61 | reg := &format.Registry{} | ||
| 62 | reg.Register(fakeFormat{fail: s.fail}) | ||
| 63 | cache, err := library.OpenMetaCache(filepath.Join(t.TempDir(), "meta.json")) | ||
| 64 | if err != nil { | ||
| 65 | t.Fatal(err) | ||
| 66 | } | ||
| 67 | lib, err := library.Scan(root, reg, cache) | ||
| 68 | if err != nil { | ||
| 69 | t.Fatal(err) | ||
| 70 | } | ||
| 71 | store := &library.Store{} | ||
| 72 | store.Set(lib) | ||
| 73 | if s.opt.Title == "" { | ||
| 74 | s.opt.Title = "gallery" | ||
| 75 | } | ||
| 76 | h, err := New(store, render.New(t.TempDir(), 2), s.opt) | ||
| 77 | if err != nil { | ||
| 78 | t.Fatal(err) | ||
| 79 | } | ||
| 80 | return h | ||
| 81 | } | ||
| 82 | |||
| 83 | func get(h http.Handler, path string) *httptest.ResponseRecorder { | ||
| 84 | rec := httptest.NewRecorder() | ||
| 85 | h.ServeHTTP(rec, httptest.NewRequest("GET", path, nil)) | ||
| 86 | return rec | ||
| 87 | } | ||
| 88 | |||
| 89 | func TestPages(t *testing.T) { | ||
| 90 | h := newServer(t, setup{about: true}) | ||
| 91 | for _, tc := range []struct { | ||
| 92 | path string | ||
| 93 | code int | ||
| 94 | want []string | ||
| 95 | reject []string | ||
| 96 | }{ | ||
| 97 | {"/", 200, []string{`href="/tokyo/"`, `data-ar="1.5000"`, "Tokyo", "2025", `href="/info/"`}, nil}, | ||
| 98 | {"/tokyo/", 200, []string{`href="/tokyo/a.jpg/"`, `href="/tokyo/b.jpg/"`, `/img/tokyo/a.jpg/960.jpg?v=`, `480w`, `class="on"`}, nil}, | ||
| 99 | {"/tokyo/a.jpg/", 200, []string{"a.jpg — 1 / 2", "X-T5 · f/2 · 1/125 · ISO 1600 · 2025-03-14", `rel="next" href="/tokyo/b.jpg/"`, `id="up" href="/tokyo/"`}, []string{`rel="prev"`}}, | ||
| 100 | {"/tokyo/b.jpg/", 200, []string{`rel="prev" href="/tokyo/a.jpg/"`, "2 / 2"}, []string{`rel="next"`}}, | ||
| 101 | {"/info/", 200, []string{"<em>there</em>"}, nil}, | ||
| 102 | {"/nope/", 404, []string{"404"}, nil}, | ||
| 103 | {"/tokyo/nope.jpg/", 404, []string{"404"}, nil}, | ||
| 104 | {"/tokyo/a.jpg/extra/", 404, nil, nil}, | ||
| 105 | } { | ||
| 106 | rec := get(h, tc.path) | ||
| 107 | if rec.Code != tc.code { | ||
| 108 | t.Errorf("%s: code %d, want %d", tc.path, rec.Code, tc.code) | ||
| 109 | continue | ||
| 110 | } | ||
| 111 | body := rec.Body.String() | ||
| 112 | for _, w := range tc.want { | ||
| 113 | if !strings.Contains(body, w) { | ||
| 114 | t.Errorf("%s: missing %q", tc.path, w) | ||
| 115 | } | ||
| 116 | } | ||
| 117 | for _, r := range tc.reject { | ||
| 118 | if strings.Contains(body, r) { | ||
| 119 | t.Errorf("%s: unexpected %q", tc.path, r) | ||
| 120 | } | ||
| 121 | } | ||
| 122 | } | ||
| 123 | } | ||
| 124 | |||
| 125 | func TestInfoHiddenWithoutAbout(t *testing.T) { | ||
| 126 | h := newServer(t, setup{}) | ||
| 127 | if strings.Contains(get(h, "/").Body.String(), `href="/info/"`) { | ||
| 128 | t.Error("nav links Info without about.org") | ||
| 129 | } | ||
| 130 | if code := get(h, "/info/").Code; code != 404 { | ||
| 131 | t.Errorf("/info/ = %d, want 404", code) | ||
| 132 | } | ||
| 133 | } | ||
| 134 | |||
| 135 | func TestImages(t *testing.T) { | ||
| 136 | h := newServer(t, setup{}) | ||
| 137 | rec := get(h, "/img/tokyo/a.jpg/960.jpg?v=1") | ||
| 138 | if rec.Code != 200 || rec.Body.String() != "JPEGDATA" { | ||
| 139 | t.Fatalf("code %d body %q", rec.Code, rec.Body.String()) | ||
| 140 | } | ||
| 141 | if cc := rec.Header().Get("Cache-Control"); cc != "public, max-age=31536000, immutable" { | ||
| 142 | t.Errorf("Cache-Control = %q", cc) | ||
| 143 | } | ||
| 144 | if ct := rec.Header().Get("Content-Type"); ct != "image/jpeg" { | ||
| 145 | t.Errorf("Content-Type = %q", ct) | ||
| 146 | } | ||
| 147 | for path, code := range map[string]int{ | ||
| 148 | "/img/tokyo/a.jpg/961.jpg": 400, | ||
| 149 | "/img/tokyo/a.jpg/960.png": 400, | ||
| 150 | "/img/tokyo/a.jpg/big.jpg": 400, | ||
| 151 | "/img/nope/a.jpg/960.jpg": 404, | ||
| 152 | "/img/tokyo/nope.jpg/960.jpg": 404, | ||
| 153 | } { | ||
| 154 | if got := get(h, path).Code; got != code { | ||
| 155 | t.Errorf("%s: %d, want %d", path, got, code) | ||
| 156 | } | ||
| 157 | } | ||
| 158 | // The mux may 404 or redirect these; either way no file is served. | ||
| 159 | for _, path := range []string{"/img/%2e%2e/a.jpg/960.jpg", "/img/tokyo/..%2fa.jpg/960.jpg"} { | ||
| 160 | if rec := get(h, path); rec.Code == 200 { | ||
| 161 | t.Errorf("%s: served %q", path, rec.Body.String()) | ||
| 162 | } | ||
| 163 | } | ||
| 164 | } | ||
| 165 | |||
| 166 | func TestImageRenderFailure(t *testing.T) { | ||
| 167 | h := newServer(t, setup{fail: true}) | ||
| 168 | if code := get(h, "/img/tokyo/a.jpg/480.jpg").Code; code != 500 { | ||
| 169 | t.Fatalf("code %d, want 500", code) | ||
| 170 | } | ||
| 171 | } | ||
| 172 | |||
| 173 | func TestTheme(t *testing.T) { | ||
| 174 | rec := get(newServer(t, setup{}), "/_/theme.css") | ||
| 175 | if rec.Code != 200 || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/css") || !strings.Contains(rec.Body.String(), "--bg") { | ||
| 176 | t.Fatalf("code %d type %q", rec.Code, rec.Header().Get("Content-Type")) | ||
| 177 | } | ||
| 178 | |||
| 179 | custom := filepath.Join(t.TempDir(), "theme.css") | ||
| 180 | os.WriteFile(custom, []byte(":root{--bg:red}"), 0o644) | ||
| 181 | rec = get(newServer(t, setup{opt: Options{ThemePath: custom}}), "/_/theme.css") | ||
| 182 | if rec.Body.String() != ":root{--bg:red}" { | ||
| 183 | t.Fatalf("custom theme not served: %q", rec.Body.String()) | ||
| 184 | } | ||
| 185 | } | ||
| 186 | |||
| 187 | func TestCustomTemplates(t *testing.T) { | ||
| 188 | dir := t.TempDir() | ||
| 189 | for _, name := range []string{"base", "home", "album", "photo", "info", "error"} { | ||
| 190 | body := `{{define "content"}}custom-` + name + `{{end}}` | ||
| 191 | if name == "base" { | ||
| 192 | body = `{{template "content" .}}` | ||
| 193 | } | ||
| 194 | os.WriteFile(filepath.Join(dir, name+".html"), []byte(body), 0o644) | ||
| 195 | } | ||
| 196 | h := newServer(t, setup{opt: Options{Templates: os.DirFS(dir)}}) | ||
| 197 | if body := get(h, "/").Body.String(); body != "custom-home" { | ||
| 198 | t.Fatalf("body = %q", body) | ||
| 199 | } | ||
| 200 | } | ||
| 201 | |||
| 202 | func TestScript(t *testing.T) { | ||
| 203 | h := newServer(t, setup{}) | ||
| 204 | rec := get(h, "/_/gallery.js") | ||
| 205 | if rec.Code != 200 || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/javascript") { | ||
| 206 | t.Fatalf("code %d type %q", rec.Code, rec.Header().Get("Content-Type")) | ||
| 207 | } | ||
| 208 | if !strings.Contains(get(h, "/tokyo/").Body.String(), `<script src="/_/gallery.js" defer></script>`) { | ||
| 209 | t.Fatal("album page missing script tag") | ||
| 210 | } | ||
| 211 | } | ||
internal/web/templates/album.html added +15
| @@ -0,0 +1,15 @@ | |||
| 1 | {{define "title"}}{{.Album.Title}} · {{.Title}}{{end}} | ||
| 2 | {{define "content"}} | ||
| 3 | <header class="title"> | ||
| 4 | <h1>{{.Album.Title}}</h1> | ||
| 5 | <span>{{with .Album.Year}}{{.}} · {{end}}{{len .Album.Items}}</span> | ||
| 6 | </header> | ||
| 7 | {{with .Album.Description}}<div class="desc">{{.}}</div>{{end}} | ||
| 8 | <div class="rows"> | ||
| 9 | {{- range .Album.Items}} | ||
| 10 | <a class="tile" href="{{photoURL .}}" data-ar="{{ar .}}" style="--ar:{{ar .}}"> | ||
| 11 | <img src="{{imgURL . 960}}" srcset="{{srcset .}}" sizes="(max-width: 600px) 100vw, 33vw" width="{{.Meta.Width}}" height="{{.Meta.Height}}" loading="lazy" alt=""> | ||
| 12 | </a> | ||
| 13 | {{- end}} | ||
| 14 | </div> | ||
| 15 | {{end}} | ||
internal/web/templates/base.html added +22
| @@ -0,0 +1,22 @@ | |||
| 1 | <!doctype html> | ||
| 2 | <html lang="en"> | ||
| 3 | <head> | ||
| 4 | <meta charset="utf-8"> | ||
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1"> | ||
| 6 | <title>{{block "title" .}}{{.Title}}{{end}}</title> | ||
| 7 | <link rel="stylesheet" href="/_/theme.css"> | ||
| 8 | <script src="/_/gallery.js" defer></script> | ||
| 9 | </head> | ||
| 10 | <body class="{{block "bodyclass" .}}{{end}}"> | ||
| 11 | <nav class="site"> | ||
| 12 | <a class="brand" href="/">{{.Title}}</a> | ||
| 13 | {{- range .Lib.Albums}} | ||
| 14 | <a href="{{albumURL .}}"{{if eq .Slug $.Current}} class="on"{{end}}>{{.Title}}</a> | ||
| 15 | {{- end}} | ||
| 16 | {{- if .Lib.About}} | ||
| 17 | <a href="/info/"{{if eq .Current "info"}} class="on"{{end}}>Info</a> | ||
| 18 | {{- end}} | ||
| 19 | </nav> | ||
| 20 | <main>{{template "content" .}}</main> | ||
| 21 | </body> | ||
| 22 | </html> | ||
internal/web/templates/error.html added +2
| @@ -0,0 +1,2 @@ | |||
| 1 | {{define "title"}}{{.Code}} · {{.Title}}{{end}} | ||
| 2 | {{define "content"}}<p class="error">{{.Code}} — {{.Message}}</p>{{end}} | ||
internal/web/templates/home.html added +10
| @@ -0,0 +1,10 @@ | |||
| 1 | {{define "content"}} | ||
| 2 | <div class="rows captioned"> | ||
| 3 | {{- range $a := .Lib.Albums}}{{$c := $a.Cover}} | ||
| 4 | <a class="tile" href="{{albumURL $a}}" data-ar="{{ar $c}}" style="--ar:{{ar $c}}"> | ||
| 5 | <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}}"> | ||
| 6 | <span class="label">{{$a.Title}}{{with $a.Year}} · {{.}}{{end}}</span> | ||
| 7 | </a> | ||
| 8 | {{- end}} | ||
| 9 | </div> | ||
| 10 | {{end}} | ||
internal/web/templates/info.html added +2
| @@ -0,0 +1,2 @@ | |||
| 1 | {{define "title"}}Info · {{.Title}}{{end}} | ||
| 2 | {{define "content"}}<article class="info">{{.Lib.About}}</article>{{end}} | ||
internal/web/templates/photo.html added +16
| @@ -0,0 +1,16 @@ | |||
| 1 | {{define "title"}}{{.Item.Name}} · {{.Album.Title}} · {{.Title}}{{end}} | ||
| 2 | {{define "bodyclass"}}photo{{end}} | ||
| 3 | {{define "content"}} | ||
| 4 | <figure class="stage"> | ||
| 5 | {{with .Prev}}<a class="prev" rel="prev" href="{{photoURL .}}" aria-label="Previous">‹</a>{{end}} | ||
| 6 | <img src="{{imgURL .Item 1600}}" srcset="{{srcset .Item}}" sizes="100vw" width="{{.Item.Meta.Width}}" height="{{.Item.Meta.Height}}" alt=""> | ||
| 7 | {{with .Next}}<a class="next" rel="next" href="{{photoURL .}}" aria-label="Next">›</a>{{end}} | ||
| 8 | </figure> | ||
| 9 | <footer class="meta"> | ||
| 10 | <span>{{.Item.Name}} — {{add .Index 1}} / {{len .Album.Items}}</span> | ||
| 11 | <span>{{exif .Item}}</span> | ||
| 12 | </footer> | ||
| 13 | <a id="up" href="{{albumURL .Album}}" hidden></a> | ||
| 14 | {{with .Prev}}<link rel="preload" as="image" href="{{imgURL . 1600}}">{{end}} | ||
| 15 | {{with .Next}}<link rel="preload" as="image" href="{{imgURL . 1600}}">{{end}} | ||
| 16 | {{end}} | ||