# Changelog

All notable changes to BW AI Schema Pro are documented here.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [2.3.1] - 2026-06-05

### Added (Tabbed settings page)
- **Settings page now mirrors the Setup Wizard.** Inner-tab navigation
  surfaces every wizard setting on a single evergreen page: Organization
  / People / Contact / Pages / Content / Features / Advanced. The wizard
  is still recommended for first-time setup, but admins no longer have
  to re-run it to adjust a single field. Switching tabs is JS-only (no
  page reload) and a `#tab=` hash remembers the active section.
- **People tab.** Maps the team-member post type, surfaces every team
  CPT row as a table with Is Author / Is Founder / Exclude from schema
  toggles and a Linked WordPress User selector — same fields as wizard
  step 2.
- **Contact tab.** Editable postal address + a 3-row contactPoint[]
  table (extra rows appear automatically as you fill them).
- **Pages tab.** Page mappings for About / Services / Contact / Blog /
  Team / Terms / Privacy — same canonical keys as the wizard.
- **Content tab.** Per-post-type schema-type defaults including the
  new "— Don't emit (handled elsewhere) —" option (see below). Auto-
  flags post types that another plugin or our own dedicated path
  already covers (team CPT → ProfilePage+Person; `job_listing` → WP
  Job Manager; `product` → WooCommerce).
- **Features tab.** Master toggles (schema / breadcrumbs / sitelinks
  search / cache), Publishing Principles + Fact-Checking URLs, "Take
  over from other schema plugins" (Yoast / RankMath / SEOPress / AIOSEO
  detection) plus the existing Post FAQ, Author Box, and Meta Box
  Display controls.
- **Advanced tab.** Wizard-gap fields that previously had no UI: VAT
  ID, LEI code, ISO 6523 code, NAICS, Tax ID, DUNS, foundingLocation,
  geo coordinates (lat / lng), and an openingHoursSpecification[]
  table with day-checkbox + time inputs per row.

### Added (Setup Wizard improvements)
- **"None / handled elsewhere" option in post-type-defaults.** The
  wizard's per-post-type schema select now includes an explicit "—
  Don't emit (handled by another plugin or our dedicated path) —"
  choice. Detects WP Job Manager (`job_listing`), WooCommerce
  (`product`), and the active team CPT, and shows a green confirmation
  note under the dropdown when "Don't emit" is the right pick.
- **Take-over-from-SEO-plugin default on first-run setup.** When the
  wizard is being completed for the first time AND a known SEO plugin
  (Yoast / RankMath / SEOPress / AIOSEO) is detected, the "Take over"
  checkbox is pre-checked. This avoids the `@id` collision documented
  below — both plugins emit a node with `@id = ".../#organization"`,
  and crawler merging is order-dependent. With the box checked, the
  SEO plugin's schema is fully suppressed and AI Schema Pro is the
  single source. Admins who want the older co-existence mode can
  uncheck the box.
- **Wizard step 6 copy rewritten.** "Schema Conflict Management"
  became "Take over from other schema plugins" with explanatory text
  that clarifies what changes when each box is checked.

### Fixed
- **Byline now falls back to the team-CPT Linked WordPress User.**
  `resolve_team_member_for_post()` in `class-bw-schema-author-override.php`
  used to consult only the per-post `_bw_schema_multiple_authors` override —
  if a site never wired up the per-post selection, the byline silently
  fell through to `/author/USERNAME/`, even when the team CPT post had a
  Linked WordPress User set. The author-box already had this fallback
  (`get_team_member_for_user()`); now the byline filter shares it. Posts
  authored by a WP user whose linked team page is published will have
  their `rel="author"`, `get_the_author_meta('url')`, and display-name
  byline rewritten to the staff page — no per-post setup needed.
  Verified on Red Seal: Kael's posts now byline-link to
  `/staff-member/kael-campbell/` and the author box renders his team-page
  featured image instead of a Gravatar mystery-man placeholder.
- **Setup-completion check now sees the v2.0 team-CPT flags.** The
  "Leadership / Founders" row in the dashboard checklist was driven
  by the legacy `bw_schema_founders` option that v2.0 stopped writing
  to. Sites running the modern flow (founders flagged via
  `_bw_schema_is_leader` post-meta on the team CPT) showed "Missing"
  despite having the data. The check now accepts either signal —
  legacy option OR at least one published team-CPT post with
  `_bw_schema_is_leader = 1`. A new sibling row tracks the same
  thing for authors (`_bw_schema_is_author`).
