=== PBJ Studio Sync ===
Contributors: pbj
Tags: studio, sync, deploy, backup, sqlite, mysql, migration, staging
Requires at least: 6.3
Tested up to: 6.5
Requires PHP: 7.4
Stable tag: 3.3.0
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html

One-way push from WordPress Studio (SQLite) to a live site (MySQL), with backup-to-file on live and a backup importer on Studio.

== Description ==

Install the same plugin on both your local **WordPress Studio** site and your **live** production site. The plugin auto-detects which environment it’s running in:

* On **Studio (SQLite)** you get a "Push to live" button and a backup importer.
* On **Live (MySQL)** you get the receiving REST endpoints and a "Generate backup" button.

Because content moves over the WordPress REST API as native WP objects (posts, terms, meta, files), the SQLite ↔ MySQL syntax differences are handled by WordPress itself — there's no raw SQL translation. Backups still ship a MySQL `.sql` dump alongside a JSON snapshot, so the same `.zip` can either be restored on a MySQL host or imported into Studio.

= What gets synced =

* Posts, pages, custom post types (with post meta + taxonomy terms)
* Media library (uploads/ folder + attachment posts; chunked upload)
* Active theme files
* Plugin files — opt-in, with a per-plugin exclude list
* Navigation menus — menu items + theme location assignments, with ID remapping
* Users / `wp_options` — opt-in, off by default

= Backup format =

A single `.zip` containing:

* `manifest.json` — environment metadata
* `mysql.sql` — full MySQL-compatible dump
* `data/<table>.json` — per-table JSON snapshot (used by the Studio importer)
* `uploads/` — (optional) the live site's upload folder

= Authentication =

Standard WordPress **Application Passwords**. Generate one in your live site (Users → Profile → Application Passwords), paste it into the Studio settings page along with the live username and URL.

== Installation ==

1. Install and activate the plugin on **both** your Studio site and your live site.
2. On the **live** site: visit Users → Profile and create an Application Password named e.g. "Studio Sync". Copy it including the spaces.
3. On the **Studio** site: open *Studio Sync → Settings* and fill in the live URL, username, and the Application Password. Save.
4. From *Studio Sync → Dashboard* click **Test connection**, then **Push to live now**.

== Frequently Asked Questions ==

= Will pushing wipe my live site? =

No. The default overwrite strategy matches by `post_type` + `post_name` (slug). Existing live posts with matching slugs are updated; everything else is left alone. There is no "delete on live" mode in v1.

= Why doesn't it sync the database directly between SQLite and MySQL? =

Direct SQL translation between SQLite and MySQL is fragile. By writing through the WordPress API on the receiving side, we let WordPress + each environment’s `$wpdb` shim handle quoting, types, and schema differences correctly.

= Do comments sync? =

No — by design. Comments are created by visitors on the live site, so live is their source of truth; a one-way Studio push would overwrite or resurrect them. Form submissions stay on the site that received them for the same reason.

= Should I sync users? =

Bulk user sync is not advised: live sites accumulate their own real accounts (customers, members) that Studio never sees. Leave the Users type off and use the Users page's targeted tools instead — push a single user to live when needed, and manage live accounts on the live site.

= Are plugins synced by default? =

No. The "Plugin files" toggle is off by default. When enabled, you can still exclude individual plugins under *Settings → Plugin excludes*. The sync plugin always excludes itself.

= Where is the staging area? =

Inside `wp-content/uploads/pbj-sync/`. The directory is protected with an `.htaccess` deny rule and an empty `index.html`. It’s removed automatically on plugin uninstall.

== Changelog ==

