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,
|
2026-10-18 22:06:46 +02:00
|
|
|
"TimelineURL": g.timelineURL(),
|
|
|
|
|
"GridURL": g.gridURL(),
|
2026-10-08 16:14:09 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
}
|