Files
photog/albums.go
T

204 lines
5.3 KiB
Go
Raw Normal View History

2026-10-08 16:14:09 +02:00
package main
import (
"encoding/json"
"log"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
)
// Tag is a normalized album tag: a lowercase free-text Name and its URL Slug.
type Tag struct {
Name string
Slug string
}
// Album is all photos sharing one tag, newest first.
type Album struct {
Tag Tag
Count int
Photos []Photo
}
// parseTags normalizes raw tag strings into unique Tags. Each input may itself
// be comma-separated (as typed in the upload form), so entries are split on
// commas, lowercased, trimmed, de-duplicated by name, and given a URL slug via
// sanitiseSlug. Entries that are empty or slug to nothing are dropped.
func parseTags(raw ...string) []Tag {
var tags []Tag
seen := map[string]bool{}
for _, entry := range raw {
for _, part := range strings.Split(entry, ",") {
name := strings.ToLower(strings.TrimSpace(part))
if name == "" || seen[name] {
continue
}
slug := sanitiseSlug(name)
if slug == "" {
continue
}
seen[name] = true
tags = append(tags, Tag{Name: name, Slug: slug})
}
}
return tags
}
// groupByTag collapses photos (already sorted newest first) into one Album per
// distinct tag name, preserving photo order within each album. Albums are
// returned sorted alphabetically by tag name. If two different names produce the
// same URL slug, the later one is suffixed (-2, -3, …) and a warning is logged,
// so album URLs stay unique.
func groupByTag(photos []Photo) []Album {
byName := map[string][]Photo{}
for _, p := range photos {
for _, t := range p.Tags {
byName[t.Name] = append(byName[t.Name], p)
}
}
names := make([]string, 0, len(byName))
for name := range byName {
names = append(names, name)
}
sort.Strings(names)
slugOwner := map[string]string{}
albums := make([]Album, 0, len(names))
for _, name := range names {
slug := sanitiseSlug(name)
if owner, taken := slugOwner[slug]; taken && owner != name {
base := slug
for n := 2; ; n++ {
cand := base + "-" + strconv.Itoa(n)
if _, t := slugOwner[cand]; !t {
slug = cand
break
}
}
log.Printf("album slug collision: %q and %q both slug to %q; using %q for the latter",
owner, name, base, slug)
}
slugOwner[slug] = name
ps := byName[name]
albums = append(albums, Album{
Tag: Tag{Name: name, Slug: slug},
Count: len(ps),
Photos: ps,
})
}
return albums
}
// baseData returns the profile-header and site-wide fields every page template
// expects, so the album templates stay in step with the others.
func (g *Generator) baseData() map[string]any {
return map[string]any{
"BaseURL": g.baseURL,
"Year": time.Now().Year(),
"Author": g.author,
"Handle": g.handle,
"Bio": g.bio,
"BioAlt": g.bioAlt,
"Initial": g.initial(),
"FontsURL": g.fontsURL,
"SerifFamily": g.serifFamily,
"MlFamily": g.mlFamily,
}
}
// renderAlbumPages writes one paginated album per tag under public/album/<slug>/,
// a flat square grid like the grid view. total is the site-wide photo count, for
// the profile header.
func (g *Generator) renderAlbumPages(albums []Album, total int) error {
for _, al := range albums {
albumDir := filepath.Join(g.outputDir, "album", al.Tag.Slug)
if err := os.MkdirAll(albumDir, 0755); err != nil {
return err
}
base := "/album/" + al.Tag.Slug + "/"
totalPages := (al.Count + g.pageSize - 1) / g.pageSize
if totalPages == 0 {
totalPages = 1
}
for i := 0; i < totalPages; i++ {
start := i * g.pageSize
end := start + g.pageSize
if end > al.Count {
end = al.Count
}
dst := filepath.Join(albumDir, "index.html")
if i > 0 {
dir := filepath.Join(albumDir, "page", strconv.Itoa(i+1))
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
dst = filepath.Join(dir, "index.html")
}
prev := ""
next := ""
switch i {
case 0:
// no prev
case 1:
prev = base
default:
prev = base + "page/" + strconv.Itoa(i) + "/"
}
if i+1 < totalPages {
next = base + "page/" + strconv.Itoa(i+2) + "/"
}
data := g.baseData()
data["Tag"] = al.Tag
data["Count"] = al.Count
data["Photos"] = al.Photos[start:end]
data["Total"] = total
data["Page"] = i + 1
data["TotalPages"] = totalPages
data["PrevURL"] = prev
data["NextURL"] = next
if err := g.render("album.html", dst, data); err != nil {
return err
}
}
}
return nil
}
// renderAlbumsIndex writes public/albums/index.html — every tag with its count,
// linking to each album.
func (g *Generator) renderAlbumsIndex(albums []Album, total int) error {
data := g.baseData()
data["Albums"] = albums
data["Total"] = total
return g.render("albums.html", filepath.Join(g.outputDir, "albums", "index.html"), data)
}
type tagJSON struct {
Tag string `json:"tag"`
Slug string `json:"slug"`
Count int `json:"count"`
}
// renderTagsJSON writes public/tags.json — the machine-readable tag list, served
// statically like feed.xml.
func (g *Generator) renderTagsJSON(albums []Album) error {
out := make([]tagJSON, 0, len(albums))
for _, a := range albums {
out = append(out, tagJSON{Tag: a.Tag.Name, Slug: a.Tag.Slug, Count: a.Count})
}
b, err := json.MarshalIndent(out, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(g.outputDir, "tags.json"), b, 0644)
}