= 3.3.0 =
* **Relative URL mode (new default).** Pushed content now rewrites Studio-origin URLs to ROOT-RELATIVE paths (/page/, /wp-content/uploads/…) instead of the absolute live URL — in every payload (posts, meta, options, menus) and in the commit-time database sweep. Content stops caring which host serves it: local port changes and future domain moves can never break links or images again. Bare host names without a scheme still map to the live host (they can't be expressed relatively). Setup → "URL handling" switches back to the pre-3.3.0 absolute behavior. Pre-3.3.0 pushers never send the new url_mode param, so a 3.3.0 receiver keeps sweeping absolute for them — fully wire-compatible both ways.

= 3.2.1 =
* Fix: the wp_navigation id-remap write now passes slashed data to wp_update_post. Unslashed, WordPress strips backslashes from the content — which corrupts page-builder layout JSON (unescaped quotes) and makes the layout render empty.

= 3.2.0 =
* **Theme parity.** The Theme type now syncs WHICH theme is active, not just its files: switch themes in Studio and live switches on the next Dashboard push (a brand-new theme switches only after its files promote at commit — never mid-transfer). New opt-in checkbox "Delete themes on live that Studio no longer has" mirror-deletes stale theme folders; the live-active theme and its parent are never deleted.
* **Block-editor navigation menus (wp_navigation) now sync.** Navigation posts travel with Content, and every navigation-link/submenu target is re-pointed by slug on live (IDs never survive a sync). Classic menus continue to use the dedicated Menus type.
* **Users page: guidance + targeted tools.** Bulk user sync is now explicitly documented as NOT advised (live sites own their real users). Instead: a per-user "Push to live" button sends exactly one account through the same protected receiver, and a read-only "Live users" viewer lists live accounts (logins/emails/roles — never passwords) with a link to manage them on the live dashboard.
* **Comments documented.** Comments never sync — by design (live visitors create them; a one-way push would clobber them). The Content page and this readme now say so explicitly.
* Old receivers ignore the new fields/routes; theme switch and live-user listing degrade gracefully against pre-3.2.0 receivers.

= 3.1.0 =
* **Plugin deletion sync (opt-in).** New checkbox under *Sync by Type → Plugins*: "Delete plugins on live that Studio no longer has." When enabled, a Dashboard push sends the source's installed-plugin manifest with the plugin-states call and the receiver mirror-deletes live plugin folders missing from it (deactivate first, then delete). Protections: the sync plugin itself, every excluded (unmanaged) plugin, single-file drop-ins, and folders staged this session are never touched. Off by default; targeted pushes (Sync by Type, Compare & Sync, Auto Sync) never delete regardless of the setting. The push summary and both logs report what was removed (`deletions: … · N plugins`).
* Old receivers ignore the new fields; a 3.1.0 receiver behaves additively for old pushers — 3.0 ↔ 3.1 pushes stay compatible either way.

= 3.0.0 =
* **Admin rebuilt as a guided, per-sync-type UI.** Every page now walks you through what it does — nothing is left to guessing:
* **New "Sync by Type" tab** — one sub-page per sync type (Content, Media, Menus, Theme, Plugins, Site Settings, Users, Deletions). Each page explains in plain English exactly what that type moves and how it behaves, holds only that type's settings (savable on its own without touching the others), and has a "Push [type] to live now" button that syncs just that one type. The per-type push respects the plugin exclude list (Compare & Sync and Full Copy remain deliberate mirror tools that ignore it).
* **New "Full Copy" tab** — whole-site copies in one click, both directions. "Copy Studio → Live" pushes everything (full mode) and prunes, making live an exact copy; "Copy Live → Studio" pulls a fresh live backup and imports it here (content, media, settings, users) with a local wipe-first — no more generate-download-upload juggling. Both sit behind a typed confirmation phrase (COPY TO LIVE / COPY TO STUDIO) plus a final confirm, and every existing protection still applies (site URLs, operator login, app passwords, roles, unmanaged plugins are never touched).
* **Dashboard "How Studio Sync works" stepper** — three numbered steps (Connect → Choose → Push), each showing live status (connected? how many types enabled? pushed before?) and linking to the tab where it happens.
* **Settings tab is now "Setup"** — a numbered connection walkthrough (URL → user → Application Password → save → inline Test connection button), then sync-behavior defaults and auto-updates, each section saving independently.
* Compare & Sync gained a numbered how-to; the Logs tab now explains where receiving-side errors live. The tombstone queue moved to Sync by Type → Deletions.
* No push/receiver logic changed — this is an admin/UX release; all 2.x safety rails are intact and 2.x ↔ 3.0 pushes are compatible.

= 2.11.0 =
* **Page hierarchy now survives a push.** The posts payload carries each post's `parent_slug` + `parent_type` (numeric `post_parent` IDs never survive a sync), and the receiver resolves them to its own IDs. A child that arrives before its parent is recorded in the session's `pending-parents.json` and fixed at commit, once the whole batch is in — so brand-new page trees come across intact. An explicit top-level post clears any stale parent on live. Fixes child pages landing flat at top level (their URLs collapsed from `/parent/child/` to `/child/`).
* **Commit no longer times out on big promotes.** `/push/commit` triggers the live-side promote of every staged theme/plugin file; with a large theme (5,000+ files) that exceeded the old 30-second client timeout, and Studio reported "cURL error 28" for a commit that actually completed on live. The commit call now has its own 150-second timeout (still inside the 180s step envelope).
* New run totals/errors: `parents_fixed` count and `[post parent]` error entries surface the deferred fixups in the push summary.


= 2.10.0 =
* Added a License section in the plugin's settings where you enter your PBJ license key to unlock automatic updates. The plugin is fully functional without a key; a key covers up to 3 sites, and on pbj.tech itself no key is required.

= 2.9.6 =
* **Postmeta-only edits are now visible in the push summary.** The meta-dirty extra pass (2.9.2) already pushes posts whose only change since the last sync was postmeta — e.g. a rewritten SEO meta description, which does not bump the post's modified date — but the summary counted them under the normal posts total, so there was no way to confirm from the log that a meta-only edit had travelled. The summary now reports them explicitly: `N/M posts (new/upd, K meta-only)`. No behavior change to what gets synced; this is reporting only, and confirms that any content update other than users syncs on an incremental push.

= 2.9.5 =
* **New: uploads-path rewriting on options sync (both directions).** Options carrying absolute filesystem paths under the source site's uploads directory — e.g. PBJ License Manager's per-product `package_file` — are rewritten to the destination's uploads path instead of arriving as dead source-only paths. On push, the options stage rewrites Studio's basedir to live's (learned from the `/info` handshake, present since 2.9.4); on Auto Sync's download direction, live values applied into Studio are rewritten the other way. Plain and JSON-escaped forms are covered and PHP-serialized values are round-tripped, same rules as the 2.8.0 URL rewriting. Inactive (behavior unchanged) when the remote reports no basedir or the paths already match. Disable via the `pbj_sync_rewrite_paths` filter.

= 2.9.4 =
* **New: mu-plugins sync stage.** Must-use plugin files now push like theme/plugin files (staged during the run, promoted atomically at commit), gated by a "Must-use plugin files" toggle (default ON). Studio's own runtime pieces (`sqlite-database-integration`, `99-studio-loader.php`) plus directory chrome are always excluded on BOTH sides — never pushed, never touched (filter: `pbj_sync_muplugin_excludes`).
* **New: mu-plugins purge (opt-in mirror).** With "Purge mu-plugins the source no longer has" enabled, after the mu-plugins file pass Studio sends its top-level mu-plugins manifest and the live side deletes entries not in it — so retiring an mu-plugin on Studio retires it on live on the next push. OFF by default: many hosts install their own mu-plugins into sites, and a default-on purge would silently delete them. The exclude list above can never be purged, and the manifest is rebuilt from disk (not the mtime-filtered push list), so incremental runs can't purge unchanged files. Requires 2.9.4 on the live side.

= 2.9.3 =
* **Fixed: featured images survive the sync.** `_thumbnail_id` holds an attachment ID, which is reassigned on the receiving site — so a synced page's featured image pointed at the wrong (or no) attachment on live; pbj.tech's homepage lost its og:image exactly this way. The pusher now sends the thumbnail's uploads-relative file path alongside each post and the receiver re-resolves it to its own attachment ID (same portable-reference pattern as the 2.9.1 `page_on_front` fix). An unresolvable thumbnail is removed rather than left pointing at a random local attachment. Old sender + new receiver keeps the previous behavior.
* **Excluded per-environment plugin config from options sync.** Option names starting with `rsssl`, `really_simple_ssl`, `litespeed`, or `lscache` (filterable via `pbj_sync_unmanaged_option_prefixes`) are no longer pushed, pruned, or counted on Compare. Security/caching plugin config is inherently per-environment (file paths, cache state, license) — pushing Studio's Really Simple Security options onto live contaminated its firewall config with Studio paths, and live then answered every authenticated REST request with a LiteSpeed 503, breaking the sync's own authentication.

= 2.9.2 =
* **Fixed: the plugin-states stage now respects the "Plugins to exclude" list.** Excluded plugins' files were already kept out of pushes (per-environment config — caching, security), but the activation-state mirror still stomped them: a plugin active only on live (LiteSpeed Cache, Really Simple Security Pro on pbj.tech) was deactivated to match Studio, silently dropping live's page cache and security headers. Excluded plugins are now fully unmanaged — never deactivated to match Studio, never force-activated. (Requires 2.9.2 on the live side; an older receiver ignores the new parameter and keeps the old behavior.)
* **Fixed: incremental pushes no longer skip posts whose only change was postmeta.** The incremental filter matches `post_modified_gmt` against the watermark, but meta writes (SEO title/description, featured image, builder meta) don't bump `post_modified` — so a page whose only edit was, say, a rewritten meta description stayed stale on live until a Force Full push. Studio now records post IDs from every meaningful `added/updated/deleted_post_meta` event in a dirty map (`pbj_sync_dirty_posts`); incremental runs push those posts in an extra pass after the normal per-type passes, and the map is pruned only when the watermark advances (same zero-errors discipline as 2.9.0). If the map overflows (mass meta import), the posts stage falls back to a full pass rather than trusting an incomplete map. Housekeeping keys (`_edit_lock`, transients, the plugin's own meta) are ignored.

