diff --git a/server/internal/database/footer_settings.go b/server/internal/database/footer_settings.go index 1290f24..795ed68 100644 --- a/server/internal/database/footer_settings.go +++ b/server/internal/database/footer_settings.go @@ -5,6 +5,8 @@ import ( "database/sql" "encoding/json" "strings" + "sync" + "time" "server/internal/models" ) @@ -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} } @@ -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 @@ -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 } diff --git a/server/internal/database/footer_settings_test.go b/server/internal/database/footer_settings_test.go index b7b7fb1..c0566c1 100644 --- a/server/internal/database/footer_settings_test.go +++ b/server/internal/database/footer_settings_test.go @@ -1,6 +1,7 @@ package database import ( + "context" "testing" "server/internal/models" @@ -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) { @@ -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) + } + } +} diff --git a/server/internal/database/taxonomy_integration_test.go b/server/internal/database/taxonomy_integration_test.go index ea44e5e..ea997ab 100644 --- a/server/internal/database/taxonomy_integration_test.go +++ b/server/internal/database/taxonomy_integration_test.go @@ -8,6 +8,8 @@ import ( "testing" _ "github.com/go-sql-driver/mysql" + + "server/internal/models" ) // A section the seed has no defaults for, for the "keeps no aliases" case. @@ -507,3 +509,108 @@ func TestEntertainmentVisibilitySeedRunsOnce(t *testing.T) { t.Errorf("books is_visible = %d after a restart, want 1 -- the seed re-ran over an editor's change", got) } } + +// TestFooterDefaultFollowsTheTaxonomy is the point of generating the footer: +// the desk curates the section strip, and the footer follows without anyone +// editing a second list. +// +// It covers the three things that make it "one layer of depth": a section's +// direct children are listed, a hidden one is not, and a grandchild is not +// either -- the tree is three levels now, and a footer that recursed would +// print the archive. +func TestFooterDefaultFollowsTheTaxonomy(t *testing.T) { + conn := taxonomyTestDB(t) + ctx := context.Background() + + if err := EnsureSettingsTable(ctx, conn); err != nil { + t.Fatalf("ensure settings table: %v", err) + } + if err := EnsureTaxonomyTable(ctx, conn); err != nil { + t.Fatalf("ensure taxonomy table: %v", err) + } + t.Cleanup(InvalidateGeneratedFooter) + + insertTaxonomyRow(t, conn, 1, "section", "entertainment", "Entertainment") + // Movies is linked, Books is hidden, and Beer Reviews is a grandchild under + // the visible Food row. + for _, row := range []struct { + id int64 + slug string + title string + parent string + visible int + }{ + {2, "movies", "Movies", "entertainment", 1}, + {3, "books", "Books", "entertainment", 0}, + {4, "food", "Food", "entertainment", 1}, + {5, "beer-reviews", "Beer Reviews", "food", 1}, + } { + insertTaxonomyRow(t, conn, row.id, "subsection", row.slug, row.title) + if _, err := conn.ExecContext(ctx, + "UPDATE site_taxonomy SET parent_slug = ?, is_visible = ? WHERE slug = ?", + row.parent, row.visible, row.slug, + ); err != nil { + t.Fatalf("place %s: %v", row.slug, err) + } + } + + labels := func() []string { + t.Helper() + settings, err := GetFooterSettings(ctx, conn) + if err != nil { + t.Fatalf("get footer settings: %v", err) + } + var found []string + for _, column := range settings.Columns { + for _, entry := range column.Entries { + found = append(found, entry.Label) + } + } + return found + } + + got := labels() + has := func(label string) bool { + for _, entry := range got { + if entry == label { + return true + } + } + return false + } + + if !has("Movies") || !has("Food") { + t.Errorf("footer = %v, want the visible subsections listed", got) + } + if has("Books") { + t.Error("a hidden subsection reached the footer; the strip toggle has to drive both") + } + if has("Beer Reviews") { + t.Error("a grandchild reached the footer; the footer is one layer deep") + } + // The literal blocks survive alongside the generated ones. + if !has("The Rectangle") || !has("Contact Us") { + t.Errorf("footer = %v, want the non-taxonomy links kept", got) + } + + // Hiding Movies removes it, once the cached columns are dropped the way a + // taxonomy write does. + if _, err := conn.ExecContext(ctx, "UPDATE site_taxonomy SET is_visible = 0 WHERE slug = 'movies'"); err != nil { + t.Fatalf("hide movies: %v", err) + } + InvalidateGeneratedFooter() + if got = labels(); has("Movies") { + t.Errorf("footer = %v, want Movies gone after it was hidden", got) + } + + // A stored menu still wins: generating is the DEFAULT, not an override of + // whatever an editor saved in the settings screen. + if err := SetFooterSettings(ctx, conn, models.FooterSettings{Columns: []models.FooterColumn{ + {Entries: []models.FooterEntry{{Kind: models.FooterEntryLink, Label: "Only This", Href: "/only"}}}, + }}); err != nil { + t.Fatalf("store a custom footer: %v", err) + } + if got = labels(); len(got) != 1 || got[0] != "Only This" { + t.Errorf("footer = %v, want the stored menu to win over the generated default", got) + } +} diff --git a/server/internal/handlers/taxonomy.go b/server/internal/handlers/taxonomy.go index 3e541f4..26946b6 100644 --- a/server/internal/handlers/taxonomy.go +++ b/server/internal/handlers/taxonomy.go @@ -102,6 +102,10 @@ func refreshTaxonomyDerivedState(r *http.Request, conn *sql.DB, slugs ...string) if err := db.RebuildTaxonomyArticleCountsFor(r.Context(), conn, slugs...); err != nil { slog.Error("failed to recount taxonomy articles after a write", "slugs", slugs, "error", err) } + // The public footer's default is built from this same tree, so a renamed + // section or a toggled subsection has to drop those cached columns too -- + // otherwise the sections screen and the footer disagree for up to a TTL. + db.InvalidateGeneratedFooter() } // parentSlugForRecount narrows validateTaxonomyParent's any-typed result to the