From d143ee2803888efbfefe4f6ec206b49d546a8fc2 Mon Sep 17 00:00:00 2001 From: ssavutu Date: Thu, 13 Aug 2026 16:33:12 -0400 Subject: [PATCH] Re-curate the A&E subsection strip The desk asked for Listicles and Books to come out of the strip and TV to go in. TV is the substantial one. It arrived hidden with the rest of the legacy WordPress sub-categories -- that seeding deliberately gave 48 categories a home without adding 48 nav links -- but it carries 135 published articles, more than most of the rows that do have a link. Listicles and Books have nine each. Visibility only: every one of these keeps its page, its URL and its articles, and a hidden row still feeds its section. Applied once, under its own flag, and each update is guarded on the current value. The strip is curated from the sections screen, so an editor who puts Books back must not find it hidden again on the next deploy, and a row somebody already set by hand is left alone rather than rewritten. Runs after the Food move as well as the legacy seeding: the latter is what creates the TV row this unhides, while Listicles and Books stay where the move left them. The two seeds touch disjoint rows. Co-Authored-By: Claude Opus 5 --- server/internal/database/taxonomy.go | 106 ++++++++++++++++++ .../database/taxonomy_integration_test.go | 82 ++++++++++++++ 2 files changed, 188 insertions(+) diff --git a/server/internal/database/taxonomy.go b/server/internal/database/taxonomy.go index d03c867..5fadf60 100644 --- a/server/internal/database/taxonomy.go +++ b/server/internal/database/taxonomy.go @@ -56,6 +56,13 @@ func EnsureTaxonomyTable(ctx context.Context, conn *sql.DB) error { return err } seeded = append(seeded, moved...) + // After the Food move as well as the legacy seeding: TV is unhidden here + // and the legacy seeding is what creates it, while Listicles and Books are + // left where the move put them. The two seeds touch disjoint rows, so the + // order between them is about legibility rather than correctness. + if err = SeedEntertainmentVisibility(ctx, conn); err != nil { + return err + } if err = RefreshCategoryAliases(ctx, conn); err != nil { return err } @@ -494,6 +501,105 @@ func SeedFoodSubsection(ctx context.Context, conn *sql.DB) ([]string, error) { return touched, writeSettingRaw(ctx, conn, "food_subsection_seeded", "1") } +// entertainmentVisibility is which A&E subsections earn a link in the section's +// strip, as decided by the desk: Listicles and Books come out, TV goes in. +// +// TV is the substantial one. It arrived hidden with the rest of the legacy +// WordPress sub-categories -- that seeding deliberately gave 48 categories a +// home without adding 48 nav links -- but it carries 135 published articles, +// more than most of the rows that do have a link. Listicles and Books have nine +// each. +// +// Visibility only. Every one of these keeps its page, its URL and its articles; +// a hidden row still feeds its section. The strip is a curation decision, which +// is exactly the kind of thing that belongs to an editor and not to a deploy -- +// see the run-once note on SeedEntertainmentVisibility. +var entertainmentVisibility = map[string]bool{ + "listicles": false, + "books": false, + "tv": true, +} + +// SeedEntertainmentVisibility applies those decisions once. +// +// Once, and recorded in cms_settings, for the reason the other seeds are: the +// strip is editable from the sections screen, and an editor who puts Books back +// must not find it hidden again after the next deploy. The flag is set even when +// nothing changed, since "already in that state" and "already ran" want the same +// outcome. +// +// Each update is guarded on the CURRENT value, so a row somebody already set by +// hand is left alone rather than rewritten, and the log reports only what this +// actually moved. +// +// An empty table means the sections have not been imported yet, so it returns +// without recording the flag and tries again next boot -- otherwise a server +// that starts before its seed import would burn the one run it gets. +func SeedEntertainmentVisibility(ctx context.Context, conn *sql.DB) error { + if conn == nil { + return nil + } + + var settingsTableExists int + if err := conn.QueryRowContext(ctx, + "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'cms_settings'", + ).Scan(&settingsTableExists); err != nil { + return err + } + if settingsTableExists == 0 { + return nil + } + + var done string + switch err := conn.QueryRowContext(ctx, + "SELECT value_text FROM cms_settings WHERE key_name = 'entertainment_visibility_seeded' LIMIT 1", + ).Scan(&done); err { + case nil: + if strings.TrimSpace(done) == "1" { + return nil + } + case sql.ErrNoRows: + // Not yet seeded. + default: + return err + } + + subsections, err := existingSlugsByKind(ctx, conn, "subsection") + if err != nil { + return err + } + if len(subsections) == 0 { + return nil + } + + changed := make([]string, 0, len(entertainmentVisibility)) + for slug, visible := range entertainmentVisibility { + if _, exists := subsections[slug]; !exists { + slog.Warn("skipping a visibility change for a subsection that does not exist", "slug", slug) + continue + } + result, err := conn.ExecContext(ctx, ` + UPDATE site_taxonomy + SET is_visible = ? + WHERE kind = 'subsection' AND slug = ? AND is_visible = ? + `, visible, slug, !visible) + if err != nil { + return err + } + // RowsAffected is not proof of anything through MaxScale, which can + // report 0 for a write that landed. It is only used to keep the log + // honest about what moved, so a wrong answer costs a log line. + if affected, err := result.RowsAffected(); err == nil && affected > 0 { + changed = append(changed, slug) + } + } + + if len(changed) > 0 { + slog.Info("applied the A&E subsection strip changes", "slugs", changed) + } + return writeSettingRaw(ctx, conn, "entertainment_visibility_seeded", "1") +} + func existingSlugsByKind(ctx context.Context, conn *sql.DB, kind string) (map[string]struct{}, error) { rows, err := conn.QueryContext(ctx, "SELECT slug FROM site_taxonomy WHERE kind = ?", kind) if err != nil { diff --git a/server/internal/database/taxonomy_integration_test.go b/server/internal/database/taxonomy_integration_test.go index d5bb170..ea44e5e 100644 --- a/server/internal/database/taxonomy_integration_test.go +++ b/server/internal/database/taxonomy_integration_test.go @@ -425,3 +425,85 @@ func TestFoodSubsectionSeedMovesTheReviewCategories(t *testing.T) { t.Errorf("wine-reviews parent = %q, want entertainment -- the seed re-ran over an editor's change", got) } } + +// TestEntertainmentVisibilitySeedRunsOnce covers the strip changes the desk +// asked for: Listicles and Books lose their link, TV gains one. +// +// The run-once half is the point. The strip is curated from the sections +// screen, so an editor who puts Books back must not find it hidden again on the +// next deploy. +func TestEntertainmentVisibilitySeedRunsOnce(t *testing.T) { + conn := taxonomyTestDB(t) + ctx := context.Background() + + if err := EnsureSettingsTable(ctx, conn); err != nil { + t.Fatalf("ensure settings table: %v", err) + } + for _, key := range []string{"legacy_subsections_seeded", "entertainment_visibility_seeded"} { + if _, err := conn.ExecContext(ctx, "DELETE FROM cms_settings WHERE key_name = ?", key); err != nil { + t.Fatalf("clear %s: %v", key, err) + } + } + + if err := EnsureTaxonomyTable(ctx, conn); err != nil { + t.Fatalf("ensure taxonomy table: %v", err) + } + insertTaxonomyRow(t, conn, 1, "section", "entertainment", "Entertainment") + // Listicles and Books are current, visible subsections; TV arrives hidden + // from the legacy seeding, which runs in the same boot. + for id, slug := range map[int64]string{2: "listicles", 3: "books"} { + insertTaxonomyRow(t, conn, id, "subsection", slug, strings.ToUpper(slug[:1])+slug[1:]) + if _, err := conn.ExecContext(ctx, + "UPDATE site_taxonomy SET parent_slug = 'entertainment', is_visible = 1 WHERE slug = ?", slug, + ); err != nil { + t.Fatalf("parent %s: %v", slug, err) + } + } + + if err := EnsureTaxonomyTable(ctx, conn); err != nil { + t.Fatalf("second ensure: %v", err) + } + + visibilityOf := func(slug string) int { + t.Helper() + var visible int + if err := conn.QueryRowContext(ctx, + "SELECT is_visible FROM site_taxonomy WHERE slug = ?", slug, + ).Scan(&visible); err != nil { + t.Fatalf("read visibility of %s: %v", slug, err) + } + return visible + } + + for slug, want := range entertainmentVisibility { + got := visibilityOf(slug) + if (got == 1) != want { + t.Errorf("%s is_visible = %d, want %v", slug, got, want) + } + } + + // Hiding is only ever about the link: TV keeps its articles either way, and + // Books keeps its page. Prove the seed touched nothing else. + if parent := func() string { + var p sql.NullString + if err := conn.QueryRowContext(ctx, "SELECT parent_slug FROM site_taxonomy WHERE slug = 'books'").Scan(&p); err != nil { + t.Fatalf("read books parent: %v", err) + } + return p.String + }(); parent != "entertainment" { + t.Errorf("books parent = %q, want entertainment -- hiding must not re-file a row", parent) + } + + // An editor puts Books back, and a restart leaves it visible. + if _, err := conn.ExecContext(ctx, + "UPDATE site_taxonomy SET is_visible = 1 WHERE slug = 'books'", + ); err != nil { + t.Fatalf("unhide books: %v", err) + } + if err := EnsureTaxonomyTable(ctx, conn); err != nil { + t.Fatalf("third ensure: %v", err) + } + if got := visibilityOf("books"); got != 1 { + t.Errorf("books is_visible = %d after a restart, want 1 -- the seed re-ran over an editor's change", got) + } +}