= 2.9.1 =
* **Fixed: brand-new plugins pushed this run are now activated on live.** Plugin files stage until commit, but the plugin-states stage ran before commit — a plugin whose files arrived in the same push wasn't visible on disk yet, was silently dropped from the desired-active list, and the push still reported zero errors (this is how a freshly-shipped plugin landed on live deactivated). Such plugins are now recorded during the plugin-states stage and activated at commit, right after the staged files are promoted into place. Activation failures — including plugins the live site can't see at all — now surface in the Studio run's error list instead of vanishing.
* **Fixed: syncing `page_on_front` / `page_for_posts` no longer 404s the live homepage.** These options hold page IDs, which are not stable across sites (posts match by slug; live assigns its own IDs), so pushing the raw Studio ID could point live's front page at the wrong page or at nothing. The pusher now sends the page's slug alongside and the receiver resolves it to the live site's own page ID; an unresolvable slug skips the write instead of installing a broken ID. (Old pusher/receiver combinations keep the previous raw-value behavior.)
* **Post-push front-end check now also fails on error pages.** The commit-time verification of live's home, posts page, and recent pages previously only scanned for leaked Studio URLs; a homepage answering 404 passed silently. Any checked page returning HTTP 400+ now lands in the run's error list.

= 2.9.0 =
* **Hardening release — no silent losses, plus Studio native-PHP-runtime readiness (Studio 1.10+/1.12+).**
* **Fixed: a push with errors no longer advances the incremental watermark.** A run whose batches failed (posts refused, files dropped, promotes failed) used to still "commit" and move the watermark, so the next incremental push silently skipped exactly the content that never arrived. Runs with errors now finish as `done_with_errors` (amber in the UI, WARN in the log), keep the old watermark, and the next push retries the affected content.
* **Fixed: live-side promote errors now surface in the Studio UI.** Staged theme/plugin files that failed to move into place at commit were only logged on the live site — Studio reported a clean success. They now appear in the run's error list as `[live promote] …`.
* **Fixed: chunked uploads are integrity-checked and retried.** Each chunk retries up to 3× with backoff before the file is failed; the receiver now requires every chunk index 0..N-1 (a duplicate + a missing chunk used to assemble a corrupt file) and verifies the assembled byte size against the source (`file_size` field; older pushers skip the check). On mismatch the file is rejected and the previous version restored.
* **Fixed: failed deletions are retried.** Tombstones the live side failed to apply are kept locally for the next push instead of being cleared with the successful ones.
* **Fixed: a crashed run no longer blocks forever.** A `running` state untouched for 10+ minutes is auto-marked failed and superseded when you start a new push (previously only a manual Cancel cleared it).
* **Fixed: Auto Sync no longer refreshes the options baseline after a failed upload** — un-uploaded settings changes stay visible to the next Auto Sync instead of being masked.
* **Fixed: import with "wipe first" validates the whole backup before wiping.** A malformed `data/*.json` used to be discovered after truncating the local tables, leaving Studio half-empty with no rollback. The import now aborts before touching anything.
* **Fixed: importing a live backup no longer injects live-only options into Studio.** The options restore now uses the same unmanaged-option predicate as push/prune/compare (previously only `siteurl`/`home` were skipped, so live's `active_plugins`, theme selection, cron, application-password rows and prefix-named role definitions imported straight into Studio).
* **Studio native PHP runtime readiness.** Studio 1.12.0 makes the native PHP runtime (real PHP binaries instead of PHP-WASM) a per-site option. Two edges handled: (1) `set_time_limit()` is enforced under native PHP (it was a no-op under WASM) — the push step now allows 180s while every HTTP call is capped well below it (30s JSON / 45s multipart via the new shared, filterable `pbj_sync_http_args` policy); (2) TLS now goes through real curl + Studio's bundled CA store — a new "Verify TLS certificates" setting (default ON) plus a targeted "cURL error 60" explanation replace the old bare failure.
* **Better errors.** Non-JSON responses now include the URL, HTTP code, and a body snippet (the classic security-plugin/maintenance-page HTML failure is now diagnosable from the Studio log alone).
* **Better browser flow.** The polling loop retries transient errors max 5× with exponential backoff (it used to retry a dead server forever), detects expired sessions/nonces with a clear "reload to resume" message, offers resume-or-cancel when a step fails (the server-side run stays resumable), and the Cancel button handles its own request failing. The progress bar now mirrors the real stage order including plugin_states/options/users/prune/tombstones (it used to drop to 0% and move backwards mid-run) and never decreases.
* **Housekeeping.** Live receiver purges push-session staging dirs older than 24h at push begin (previously they accumulated forever); run scratch dirs are cleaned on every terminal state, not just cancel; default settings are defined once (`pbj_sync_default_settings()`) instead of duplicated in activate + migrate; stale stage-order comments corrected.

= 2.8.0 =
* **New: origin-URL rewriting on push.** Every outbound payload — post content/excerpts, post meta (builder JSON), options, widgets, term descriptions, and menu URLs — is rewritten from the Studio origin URL (e.g. `http://localhost:8881`) to the live URL before it goes over the wire. Covers plain, JSON-escaped (`http:\/\/localhost:8881`, how YOOtheme Pro stores URLs inside builder JSON), URL-encoded, protocol-relative, and bare-host forms; PHP-serialized values are round-tripped so their byte lengths stay valid (a plain search-replace corrupts them). Previously builder layouts synced with their localhost URLs intact, shipping dead links to production. Disable via the `pbj_sync_rewrite_urls` filter.
* **New: live-side URL sweep at commit.** `POST /push/commit` now accepts an `origin_url` param (older receivers ignore it) and rewrites any live DB row still carrying the origin URL — posts, postmeta, options, termmeta, term descriptions, usermeta, comments — with the same serialized-safe rules. This cleans contamination from pushes that predate 2.8.0 and from other import paths. `guid` is deliberately untouched. Sweep counts are logged on both sides and returned in the commit response.
* **New: post-push front-end assertion.** After a successful commit, Studio fetches a sample of live pages (home, the posts page, the 3 most recently modified pages) and scans the rendered HTML for the Studio origin host. Any hit is logged as an ERROR ("URL LEAK on live after push") and listed in the run's error panel, so a leak can never masquerade as a clean push again.

= 2.7.0 =
* **Fixed: a failed commit no longer reports success.** When the final `POST /push/commit` errors (e.g. the 401-on-commit failure mode), the run now ends with status `failed`, logs an ERROR ("Commit failed: …" + the run summary), and — as before — does NOT advance the incremental watermark. Previously the run still said "done" and logged a success line, masking the failure.
* **No-op push warning.** A committed push that moved 0 items now logs a WARN — "Push committed but sent 0 items — incremental mode found nothing modified since <date>. If you expected changes, run again with 'Force full sync'." — and the Dashboard shows it prominently in a new amber "warn" style instead of a plain "Push complete."
* **UI handles the `failed` status.** The Dashboard result panel now renders "Push FAILED." with the errors in red for a failed run (it previously only knew done/cancelled).
* **Tab reorder (natural flow).** Studio: Dashboard → Compare & Sync → Export & Import → Settings → Logs. The "Import a live backup" form moved off the Dashboard into the new Export & Import tab, so the Dashboard is purely push + progress. Live keeps its Backup card on the Dashboard (Dashboard → Settings → Logs).
* **New: Export & Import tab (Studio).** Two downloads plus the relocated import form: (1) **WordPress export (WXR)** via core `export_wp()` — a standard eXtended-RSS file importable into ANY WordPress via Tools → Import, with a copy kept under `uploads/pbj-sync/exports/`; (2) **Full backup (.zip)** — the same manifest + SQL dump + per-table JSON snapshots + uploads archive the Live backup tool produces, now generated on Studio too (table enumeration is SQLite-aware: `SHOW TABLES` on MySQL as before, `sqlite_master` / `$wpdb->tables()` fallbacks on SQLite). Both are nonce-protected `admin-post` downloads requiring `manage_options`.
* **New: Auto Sync (merge newest) on Compare & Sync.** One click merges Studio and Live in BOTH directions, newest wins, strictly additive (never deletes on either side). Posts/pages/CPTs/media are diffed per item by post type + slug using `post_modified_gmt` (new live endpoints `GET /manifest/posts`, `POST /export/posts`); live-newer items are downloaded and applied through the exact same upsert code the live receiver uses (`apply_post_item`, shared), with attachment files fetched from their live URLs and metadata regenerated locally; Studio-newer items are uploaded via the existing push machinery restricted to just those IDs (new `only_post_ids` override). Settings (options) merge three-way against a `pbj_sync_option_snapshot` baseline via `GET /export/options` — changed on one side wins, changed on both → Studio wins with a CONFLICT warning logged per option; the first run only records the baseline. Terms ride additively (with the upload, and per-post on download); menus push additively; **users and theme/plugin files are excluded** from Auto Sync (no reliable timestamps / upload-only via the existing flows). Deletion sync is forced OFF for the auto-sync run.

= 2.6.1 =
* **Menu items with no matching target on live are no longer dropped.** A `nav_menu_item` of type `post_type`/`taxonomy` whose target page/term can't be found on the live site (e.g. it links to a page that exists only on production, or to a page that hasn't been synced yet) is now kept in the menu as a **custom link to the same path on the live host**, instead of being silently skipped. If that page later syncs, the next push re-links it as a proper post_type item (the menu stage rebuilds each menu every run). Items with no usable URL at all are still reported in `failed`, now with `type`/`object`/`ref` for diagnosis. The push response + Dashboard/Compare progress show the count kept as custom links. Fixes the 2.6.0 behavior where such items surfaced as `unresolved_target` errors and vanished from the live menu.

