Add albums: group photos by free-text tags
An album is a tag. Photos carry lowercase free-text tags in their .toml sidecar (`tags = [...]`), parallel to the existing day grouping — no database, all derived at build time. - albums.go: parseTags (lowercase/trim/dedupe, slug via sanitiseSlug), groupByTag mirroring groupByDay, and rendering for album pages, the index, and the JSON API. Slug collisions between distinct names are suffixed and logged. - Routes: /album/<slug>/ (paginated square grid per tag), /albums/ (index with counts), /tags.json (machine-readable tag list, served statically like feed.xml). - POST /upload gains a `tags` field written into the sidecar; the web upload form gains a tags input. - Tags shown on each photo page linking to their album; an Albums link in the timeline/grid header.
This commit is contained in:
1 parent
f33bdd55b1
commit
a42abfab48
11 files changed
+469
-6
No files matched your search
@@ -0,0 +1,204 @@
|
||||
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)
|
||||
}
|
||||
Reference in new issue
Block a user