- **Renderer now honors the bare `none` value.** The new "Don't emit"
  option saves as the string `"none"` (no colon). The renderer's
  skip-this-post test recognized `'none:None'`, `'no_schema'`, and
  `'none:*'` — adding bare `'none'` so the new wizard / settings UI
  works end-to-end.
- **Founding date repopulates for year-only legacy values.** Older
  installs may have stored just the year (e.g. `"2005"`); the Settings
  page now normalizes to `YYYY-01-01` on read so the HTML5 date input
  isn't silently blank.

## [2.3.0] - 2026-06-05

### Added (Post FAQ module)
- **New module: Post FAQ.** Attach a question-and-answer list to any
  supported post type via a sortable metabox on the post-edit screen,
  rendered on the front-end as an inline FAQ section accompanied by
  FAQPage JSON-LD. Pattern adapted from the stackd-consulting child-theme
  implementation, generalized to work without TotalWP.
- **Configurable insertion point.** Settings dropdown chooses how the
  FAQ slots into the page:
  - **After the post content** — uses the `the_content` filter, works
    on virtually every theme. Default.
  - **Kadence: after content, before comments** — hooks
    `kadence_single_after_content` for tight slotting under the body.
  - **Kadence: after the author byline / title area** — hooks
    `kadence_single_after_entry_header`.
  - **Genesis: after the entry** (`genesis_after_entry`, priority 12).
  - **Astra: after the entry** (`astra_entry_after`).
  - **GeneratePress: after content** (`generate_after_content`).
  - **Custom hook** — text input for any action hook name on themes
    that aren't pre-supported.
  - **Shortcode only** — no auto-insertion. Use `[bw_schema_faq]`
    inside the post body to place manually.
- **Shortcode** `[bw_schema_faq]` (current post) or
  `[bw_schema_faq post_id="123"]`. Always available, even when
  auto-insertion is set to shortcode-only. Idempotent guard prevents
  double-rendering if both the shortcode and the auto-insert match.
- **Per-post overrides** in the metabox:
  - Heading (falls back to the global default)
  - Accordion mode (inherit / on / off)
  - Hide on this post (overrides the global enable)
- **Global settings** in Settings → AI Schema Pro → Settings:
  - Enable Post FAQ
  - Post types
  - Default heading
  - Default accordion behavior (one-open-at-a-time vs all-expanded)
  - Insertion point + custom-hook input
- **Minimal CSS.** Renders inline, uses `currentColor` and `em` units
  so it inherits typography and link color from the surrounding theme.
  No external stylesheet, no jQuery on the front-end — accordion is
  vanilla JS, idempotent, no dependencies.
- **Storage keys** (post meta): `_bw_schema_post_faq_items`,
  `_bw_schema_post_faq_heading`, `_bw_schema_post_faq_accordion`,
  `_bw_schema_post_faq_disable`. Settings option:
  `bw_schema_post_faq_settings`.
- Independent of the existing `BW_Schema_FAQ` schema class — that one
  generates FAQPage schema for users who manually set a post's schema
  type to FAQPage and write into `_bw_schema_faq_items`. The new module
  has its own data store and emits its own inline JSON-LD so a regular
  Article-schema'd post can carry an FAQPage block as a parallel,
  visible section without the two systems fighting.