= 2.6.0 =
* **Navigation menus now sync.** Added a dedicated "Navigation menus" sync option (Dashboard "What to sync" + a Menus row on Compare & Sync). Previously menus never crossed at all: the `nav_menu_item` posts (the menu links) are excluded from the generic post sync, and the `nav_menu` taxonomy (the menu containers) is non-public so the term sync skipped it too. The new menu stage pushes each menu's items **and** the theme location assignments (Primary, Footer, etc.).
* **Menus survive the ID reassignment.** A menu is glued together entirely by numeric IDs — each item points at its target page/category by ID, at its parent item by ID, and the location map points at the menu by term ID. Those IDs are reassigned on live (everything upserts by slug), so a naive copy would arrive scrambled. The menu stage sends portable references instead — target objects by slug, parent items by a per-run correlation id — and the receiver remaps them to live's own IDs in two passes, then rewrites `nav_menu_locations` accordingly.
* The menu stage runs **after** posts and terms so the pages/categories a menu points at already exist on live and can be resolved. It mirrors each menu (clears existing items, recreates from the source) so re-syncs don't pile up duplicates. Default ON. Upload-only — the backup/import (Download) path doesn't perform the ID remap, so menus are disabled for Download on Compare & Sync.

= 2.5.2 =
* **Compare & Sync Settings count is now meaningful — it can actually reach "=".** Before, the Settings row counted EVERY `wp_options` row on each side (e.g. Studio 724 vs Live 1849). A live site accumulates hundreds of transients/cache rows, plus protected brick options and role/capability rows, that the sync intentionally never writes or prunes — so the live number could never fall to match Studio and the row showed "≠" forever, even right after a clean prune. The Compare now counts only the options the sync actually manages, using the exact same predicate as the push and prune. Both sides count the same population, so after an Upload (with Prune for an exact mirror) Settings shows "=".
* **One source of truth for "options the sync ignores."** New `PBJ_Sync_Pusher::is_unmanaged_option_name()` is now used by everything that decides what to send, write, prune, or count (`collect_option_names`, `push_options`, `prune`, `local_summary`), so those four can no longer drift apart. Covers: protected/brick names, transients, the plugin's own options, role/capability/user-level rows, and application-password rows.
* Note on the Plugins count: it counts installed plugins identically on both sides, so if Live shows fewer it really is missing those plugin files — tick the Plugins section on Compare & Sync (which ignores the exclude list, 2.5.0+) and push to bring Live up to match.

