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) + } +}