### Added (Post FAQ — universal CSS-selector insertion + heading styles)
- **New insertion mode: "Insert at an HTML element (CSS selector)".**
  For themes and page builders that don't expose a clean action hook
  (Elementor, Bricks, Divi, WPBakery, etc., plus any theme we
  haven't seen yet). The admin enters a CSS selector and a position
  (after / before the element, or inside at the start/end). On the
  front-end, the FAQ is server-rendered inside a hidden `<div hidden>`
  container in the footer; a tiny vanilla-JS snippet locates the
  selector at DOMContentLoaded and moves the FAQ next to it via
  `insertAdjacentElement`. If the selector doesn't match, the FAQ
  stays hidden (better silent than wrong-place). The FAQPage JSON-LD
  is inside the hidden container too, but JSON-LD is DOM-parsed
  regardless of CSS display state so schema validators still see it.
- **Zero JS cost when not in selector mode.** The DOM-injection
  snippet is only emitted when "Insert at an HTML element" is the
  active insertion mode. Other modes (`the_content`, Kadence, etc.)
  emit no extra JS at all.
- **New "Heading color" and "Heading size" settings.** Both optional.
  Heading color is gated behind an "Override the theme color"
  checkbox so the default behavior (inherit from theme) is preserved
  unless the admin explicitly opts in. Heading size is a preset
  dropdown (Inherit / 0.9em / 1em / 1.1em / 1.25em / 1.5em / 2em).
  When set, both are emitted as inline `style="..."` on the section's
  H2 only.
- Reordered the insertion-mode dropdown so "Custom action hook" is
  clearly labeled as advanced and sits next to the new selector
  option. Renamed from "Custom hook" → "Custom action hook" to make
  its scope explicit (only `add_action` hooks; filters and shortcode
  interception use the CSS-selector path instead).

## [2.2.3] - 2026-06-03

### Fixed (description missing from rendered Organization schema)
- **Renderer now fills `description` (and `slogan`) onto a legacy
  `bw_schema_organization_override` JSON before returning it on the
  homepage path.** Previously the renderer at
  `class-bw-schema-renderer.php:220` did an early `return $decoded;`
  on the override, completely bypassing the code path that adds
  description from `bw_schema_org_description`. On any site where
  `bw_schema_organization_override` was populated by an older plugin
  version (or carried forward by the bwgeo → bw-plugins import), the
  description field saved via the Setup Wizard would never appear in
  the rendered JSON-LD — even though the wizard's textarea would
  show it (because the textarea reads from the saved option, which
  was correct; it's the renderer that was ignoring it). The override
  still wins per-field — the patch only fills fields the override
  doesn't already have. Closes the gap without requiring users to
  re-run setup (re-running Step 7 deletes the override entirely,
  which is the alternative if a site prefers a clean state).
- Verified on aismartventures.com: rendered Organization schema had
  `name`, `url`, `telephone`, `email`, `logo`, `sameAs`, `foundingDate`,
  `numberOfEmployees`, `award`, `hasCredential`, `areaServed`,
  `contactPoint`, `founder`, `employee` — but no `description`. The
  wizard's Step 1 textarea clearly showed a real org bio, and
  `bw_schema_org_description` was saved correctly. The early-return
  on the override path was the cause.

### Added (Description on the regular settings page)
- **Description textarea on Settings → AI Schema Pro → Settings →
  Advanced Organization Details.** Saves to the same
  `bw_schema_org_description` option the Setup Wizard's Step 1 writes,
  so admins can edit it without walking through the 7-step wizard. The
  wizard and the settings page stay in lockstep — neither overrides
  the other, both read/write the same option.

## [2.2.2] - 2026-06-01

### Added (Team Survey — inline setup UI)
- When no team post type is mapped yet, the Team Surveys page now
  shows a setup screen inline instead of pushing the admin off to
  the main AI Schema Pro settings page. The screen offers:
  - **Likely matches** — scored suggestions based on common team-CPT
    slugs and labels (`team`, `team_member`, `our-team`, `staff`,
    `people`, `employee`, `crew`, etc.). Each candidate shows the
    label, the slug, and the published post count so admins can
    confirm at a glance.
  - **Fallback dropdown** of all non-core public CPTs on the site,
    alphabetical. Overrides the radio choice if both are selected.
  - **"Connect this post type" button** — writes the chosen slug
    to the existing `bw_schema_team_post_type` option (the same
    one the main AI Schema Pro settings page writes to). Admin can
    still change it from the main settings page later.
- New **"Proceed without a team post type" button** with a warning
  notice explaining the tradeoff: submissions can still be
  collected and reviewed (they all wait in the holding queue),
  but the publish step has no destination until a CPT is
  connected. Sets a sticky `bw_schema_survey_proceed_without_team_cpt`
  option so the workflow unlocks.
- When the admin is in "proceed without" mode, a persistent yellow
  banner appears at the top of the Team Surveys queue with a
  "Connect one now" link that takes them back to the setup screen
  (via a nonced GET). Manual entry (`Add team info manually`)
  shows a friendlier "set up a team post type to use this" notice
  rather than a broken form.

### Added (internal)
- `BW_Schema_Survey::is_setup_complete()` — true when a team CPT is
  mapped OR proceed-without is set. Now used as the gate everywhere
  the workflow needed `BW_Schema_Team_Member::get_team_post_type()`,
  including `is_open()`.
- `BW_Schema_Survey::suggest_team_post_types()` /
  `all_eligible_post_types()` /
  `set_team_post_type()` /
  `set_proceed_without_team_cpt()` /
  `is_proceeding_without_cpt()` — public helpers for the setup UI
  and any future "fix it" actions elsewhere in the admin.

## [2.2.1] - 2026-06-01

### Added (Team Survey — reset controls for testing)
- New "Reset" section at the bottom of the Team Survey settings page
  with two actions, both gated by JS confirm dialogs:
  - **Delete all submissions** — empties the
    `bw_schema_survey_responses` table. Survey window, URL, and
    settings stay as-is. Use between test runs to re-submit without
    starting from scratch.
  - **Reset everything** — closes the window, deletes all
    submissions, rotates the URL token (invalidating the current
    public link immediately), and restores every Team Survey
    setting (slug, intro HTML, notification email + toggle, grant
    Editors toggle) to its default. Caps are re-synced so the
    Editor role's `bw_schema_moderate_team_surveys` cap reflects
    the reset state. Schema data already published onto team
    posts is intentionally not touched — admins can edit those
    posts directly to clear.
- New helper `BW_Schema_Survey_Store::truncate_responses()` for the
  single SQL DELETE used by both reset actions. Returns rows
  deleted so the success notice can show the count.

## [2.2.0] - 2026-05-28

### Fixed (recurring "wrong byline link / 404 author" bug on Kadence sites)
- **Byline link `href` is now correctly overridden server-side** when a
  post has a team-member author override (e.g. risealliance.com's
  /resource/ posts displayed by Robert DiNozzi).
  Previously: `the_author` filter overrode the *display name* only;
  the link `href` still pointed at the WP author archive
  `/author/{post_author}/`. A wp_footer JavaScript rewriter masked
  this by replacing the visible text after page load, so the byline
  *looked* right but the link still went to the wrong place — and
  bots / paste-the-URL users hit 404s. Now three filters work
  together server-side:
  - `get_the_author_display_name` -> overrides byline name
  - `get_the_author_user_url` -> overrides Kadence's preferred href
    source (`get_the_author_meta('url')`). This is the actual filter
    Kadence prefers — note WP renames the `url` field to `user_url`
    internally, so the filter is `get_the_author_user_url`, not
    `get_the_author_url`.
  - `author_link` -> overrides the byline href when themes use
    `get_author_posts_url()` instead (fallback)
  All three are scoped to: singular-post context, same author as the
  queried post. Comments, widgets, user-profile reads elsewhere are
  untouched. The `the_author` filter and the JS rewriter remain in
  place as belt-and-braces.
- **`/author/USERNAME/` redirect has a fallback now.** The
  `_bw_schema_linked_user` meta on the team page (the configuration
  pointer that historically drifts due to UpdraftPlus restores,
  "Rewrite & Republish," CSV reimports, etc.) is no longer the only
  way to resolve the redirect target. When that meta is empty, the
  plugin now looks at recent posts authored by the visited WP user
  for one with a team-member schema author override, and redirects
  to that team page. This rescues the common drift case without
  requiring the user to keep the meta synced manually.
- **Admin notice on the team CPT edit screen** when this team page
  is referenced as a schema author on at least one post but has no
  Linked WordPress User set. Loud yellow banner with copy that
  points at the field to fix. Prevents the bad state from being
  silent — even if the byline link works (via the new
  `author_link` filter), this still flags the config gap so the
  /author/X/ redirect stays solid.

### Added (manual admin intake — Path 1)
- **New admin page: Add team info manually.** Reachable from the
  "Add team info manually" button at the top of the Team Surveys
  queue, and from the empty-queue state. Renders the same survey
  questions in admin context, lets admin pick an existing team member
  or create a new draft, and on save creates a response with
  `status=structured` (skips moderator triage — admin is trusted) with
  the structured payload pre-filled by the deterministic raw→structured
  parser. Lands the admin on the Schema Review tab so they can refine
  before publishing.
- Empty-queue state now surfaces both intake paths (manual entry,
  public survey link) so first-time users know how to start.

### Fixed (rewrite registration — 404 on the survey link)
- **`register_rewrite()` is now called directly from
  `BW_Schema_Survey::init()` instead of being hooked onto `init` at the
  same priority as the caller.** `WP_Hook`'s priority foreach iterates
  an array copy of the bucket, so a same-priority callback added during
  iteration never fires on the current request — meaning the rule was
  missing from `extra_rules_top` on every request, and a follow-up
  `flush_rewrite_rules()` (from a permalink resave, etc.) would wipe
  the activation-time rule out of the DB-stored `rewrite_rules` option.
- **Added one-shot version-tracked auto-flush** via
  `BW_Schema_Survey::maybe_flush_rewrites()`, gated on the
  `bw_schema_survey_rewrite_version` option. Handles file-only deploys
  (no activation cycle) so the rule lands in the DB on the next request
  without needing the user to deactivate/reactivate or visit Settings →
  Permalinks. Subsequent requests are a single option lookup; the
  flush runs at most once per deploy.
- Settings handlers for slug change and token rotation now also
  re-register the rewrite, flush, and bump the version option in one
  shot — so URL rotation invalidates the old URL immediately.

### Added (Team Survey workflow — Phase 1)
- **New module: Team Survey workflow** for collecting team-member info
  in-WordPress instead of via Google Forms. Replaces the out-of-band
  intake process with a four-stage pipeline: public survey → moderator
  triage → schema-shaped review → publish to live Person-schema fields.
  See `docs/SPEC-team-survey.md` for the full spec.
- Public survey page at `/{slug}/{token}/` (default
  `/bw-team-survey/<token>/`), served via a plugin-owned rewrite rule.
  Robots-blocked (`<meta robots noindex>` + `X-Robots-Tag` header) so
  the rotatable token never lands in any index.
- Time-limited open window: admin opens the survey for up to 7 days at
  a time, with a "Renew" action for up to another 7 days. Survey is
  closed by default until explicitly opened.
- New DB table `{prefix}bw_schema_survey_responses` for staging
  responses (raw + structured payloads, status, moderator notes).
  Installed via dbDelta on activation/upgrade; schema version tracked
  in `bw_schema_survey_db_version`.
- New capability `bw_schema_moderate_team_surveys`. Granted to
  administrators by default; settings toggle to also grant to editors.
- Admin pages under the existing AI Schema Pro options menu: queue,
  per-submission detail (triage / schema review / publish tabs),
  settings (open/renew, regenerate URL, notifications, capability
  grant).
- Holding queue for "I'm not listed" submissions — moderator
  graduates them to a team CPT draft before they enter the workflow.
- Anti-abuse: honeypot field, 3-second time-trap, IP rate-limit
  (5/hour, same pattern as `BW_Schema_Security::check_rate_limit()`),
  WP nonce on the form. No CAPTCHA — moderation gating is the primary
  defense.
- Precondition gate: workflow is hidden / closed until a team post
  type is mapped in the existing AI Schema Pro settings.

### Notes
- AI features (mosiah-backed research and parse assists) are
  intentionally **out of scope for 2.2.0** — Phase 2 / 2.3.0+. The
  data model is AI-agnostic so Phase 2 layers on without schema
  changes. See `docs/SPEC-team-survey.md` § Phase 2 for the planned
  contract.
- All new code lives under the grandfathered `bw_schema_*` prefix per
  `CLAUDE.md` § Pre-existing-plugin exception. The 3.0.0 rename will
  migrate the new keys alongside the existing ones.

## [2.1.5] - 2026-05-27

### Fixed (performance)
- **CRITICAL: Removed `BW_Schema_Team_Member::remove_remaining_schemas()`** and its
  `add_action( 'wp_head', ..., 0 )` registration in
  `includes/class-bw-schema-team-member.php`. On team-member pages it ran every
  `wp_head` and iterated the entire `$wp_filter['wp_head']` table using fragile
  `strpos()` string-matching to remove "schema-looking" hook keys. Both expensive
  and dangerous (could match legitimate non-schema hooks). The job it was trying
  to do — disabling other plugins' schema output — is already handled correctly
  by `BW_Schema_Core::disable_conflicting_schema()`, which uses named filter
  hooks driven by the `bw_schema_disable_sources` setting. The
  `remove_all_actions( 'wp_head', 60 )` call in `disable_conflicting_schemas()`
  is retained — that's the Yoast-priority cleanup, not the catch-all iteration.

- **HIGH: Added transient fallback to `BW_Schema_Cache`** in
  `includes/class-bw-schema-cache.php` for hosts without a persistent object
  cache (Flywheel, most shared hosts). Previously `get`/`set`/`delete` used only
  `wp_cache_*`, which defaults to request-scoped on those hosts — meaning the
  cache was a no-op and schema regenerated on every page load. The class now
  routes through transients (DB-backed, persistent across requests) when
  `wp_using_ext_object_cache()` returns false; sites with a real object cache
  continue using `wp_cache_*`. `clear_all()` nukes the plugin's own transients
  via a prefix-scoped DELETE on the options table (no other plugin's data is
  touched). Cache key prefix is `bw_schema_` so all keys are namespaced.

### Changed (performance)
- **Consolidated 6 plugin-owned `init` hooks into one wrapper** in
  `bw-ai-schema-pro.php`. Previously each module (textdomain, Cache, Hooks,
  Security, Author_Override, Team_Member) registered its own
  `add_action( 'init', ... )`; WP walks `$wp_filter['init']` on every request,
  so cutting 6 entries down to 1 (named `init_default_priority_modules`) saves
  per-request work. **The priority-1 (`maybe_migrate_options`) and priority-5
  (`disable_conflicting_schema`) registrations remain separate** — they need
  their explicit priorities.

- **`BW_Schema_Renderer` is now a singleton** (via `get_instance()`). The
  renderer is stateless — `render()` reads context from globals/options/post
  meta each call — but was constructed fresh on every `wp_head` and every
  schema-preview AJAX request. Both call sites in `bw-ai-schema-pro.php` now
  reuse the shared instance.

### Security
- `maybe_redirect_to_setup()`: gate the `?skip_setup=1` self-completion of the setup
  wizard behind `current_user_can( 'manage_options' )`. Previously any logged-in user
  reaching wp-admin (e.g. a subscriber on their own profile page) could trip the
  "setup complete" flag. Impact was minor (only suppressed the wizard redirect for
  admins) but it was the single nonce/cap gap surfaced by the 2026-05-06 security
  audit. See `docs/SESSION-LOG.md` for the audit summary.

## [2.1.4] - 2026-05-06

### Changed
- **Update server moved.** Plugin now checks for updates from
  `https://plugins.bowden.works` (the bw-update-server in the bw-plugins framework)
  instead of the legacy `https://bwgeo.demoing.info/wp-content/uploads/plugin-updates/`.
  No functional change for site administrators — updates continue to arrive automatically;
  only the source server has changed.
- Plugin headers updated to declare `Requires at least: 6.0`, `Requires PHP: 7.4`, and
  `Update URI` per the BW plugins framework standard. Plugin URI and Author URI now
  point at `bowden.works` rather than the legacy `bowdenworks.com`.
- License field standardized to `GPL-2.0-or-later`.

### Added
- `BW_AI_SCHEMA_PRO_VERSION` constant (mirror of `BW_SCHEMA_VERSION`) so BW release
  tooling can introspect the version. Internal code continues to use `BW_SCHEMA_VERSION`.
- ABSPATH guard added to `includes/class-bw-schema-author-box.php`.
- Imported into the bw-plugins development framework. Source moved from
  `/srv/apps/bwgeo/wp-content/plugins/bw-ai-schema-pro/` to
  `/srv/apps/bw-plugins/wp-content/plugins/bw-ai-schema-pro/`. See `docs/ROADMAP.md`
  for the follow-up rename + prefix-conformance plan.

## [2.1.3] - pre-2026-05-06

History prior to bw-plugins import is not captured here. The plugin's prior changelog
(if any) lived in `/srv/apps/bwgeo/`. Future entries follow the Keep a Changelog format.