= 2.5.1 =
* **CRITICAL: a push can no longer disable its own REST auth.** Symptom: a full sync transferred fine (e.g. options + ~12k files) but the final `POST /push/commit` came back `HTTP 401 Authentication required`, so the run never finalized — while the admin stayed logged in in the browser. Cause: a write or prune during the options stage could put the live site into a state where `wp_is_application_passwords_available()` returns false partway through the run, after which every remaining REST call (run over an Application Password) is rejected. Fix: the plugin now force-enables Application Passwords **only for its own REST namespace** (`pbj-sync/v1`), so the channel the sync runs on can never be turned off by something the sync itself writes. Site-wide app-password policy is untouched — only the sync's private endpoints are guaranteed reachable. As defense-in-depth, the options stage now also refuses to write OR prune any option whose name matches `application_password`.

= 2.5.0 =
* **Compare & Sync now ignores the dashboard "Plugins to exclude" list.** When you tick the *Plugins* section on the Compare & Sync page, every plugin is pushed (only PBJ Studio Sync itself is kept out) — regardless of what's in *Settings → Plugins to exclude*. The exclude list still governs the regular Dashboard push; Compare & Sync is a deliberate mirror tool, so it bypasses it. Previously plugins silently didn't transfer because most of them were on the exclude list. A run-time warning on the Compare page notes that caching/security plugins (LiteSpeed, Really Simple SSL, etc.) whose config differs between Studio and Live can break the live site, so tick Plugins deliberately.

