Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
235 changes: 227 additions & 8 deletions server/internal/database/footer_settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"database/sql"
"encoding/json"
"strings"
"sync"
"time"

"server/internal/models"
)
Expand All @@ -14,10 +16,183 @@ import (
// whole by the settings screen, and it never needs to be queried by parts.
const footerSettingKey = "footer_menu"

// defaultFooterColumns mirrors the footer the public site shipped hardcoded, so
// an untouched install serves exactly what it served before and the settings
// screen opens pre-populated instead of blank.
func defaultFooterColumns() []models.FooterColumn {
// footerPart is one group inside a generated column: either a taxonomy section,
// rendered as its heading plus its visible subsections, or a literal run of
// entries for the things the taxonomy does not describe.
type footerPart struct {
Section string
Entries []models.FooterEntry
}

// footerTemplate is the SHAPE of the footer -- which sections share a column and
// in what order -- with the links themselves left to the taxonomy.
//
// The shape stays hand-written because it is a layout decision that no data
// answers: Columns is stacked under Opinion and Special Editions under Comics &
// Puzzles to keep the footer at six columns, and a rule like "one column per
// section" would widen it to eight the moment somebody adds a section.
//
// The two literal blocks are the entries that are not taxonomy at all. The About
// column is site furniture. The Special Editions block is subtler: those rows DO
// exist in the taxonomy now, but three of the four deliberately point somewhere
// other than their own page -- The Rectangle is an external site, Welcome Week is
// a search URL, 100 Year Anniversary is a bespoke page -- and Graduation is a
// section of its own that appears in no other column. Generating them would
// quietly redirect four working links.
func footerTemplate() [][]footerPart {
link := func(label, href string) models.FooterEntry {
return models.FooterEntry{Kind: models.FooterEntryLink, Label: label, Href: href}
}
external := func(label, href string) models.FooterEntry {
return models.FooterEntry{Kind: models.FooterEntryLink, Label: label, Href: href, NewTab: true}
}
heading := func(label, href string) models.FooterEntry {
return models.FooterEntry{Kind: models.FooterEntryHeading, Label: label, Href: href}
}

return [][]footerPart{
{{Entries: []models.FooterEntry{
heading("About", "/about"),
link("Contact Us", "/contact"),
external("Join The Triangle", "https://docs.google.com/forms/d/e/1FAIpQLScra_6sUenvmpIuQ5FjmMyWO0a2sz9z36HkrqfnYQvJGH9BGQ/viewform"),
link("Staff", "/staff"),
link("Find-A-Triangle", "/find"),
link("Photo Gallery", "/photo"),
external("Print Archive", "https://drexel.primo.exlibrisgroup.com/discovery/collectionDiscovery?vid=01DRXU_INST:01DRXU&inst=01DRXU_INST&collectionId=81448731180004721"),
link("Constitution", "/proxy/wp-content/uploads/2026/03/The-Triangle-Constitution-3.pdf"),
}}},
{{Section: "news"}},
{{Section: "sports"}},
{{Section: "opinion"}, {Section: "columns"}},
{{Section: "entertainment"}},
{{Section: "comics-puzzles"}, {Entries: []models.FooterEntry{
heading("Special Editions", "/"),
link("Graduation", "/graduation"),
link("Welcome Week", "/search?s=Welcome%20Week"),
external("The Rectangle", "https://therectangle.org"),
link("100 Year Anniversary", "/one-hundred"),
}}},
}
}

// footerTaxonomy is the section tree the footer needs: each section's title, and
// the visible subsections directly under it, in strip order.
type footerTaxonomy struct {
titles map[string]string
children map[string][]models.FooterEntry
}

// loadFooterTaxonomy reads the sections and their DIRECT visible subsections.
//
// Direct only. The tree is three levels deep now -- Beer Reviews hangs under
// Food, which hangs under A&E -- and a footer that recursed would list the whole
// archive. One layer is what the section strip shows, so the footer matches the
// page it links to.
//
// is_visible filters both, which is what makes the footer stop being a separate
// thing to maintain: hiding a subsection in the sections screen removes it from
// the strip and from the footer in one action.
func loadFooterTaxonomy(ctx context.Context, conn *sql.DB) (footerTaxonomy, error) {
loaded := footerTaxonomy{
titles: map[string]string{},
children: map[string][]models.FooterEntry{},
}
if conn == nil {
return loaded, nil
}

rows, err := conn.QueryContext(ctx, `
SELECT kind, slug, canonical_title, COALESCE(parent_slug, '')
FROM site_taxonomy
WHERE kind IN ('section', 'subsection') AND is_visible = 1
ORDER BY id ASC
`)
if err != nil {
return footerTaxonomy{}, err
}
defer rows.Close()

for rows.Next() {
var kind, slug, title, parent string
if err := rows.Scan(&kind, &slug, &title, &parent); err != nil {
return footerTaxonomy{}, err
}
slug = strings.TrimSpace(slug)
title = strings.TrimSpace(title)
if slug == "" || title == "" {
continue
}
if kind == "section" {
loaded.titles[slug] = title
continue
}
if parent = strings.TrimSpace(parent); parent != "" {
loaded.children[parent] = append(loaded.children[parent], models.FooterEntry{
Kind: models.FooterEntryLink,
Label: title,
Href: "/" + slug,
})
}
}
return loaded, rows.Err()
}

// buildFooterColumns renders footerTemplate against the taxonomy.
//
// Falls back to the static columns whenever the taxonomy cannot supply the
// sections -- an unreadable table, or one that has not been imported yet. The
// footer is on every page, so "render the old links" beats "render an empty
// nav" by a wide margin.
func buildFooterColumns(ctx context.Context, conn *sql.DB) []models.FooterColumn {
loaded, err := loadFooterTaxonomy(ctx, conn)
if err != nil || len(loaded.titles) == 0 {
return staticFooterColumns()
}

columns := make([]models.FooterColumn, 0, len(footerTemplate()))
for _, parts := range footerTemplate() {
entries := make([]models.FooterEntry, 0, 12)
for _, part := range parts {
rendered := part.Entries
if part.Section != "" {
title, known := loaded.titles[part.Section]
if !known {
// A section the template names but the taxonomy does not
// have. Skipping the group keeps the rest of the column,
// rather than emitting a heading that links nowhere.
continue
}
rendered = append([]models.FooterEntry{{
Kind: models.FooterEntryHeading,
Label: title,
Href: "/" + part.Section,
}}, loaded.children[part.Section]...)
}
if len(rendered) == 0 {
continue
}
// The blank line between stacked groups, added only between them so
// a column never opens or closes on a spacer.
if len(entries) > 0 {
entries = append(entries, models.FooterEntry{Kind: models.FooterEntrySpacer})
}
entries = append(entries, rendered...)
}
if len(entries) > 0 {
columns = append(columns, models.FooterColumn{Entries: entries})
}
}

if len(columns) == 0 {
return staticFooterColumns()
}
return columns
}

// staticFooterColumns mirrors the footer the public site shipped hardcoded. It
// is now only the fallback for when the taxonomy cannot be read; the live
// default is generated from it by buildFooterColumns.
func staticFooterColumns() []models.FooterColumn {
link := func(label, href string) models.FooterEntry {
return models.FooterEntry{Kind: models.FooterEntryLink, Label: label, Href: href}
}
Expand Down Expand Up @@ -93,6 +268,50 @@ func defaultFooterColumns() []models.FooterColumn {
}
}

// generatedFooter caches the columns built from the taxonomy.
//
// The footer renders on every page and the stored setting is absent by default,
// so without this every request would run a taxonomy query -- the exact shape of
// load that put a queue in front of the database on 2026-08-06. It shares
// settingsCacheTTL with cms_settings, since it is the same kind of value: read
// constantly, changed a few times a month.
var (
generatedFooterMu sync.RWMutex
generatedFooterColumns []models.FooterColumn
generatedFooterExpires time.Time
)

// defaultFooterColumns is the footer served when nothing is stored: generated
// from the taxonomy, cached, and falling back to the static columns.
func defaultFooterColumns(ctx context.Context, conn *sql.DB) []models.FooterColumn {
ttl := settingsCacheTTL()
if ttl > 0 {
generatedFooterMu.RLock()
cached, expires := generatedFooterColumns, generatedFooterExpires
generatedFooterMu.RUnlock()
if cached != nil && time.Now().Before(expires) {
return cached
}
}

columns := buildFooterColumns(ctx, conn)
if ttl > 0 {
generatedFooterMu.Lock()
generatedFooterColumns, generatedFooterExpires = columns, time.Now().Add(ttl)
generatedFooterMu.Unlock()
}
return columns
}

// InvalidateGeneratedFooter drops the cached columns, so a taxonomy edit shows
// up in the footer on the next request rather than up to a TTL later. Called
// from the same place that refreshes the other taxonomy-derived state.
func InvalidateGeneratedFooter() {
generatedFooterMu.Lock()
generatedFooterColumns, generatedFooterExpires = nil, time.Time{}
generatedFooterMu.Unlock()
}

// GetFooterSettings returns the stored footer menu, falling back to the
// built-in default when the key is absent, blank, unparseable, or stores an
// empty menu. The public footer is not something that should ever render empty
Expand All @@ -103,19 +322,19 @@ func GetFooterSettings(ctx context.Context, conn *sql.DB) (models.FooterSettings
return models.FooterSettings{}, err
}
if !found {
return models.FooterSettings{Columns: defaultFooterColumns()}, nil
return models.FooterSettings{Columns: defaultFooterColumns(ctx, conn)}, nil
}
if strings.TrimSpace(raw) == "" {
return models.FooterSettings{Columns: defaultFooterColumns()}, nil
return models.FooterSettings{Columns: defaultFooterColumns(ctx, conn)}, nil
}

var parsed models.FooterSettings
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
return models.FooterSettings{Columns: defaultFooterColumns()}, nil
return models.FooterSettings{Columns: defaultFooterColumns(ctx, conn)}, nil
}
parsed.Columns = normalizeFooterColumns(parsed.Columns)
if len(parsed.Columns) == 0 {
return models.FooterSettings{Columns: defaultFooterColumns()}, nil
return models.FooterSettings{Columns: defaultFooterColumns(ctx, conn)}, nil
}
return parsed, nil
}
Expand Down
52 changes: 51 additions & 1 deletion server/internal/database/footer_settings_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package database

