# BW AI Schema Pro — Config Export / Import

**Status:** Built 2026-07-22 · Version 2.6.0
**Page:** Settings → AI Schema Pro → **Export / Import** (`bw-ai-schema-tools`)

## Why

A site's schema configuration is spread across ~51 options and takes a long pass
through the Setup Wizard and nine Settings tabs to build. This module lets the
whole thing be exported as JSON and imported back — as a backup before a big
change, to move settings between sites, or to keep alongside the project.

**Sample presets were built and then removed** before release (2026-07-22). A
one-click button that replaces an entire site configuration read as too dangerous
to sit in the admin of a plugin installed on live client sites — the risk of
someone loading "Resort" onto a real client outweighed the demo convenience. The
seven preset files are parked at
`/srv/apps/bw-plugins/.removed-presets-2026-07-22/` if that decision is ever
revisited; nothing in the shipped plugin refers to them.

## The tier map is the design

The plugin owns ~51 `bw_schema_*` options and they are not equally portable. The
map lives in `BW_Schema_Config_IO::get_tier_map()` and drives everything.

| Tier | Behaviour |
|---|---|
| **config** | Exported and imported by default. The portable settings. |
| **site_specific** | `bw_schema_page_mappings` (post IDs), `bw_schema_team_post_type`, `bw_schema_room_post_type`, `bw_schema_plugin_default_author` (user ID). Exported in their own block; import is opt-in. |
| **authors** | `bw_schema_custom_authors`, `bw_schema_external_authors`, `bw_schema_founders` — real people's names, emails, bios. Export is opt-in. |
| **never** | `bw_schema_version`, `bw_schema_settings_version`, `bw_schema_setup_complete`, `bw_schema_security_log`. Excluded both ways. |

`bw_schema_taxonomy_mappings` is **config**, not site-specific — it is keyed by
post type / taxonomy / **term slug**, not term IDs
(`class-bw-schema-admin.php`, taxonomy mapping save handler).

`bw_schema_founders` is **authors**, not config, despite looking like settings: it
is the legacy pre-team-CPT option and holds real people's names.

## The sectioned-settings mirror

`bw_schema_settings` duplicates values also held in top-level options
(`settings['content']` holds `team_post_type`, `room_post_type`, `page_mappings`,
`post_type_defaults`), and `BW_Schema_Core::get_settings()` reads the **mirror
first**. A stale mirror would keep serving the old team CPT after an import.

`reconcile_settings_mirror()` runs after every import / undo and
**converges both ways**: a non-empty top-level value wins; otherwise a non-empty
mirror value is backfilled up to the top level; if both are empty, nothing
happens. One-directional copying was the first implementation and was wrong — on
a site where only the mirror was ever populated it would have wiped the team CPT,
because the mirror is the value `get_settings()` prefers.

## File format

```json
{
  "_meta": {
    "format": "bw-ai-schema-pro/config",
    "format_version": 1,
    "plugin_version": "2.6.0",
    "exported_at": "2026-07-22T14:03:00+00:00",
    "site_url": "https://example.com",
    "includes": ["config", "site_specific"]
  },
  "config":        { "bw_schema_organization": { … } },
  "site_specific": { "bw_schema_page_mappings": { … } },
  "authors":       { … }
}
```

Import **refuses** an unknown
`format` or a `format_version` above what the build understands, and **warns**
(without refusing) when the file came from a newer plugin version.

Options that have never been set are omitted from an export rather than written
as `false` — importing a `false` would otherwise overwrite a real value on the
destination site.

## Safety model

1. **Allowlist.** Import only writes keys present in the tier map, and only when
   the key appears in the block it belongs to. A crafted file cannot set
   `active_plugins` or any other WordPress option. Verified by test.
2. **Per-key sanitization**, delegating to the modules' own sanitizers where they
   exist (`BW_Schema_Location::sanitize_locations()` / `::sanitize_areas()`), so
   there is one definition of a valid location. URLs go through `esc_url_raw()`,
   which drops `javascript:`.
3. **Automatic snapshot** into `bw_schema_config_backup` before every import
   and undo, powering one-click Undo. Undo also **deletes** keys that did
   not exist in the snapshot, so it leaves no debris.
4. **Nonce + `manage_options`** on both actions. The export download runs on
   `admin_post_` so it can set headers before output.
5. **The uploaded file is never stored.** It is read straight from the PHP upload
   tmp path, so a config file never lands in `uploads/` where it could be fetched
   over HTTP. Files over 2 MB are rejected — an export is a few KB.

## Bug found and fixed alongside

`BW_Schema_Org_Builder::is_local_business_type()` did not list **`Attorney`**,
even though the Organization tab's own type picker offers it. Every law-firm site
was therefore silently emitting no `geo`, `openingHoursSpecification` or
`priceRange` — the same class of bug as the lodging subtypes fixed in 2.4.0.
`Warehouse`, `HardwareStore` and `AutoPartsStore` (offered by the Locations
module's business-type picker) were added at the same time.

## Non-goals

- Per-post meta is not exported — this is a *settings* export, not a content
  migration.
- One undo slot, not a history. Download a backup before anything irreversible.