= 2.4.0 =
* **Settings sync re-enabled across differing table prefixes.** 2.3.0 over-corrected by disabling the whole options section when Studio and Live prefixes differ. Now options/theme-mods sync normally again — only the *user/role* parts are protected (the targeted guards from 2.3.0 stay: `*user_roles`, `*capabilities`, `*user_level`, `*user_settings`, plus siteurl/home/active_plugins are never written or pruned). So footer/theme-mods/plugin settings sync, while role definitions and logins remain safe.

= 2.3.0 =
* **CRITICAL safety fix.** Prevents the scenario where a sync could remove a live administrator's access. Three guards:
  1. **Table-prefix awareness.** Options are stored/compared by name and some are prefix-named (notably `{prefix}user_roles`, which holds the role/capability DEFINITIONS). If Studio and Live use different table prefixes, the options section is now automatically **skipped** for the run (with a logged warning) — previously a prune deleted live's `{prefix}user_roles` because it didn't match Studio's differently-prefixed name, wiping every admin's capabilities. Posts/media/users/theme/plugins are unaffected.
  2. **Role/capability options are never written or pruned**, regardless of prefix (`*user_roles`, `*capabilities`, `*user_level`, `*user_settings`).
  3. **User sync hardened:** never syncs `_application_passwords` or `session_tokens` (site-specific credentials), never modifies the operator's own user meta, and **never strips administrator from an existing live admin** (no downgrade-to-nothing).