import (
"context"
"testing"

"server/internal/models"
Expand Down Expand Up @@ -76,7 +77,7 @@ func TestNormalizeFooterColumns_StripsSpacerContent(t *testing.T) {
// The default menu is what an untouched install serves, so it must survive
// normalization unchanged.
func TestDefaultFooterColumns_SurviveNormalization(t *testing.T) {
defaults := defaultFooterColumns()
defaults := staticFooterColumns()
normalized := normalizeFooterColumns(defaults)

if len(normalized) != len(defaults) {
Expand All @@ -88,3 +89,52 @@ func TestDefaultFooterColumns_SurviveNormalization(t *testing.T) {
}
}
}

// TestBuildFooterColumns_FallsBackWithoutTaxonomy is the safety net: the footer
// is on every page, so a taxonomy it cannot read has to leave the old links
// standing rather than render an empty nav.
func TestBuildFooterColumns_FallsBackWithoutTaxonomy(t *testing.T) {
got := buildFooterColumns(context.Background(), nil)

want := staticFooterColumns()
if len(got) != len(want) {
t.Fatalf("got %d columns, want the %d static ones", len(got), len(want))
}
if got[0].Entries[0].Label != "About" {
t.Errorf("first entry = %q, want the static About heading", got[0].Entries[0].Label)
}
}

// TestFooterTemplate_KeepsTheNonTaxonomyLinks guards the entries that must NOT
// be generated. Three of the Special Editions links deliberately point away
// from their own taxonomy page, and Graduation is a section that appears in no
// other column, so generating that block would quietly redirect four links.
func TestFooterTemplate_KeepsTheNonTaxonomyLinks(t *testing.T) {
var literals []models.FooterEntry
for _, column := range footerTemplate() {
for _, part := range column {
if part.Section == "" {
literals = append(literals, part.Entries...)
}
}
}

for _, want := range []struct{ label, href string }{
{"The Rectangle", "https://therectangle.org"},
{"Welcome Week", "/search?s=Welcome%20Week"},
{"100 Year Anniversary", "/one-hundred"},
{"Graduation", "/graduation"},
{"Contact Us", "/contact"},
} {
found := false
for _, entry := range literals {
if entry.Label == want.label && entry.Href == want.href {
found = true
break
}
}
if !found {
t.Errorf("%q -> %q is no longer a literal footer entry; generating it would change where it points", want.label, want.href)
}
}
}
Loading
Loading