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//, // 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) }