* Net: user accounts + roles/auth-level still sync; application passwords and login sessions never do; and a sync can't delete role definitions or lock out an admin.

= 2.2.0 =
* **Prune (true mirror).** A new checkbox on the Compare & Sync page deletes items on the destination that don't exist on the source, for the ticked sections — so a one-way sync can produce an EXACT mirror (this is what finally makes the comparison counts equal). Covers posts/pages/custom-post-type items (incl. forms), media, settings/options, users, and plugin files. It's destructive and off by default, behind an explicit confirmation. Hard protections: never deletes the sync plugin itself, the logged-in admin (deleted users' content is reassigned to them), or brick-critical/sync-internal options (siteurl, home, active_plugins, etc.). On the Download direction, Prune wipes the local content tables before restoring.
* Why it was needed: the push is additive (upsert by slug, never deletes), so any section where the destination had extra items — e.g. Settings 724 vs 1677, Media 53 vs 55 — stayed "≠" forever no matter how many times you synced. Prune removes the extras.
* The Compare summary now counts the same content post types the sync carries (including private CPTs like `pbj_form`), so forms are reflected in the Posts count.

= 2.1.0 =
* **Syncs ALL content post types, not just public ones.** Plugin content stored in private custom post types — e.g. PBJ Form Builder's forms (`pbj_form`) — now travels with a sync. Previously only public types (posts/pages/attachments) were pushed, so forms and other plugin data silently never reached live. WordPress-internal types (revisions, nav menu items, customizer/site-editor types, action-scheduler) and form *submissions* (`pbj_form_entry`) are still skipped.
* **Mirrors plugin active/inactive status.** When the Plugins section is synced, live's active-plugins set is brought in line with Studio's. The sync plugin is never deactivated, and only plugins whose files exist on live are activated, so this can't disable the syncer or fatal on a missing plugin. Note: this makes live's active set MATCH Studio's, so a plugin active only on live will be deactivated — sync the Plugins section deliberately.
* Tip: reference forms with `[pbj_form slug="..."]` (PBJ Form Builder 1.1.0+) rather than a numeric id, since post IDs are reassigned on the destination but slugs are preserved across a sync.

= 2.0.2 =
* The Compare & Sync page now shows the same live progress bar + per-section counts table as the Dashboard during an Upload (stage, current item, % bar, terms/posts/uploads/theme/plugins/settings/users/errors), plus a Cancel button. Previously it only showed a one-line text status.

= 2.0.1 =
* Fixed: the Compare & Sync page's **Upload** button started the push but redirected to the Dashboard, which silently resumed without showing progress (looked like "nothing happened"). The push now runs inline on the Compare page with live progress and a final summary, and start-up errors are surfaced instead of hidden.

= 2.0.0 =
* **Full-site sync.** The push can now carry the `wp_options` table (including `theme_mods_*`, so footer/header/customizer settings travel) and **users** (with roles, user meta, and password hashes preserved). Both are opt-in under Settings → What to sync. A hard server-side blocklist always protects the live site's `siteurl`/`home`, active-plugins list, theme selection, and the sync plugin's own state, and the user sync never changes the password or role of the admin the request runs as (no lock-outs).
* **New "Compare & Sync" tab (Studio).** Shows a section-by-section count comparison of Studio vs Live (posts, media, terms, theme, plugins, settings, users), with a checkbox per section and a direction choice: **Upload** (overwrite Live with Studio, via the push engine) or **Download** (overwrite this local Studio with Live, by pulling a live backup and importing only the selected sections). Theme/plugin files aren't part of backups, so they're upload-only.
* New REST endpoints: `POST /push/options`, `POST /push/users`, `GET /summary`.
* Importer accepts a per-section table allowlist so the download direction restores only what you picked.
* Settings migration backfills the new keys on existing installs.

= 1.5.5 =
* **Theme/plugin sync is now crash-safe.** Theme and plugin files are written to a per-session staging area during the push and moved into the live directories with an atomic rename only at commit — entry-point files (Composer autoloader, functions.php, style.css) last. The live theme is never left half-updated mid-push, which is what could fatal the whole site (e.g. a Composer autoloader referencing vendor files that hadn't transferred yet). An aborted or failed push now leaves the live theme completely untouched.
* Clarified the "Plugins to exclude" setting (it excludes, it does not include) and noted it only applies when "Plugin files" is enabled.
* Push log now records which sync targets are enabled (e.g. plugins=on/OFF), so a skipped stage isn't mistaken for "no changes."

= 1.5.4 =
* Sync media deletions: hook `delete_attachment` so trashing or deleting a media item on Studio also removes it on live (`before_delete_post` alone misses media deletions).

= 1.5.3 =
* "Check / fix REST API" now rebuilds permalinks via `$wp_rewrite->set_permalink_structure()` + `flush_rules(true)` so the fix actually sticks.

= 1.5.1 =
* Fixed push stage order so uploads transfer before posts (attachment metadata generation on live needs the file on disk).

= 1.4.0 =
* **Self-update.** Hooks into WordPress's native update machinery so the plugin appears in Plugins → Installed Plugins with an "Update available" notice whenever a new release is published. One-click update from then on — no more uploading zips.
* Two source flavors, configurable under *Studio Sync → Settings → Auto-updates*:
  * **GitHub releases** — enter `username/repo`; the plugin reads the latest release tag and downloads the attached `.zip` asset (or the GitHub zipball as a fallback).
  * **Self-hosted JSON manifest** — point at a URL serving `{ version, download_url, tested, requires_php, changelog }`.
* "Check for updates now" button in Settings (clears the 6-hour cache and refreshes the plugin update transient).
* Update folder normalization: GitHub zipballs unpack with weird folder names like `user-repo-abc123/`. The updater renames them back to `pbj-studio-sync/` during install so WP replaces the existing folder cleanly.

= 1.3.0 =
* Canonical install folder is now `pbj-studio-sync` (no more `-v11`, `-v12` suffixes). From here on, future updates use WP's normal "Replace current with uploaded" flow — bump the version, upload the zip, click Replace.
* Orphan cleanup is now general: any leftover `pbj-studio-sync-v*` folders from the earlier renamed releases are detected and removed via the admin notice's one-click button.

= 1.2.1 =
* Activation hardening: when activating, automatically deactivate any older sibling copy of the plugin (same plugin, different folder name like `pbj-studio-sync-v11`). Prevents the "Cannot redeclare class" fatal that happens when two versions try to load in the same request.
* Top-of-file double-load guard: if a sibling still slips through, the second copy bails with a clear admin notice instead of crashing PHP.

= 1.2.0 =
* Incremental sync — only items modified since the last successful push are sent. Posts filter on `post_modified_gmt`, files on `filemtime()`. Taxonomy terms are still always sent (WP doesn't track per-term modification time).
* "Force full sync this time" checkbox on the Dashboard for one-off complete pushes.
* Default sync mode setting under *Settings* (incremental | full).
* "Last successful push" timestamp shown on Dashboard + Settings, with a Reset link.
* Stale-folder cleanup: if an older copy of the plugin is left in `wp-content/plugins/pbj-studio-sync/`, the new version detects it and shows an admin notice with a one-click removal button.

= 1.1.0 =
* Push is now a chunked state machine driven by browser polling — short AJAX calls (~3.5 sec each) instead of one long-blocking request. Fixes the "browser freezes during big media pushes" issue.
* Progress UI: live stage indicator, progress bar, current-item display, per-stage counts, error count.
* Cancel button — aborts the run, asks live to clean up its session, and updates the log.
* Runs survive page reloads: navigate away and back and the UI picks up where it left off.
* File enumeration is now persisted to disk (under `uploads/pbj-sync/runs/<id>/`) so big uploads dirs don't bloat the options table.

= 1.0.0 =
* Initial release: Studio → Live REST push, live backup-to-zip, Studio importer.
