EZ Page Sync

EZ Page Sync changelog

What changed in the latest 20 releases. The newest is v1.2.3.

Version 1.2.3

  • Fixed: When a file production already has exists in several copies and the first one's file is missing, the other copies are tried instead of uploading it again.
  • Fixed: In the Rollback Manager, quickly switching posts could show the previous post's snapshots, so Restore could act on the wrong post.
  • Fixed: "Ago" text in the Rollback Manager no longer reads as the future when the computer's clock is behind.

Version 1.2.2

  • Fixed: A sync only uploads a file production does not already have. The same file uploaded in a different month is reused when it is on disk. A file that was uploaded by hand on production is matched by name, and only when its bytes are identical. Update production for this to apply.
  • Fixed: Overwrite Existing Media compares the file on disk, skips identical files, and never writes over a file in another upload month.
  • Fixed: Links to reused files are corrected in page content and in meta, including Elementor, CSS url(), root-relative links, and thumbnails. Alt text on a hand-uploaded production image is kept.
  • Fixed: Two WordPress sites on one MySQL server no longer block each other's syncs, trash jobs, or log cleanup.
  • Improved: In the Rollback Manager, you can search for content. It is grouped by post type with icons, and each type lists its most recently synced content first.
  • Improved: Rollback snapshots show in one compact list, newest first, with the date, how long ago, and a Latest tag.
  • Fixed: Short dashboard tabs no longer jump up after their content loads.

Version 1.2.1

  • Fixed: Trash on production only moves a page whose production copy stores _ez_ps_staging_link_id for this staging post. A matching slug is not enough. Include pages whose production link is not proven is accepted and does nothing. Restore the page on staging and sync it once to stamp the link, then trash it again.
  • Fixed: Select all sendable selects every loaded sendable row, including rows on other pages. Clearing it clears those rows too.
  • Fixed: Sync Pages and the trash summary say when they stop at their caps (5,000 pages per post type, 10,000 trashed pages in the summary). The caps are unchanged.
  • New: Plugins → View Details now shows the description, installation, FAQ, and changelog from the bundled readme. Link URLs are checked, and a javascript: or data: link is dropped.
  • UI: Save the current filter has a save icon. Trash on production has a trash icon and is red, including while it is disabled. The Pages/Posts list Sync and Discard buttons fit the column.
  • Security: The ez_ps_feature_enabled filter can turn a Premium feature off. It cannot turn one on. Shipped JavaScript and CSS are minified.

Version 1.2.0

  • New: Trash on production — trashing a linked page on staging marks it pending; it does not call production by itself. From Sync Pages → Trashed, tick the pages and click Trash on production. A confirm dialog lists each staging page, its production page (with a link), and the change before anything is sent. Production moves the page to its own trash; there is no permanent delete, no redirect, and no Cloudflare purge. Undo restores the prior status. Trashing more than one page per job needs Premium (bulk_sync); a single trash is free.
  • New: Pages trashed before this release show a Queue for production trash button so they can be sent the same way.
  • New: Finished trash jobs update the Trashed list in place. Pages production has trashed drop out of the view; the job status has its own line with Undo or Cancel and a dismiss button.
  • New: Sync Pages remembers your post type, status, layout, sort, and page-size choices per user (Save the current filter). Status now defaults to Published.
  • New: The admin menu badge also counts trashed pages waiting for production, and updates without a page load — after a sync, discard, rollback, or trash job, after a block-editor save, across your open tabs, and on each heartbeat.
  • Fixed: A sync that got "already in progress" (HTTP 429) no longer releases the lock held by the sync that is running.
  • Fixed: Trashing or restoring a page on staging no longer sends a change notification, so bulk trash no longer uses up the receiver's hourly rate limit.
  • Fixed: Scheduled posts now publish at the right time on production. The sender sends post_date_gmt, and the receiver writes schedule dates only for status future.
  • Fixed: A duplicate single-value Yoast or Astra meta row no longer arrives as the literal text "Array", and a non-string page template is reset to default instead of causing a fatal error (also on rollback).

Version 1.1.47

  • Fixed: ACF Storage Shape & Preview Accuracy — Sender::collect_acf_data() now reads ACF's raw storage shape (get_field_objects($post_id, false)) instead of display-formatted values so link fields configured with Return Value = "Link URL" transmit their full ['title', 'url', 'target'] array, and Receiver::calculate_detailed_differences() normalizes sub-field keys (Sender::remap_acf_field_keys_to_names()) so unchanged ACF fields are not falsely flagged on Preview / Dry Run.
  • Fixed: Type-Gated ACF Remapping — restricts post and media reference remapping on both Sender and Receiver to ACF field types that actually hold post or attachment references, recursively strips excluded presentation/layout sub-fields (message, accordion, tab) inside group, repeater, clone, and flexible_content containers while deduplicating envelope key discovery, and raw-decodes URL paths in Receiver::is_probable_media_url() before wp_check_filetype().
  • Fixed: First-Edit Baseline Snapshot Capture — Sender::maybe_capture_baseline_snapshot() hooks pre_post_update and elementor/document/before_save to capture a local _ez_ps_last_sent_{dest} restore point on the first edit of an already in_sync page that has no snapshot yet, making Discard Edits available immediately without a full re-sync.
  • Fixed: Persistent Sync Status Counters — maintains Last Sync, Synced Today, Failed (7d), and Total Syncs as non-autoloaded wp_options counters (ez_ps_stat_last_sync, ez_ps_stat_total_syncs, ez_ps_stat_day_{Y-m-d}, ez_ps_stat_fail_{Y-m-d}) with one-shot backfill (Logger::maybe_seed_stats()) and daily 9-day bucket pruning so clearing or rotating the sync log no longer resets the status panel.
  • Fixed: Parent Slug Validation — Validator::is_clean_slug_segment() rejects post_parent_slugs segments outside [A-Za-z0-9_-] and valid UTF-8 percent-triplets (%80–%FF), blocks percent-encoded ASCII (%00–%7F), and fails closed when sanitize_title() would rewrite a segment beyond ASCII case-folding or WordPress's four save-context UTF-8 dash octets.
  • Fixed: ACF Field Groups in Sync Pages — Admin::get_dashboard_sync_post_types() includes acf-field-group in the Sync Pages "All Post Types" query, filter dropdown, list-table columns, post-edit metabox, first-edit baseline capture, and Receiver::is_allowed_post_type() whenever ACF is active and custom_post_types is entitled (Premium), while keeping Free tier capped to page + post.
  • New: Unsynced Edits Menu Badge — the EZ Page Sync admin menu item shows how many pages offer Discard Edits (a snapshot exists and the page is not In Sync). Free tier, sender only. The count uses bounded, chunked queries that never read snapshot contents, and is cached with a generation counter that resets on post saves, syncs, discards, baselines, and settings changes.
  • UI: The Dry Run button is now labelled Sync on the dashboard, the Pages/Posts list, and the post-edit metabox. It still opens the preview first; nothing is written until you confirm.
  • UI: The post-edit metabox now uses the same preview dialog and progress bar as the Sync Pages dashboard, and syncs to the page's own destination. Plugin dialogs keep a 50px top and bottom margin on short screens.

Version 1.1.46

  • Maintenance: Updated the bundled Freemius WordPress SDK from 2.13.4 to 2.13.5. The SDK release is security hardening: admin-notice output is escaped, account opt-in is restricted to administrators, and pricing-page settings can no longer be overridden through URL parameters. The SDK also drops its post-purchase "You have purchased a license" notice. No changes to sync behavior or Free/Premium gating.

Version 1.1.45

  • Fix: A hierarchical page whose parent cannot be described (different post type, missing slug, or a chain too deep to send) no longer lands under a parent ID that only exists on staging. The sender transmits an empty post_parent_slugs chain. An unlinked page stops with HTTP 409 (EZ_PS_PARENT_NOT_FOUND). A page already linked by ID keeps its production parent and still receives the content update. Force does not bypass this. Update both staging and production.
  • Fix: Preview reports that case as blocked, including when a stored production ID points at a different post type. An empty slug no longer previews an update against a slug-less draft while the real sync would create a new page.
  • Fix: The sender returns EZ_PS_PARENT_NOT_FOUND as its own HTTP 409, not a generic sync failure, and does not offer the force dialog for it.
  • Fix: A parent-slug segment containing / or %2f is rejected, so one segment cannot be decoded into two ancestors. Stored slugs are sent as saved (Services still resolves); the receiver sanitizes them.

Version 1.1.44

  • New: Dry run preview parity — preview responses now include destination hierarchical slug paths (post_name_production) and permalinks (post_url_production). The post edit metabox dry run UI now features the same structured presentation as the dashboard modal (confidence badge, action badge, and compact grid layout).
  • New: Sync log filtering and management — added "Latest per page" filter to sync logs (displays the newest sync entry per page while retaining system and connection events), and added a modal-confirmed "Delete Log" bulk action.
  • Fix (admin): Conditionally hides the Sync Options tab when ACF Pro is inactive or has no registered options pages, and hides the ACF Sync setting toggle when ACF is not installed. Saving settings without ACF active preserves the existing ez_ps_sync_acf option.
  • Fix (style): Scoped dry run and discard button styles in admin.css to .ez-ps-admin-wrap to prevent style leakage on post edit screens, and added post-type badge styling in the sync table.
  • Fix (debug): Cleared PHP stat cache (clearstatcache()) after debug log rotation so file size checks reflect the truncated state immediately.

Version 1.1.43

  • New: Parent-aware hierarchical post matching — post parent slug chains (post_parent_slugs) are passed in payload and matched on the receiver. Prevents duplicate child slugs under different parent hierarchies from cross-matching or false-positively tripping ambiguous-slug guards. Preview diff surfaces parent-not-found as blocked instead of failing at apply time.
  • New: Astra 6-tier display condition remap — re-maps Astra layout and header/footer builder display conditions (rule IDs, taxonomies, terms, user roles, post types) between environments.
  • New: ACF slug-hint schema support — relation and post-object fields include slug hints so target references resolve correctly even when post IDs diverge across environments.
  • New: Video media attachment resolution on production — when video attachments are excluded from sideload, the receiver searches for an existing local copy by hash, timestamp, or filename and attaches it (including featured images and inline embeds), rather than leaving null references.
  • New: Media extraction enhancements — extracts root-relative /wp-content/uploads/ URLs in CSS url(...) declarations and inline styles, extracts classic gallery shortcode IDs ([gallery ids="..."]), and supports numeric string IDs in Elementor widget image attachments.
  • Fix (security): Admin debug log REST routes (/admin/debug-log, /admin/debug-log/download) elevated to rest_can_manage_security permission callback.
  • Fix (engine): Ambiguous slug COUNT is scoped to the parent tree so cousin pages sharing a slug under distinct parent hierarchies are not falsely force-gated.
  • Fix (engine): New posts with unresolvable authors fall back to the first available administrator. Post excerpts are preserved across syncs.
  • Fix (engine): Cloudflare Ray ID (CF-Ray) parsed from remote edge responses and surfaced in diagnostic error logs.
  • Fix (engine): Logger prune batches now execute via MySQL-legal SELECT id ... DELETE ... IN () batching.
  • Fix (resilience): Receiver error responses propagate HTTP status and wire error codes from WP_Error. Remote 429 status recognizes both EZ_PS_CONCURRENT_SYNC and EZ_PS_SYNC_IN_PROGRESS.
  • Fix (media): Media hash backfill skips unreadable files or files exceeding ez_ps_max_encoded_file_bytes (100 MB). Media hash lookups strictly join $wpdb->posts to ensure post_type = 'attachment' and post_status = 'inherit'.
  • Fix (discard): Discard staging changes detects and logs failures if add_post_meta() returns false during post-meta restoration.
  • Admin: Bulk sync REST endpoint enforces a 50-item safety limit per request and forwards force and sync_mode parameters.

Version 1.1.42

  • New: Discard Staging Changes — after every successful sync, the sender stashes a snapshot. A new Discard Edits button (Free, not Elementor-gated) on the Sync Pages dashboard, post-list column, and post-edit metabox restores the page to its last synced state and re-stamps status to In Sync. All-or-nothing restore with observer muting, attached-file normalizer, kses teardown in finally, and catch (\Throwable) → EZ_PS_037. Guards: editor lock (EZ_PS_034), MySQL GET_LOCK mutex (EZ_PS_035), post-type mismatch (EZ_PS_033), partial-restore detection (EZ_PS_036).
  • New: Media preflight handshake — before building the payload the sender asks the receiver which media files it already has (batched hash-then-path IN() queries, never filename). Files already present have their base64 bytes stripped, keeping the payload under the 10 MB cap without losing any data. Best-effort and fail-open: a network error or unsupported receiver leaves the full bytes in place. Overwrite-media mode reports nothing present so overwrites still work.
  • New: Video media exclusion — video/* attachments are excluded from the payload by default (ez_ps_skip_video_media filter, default true). A URL-only stub is sent so domain rewriting still works; production plays from its local copy. Filter returns false to force sideload.
  • New: Referenced media only (ez_ps_collect_referenced_media_only, default off) — when enabled, only media actually referenced on the page is collected (featured image, images/links in content, ACF, Elementor, Astra media), skipping orphaned post_parent attachments that push large pages over the size cap. Linked upload URLs (<a href> to PDFs, File blocks) are extracted with a lookbehind-safe regex and resolved via home_url() (subdirectory-install safe). CSS background-image is not detected — documented limitation.
  • New: Media hash backfill on reuse — when the receiver reuses an existing attachment, it stamps _ez_ps_media_hash from its own md5_file() if missing, so subsequent syncs and the preflight resolve via the fast hash path instead of the slow _wp_attached_file scan.
  • New: Ambiguous slug match detection (EZ_PS_AMBIGUOUS_SLUG_MATCH, ez_ps_detect_ambiguous_slug_match filter, default on) — when multiple production pages share a slug, the receiver warns and requires force, rather than silently picking one. Draft/pending/auto-draft excluded from the count. Guarded on $resolved_target_existed to avoid flagging a just-created post.
  • New: Debug logging subsystem — file-based logging via Logger::debug() to wp-content/ez-page-sync-debug.php (always-on; ez_ps_debug_mode checkbox controls UI visibility only). 10 MB rotation, REST endpoints for view/download/clear, terminal-style admin viewer with search and syntax coloring. Payload composition breakdown logged before the size cap.
  • Fix (discard): Removed maybe_unserialize() from the meta restore path — wp_slash() on a __PHP_Incomplete_Class object (from a deactivated plugin's serialized data) caused a fatal TypeError on PHP 8.
  • Fix (discard): Observer muting + attached-file normalizer now installed during discard. Third-party save_post / meta / taxonomy observers (e.g. MainWP Child Changes Logs) no longer fire during restore. Filterable via ez_ps_muted_receive_observers.
  • Fix (discard): Error codes EZ_PS_007, EZ_PS_030, EZ_PS_031, EZ_PS_033 now carry explicit HTTP statuses (404, 409, 422, 409).
  • Fix (Elementor): remap_elementor_template_refs() now requires mapped values be positive integers before writing, preventing latent _elementor_data corruption from non-scalar map entries.
  • Fix (preview): __PHP_Incomplete_Class objects in production ACF/Yoast meta no longer fatal the Dry Run diff — normalize_diff_value() safely JSON-encodes objects via get_object_vars().
  • Fix (sender): WP_Error codes from the sender are now normalized (both $body['code'] and $body['data']['code'] are uppercased before comparison), so EZ_PS_FORCE_REQUIRED and EZ_PS_CONCURRENT_SYNC responses from the receiver are correctly recognized.
  • UI: Sync Now button removed from all surfaces — all syncs now go through Dry Run → Preview → Apply. Force sync is still available via the Preview confirmation dialog.
  • UI: Bulk Sync button, Select All checkbox, and row checkboxes removed — syncs are single-page only. The bulkSync and updateBulkButton JS functions are no-ops.
  • UI: Sync Pages toolbar reorganized — filter dropdowns wrapped in a dedicated row, search input moved below. Destination dropdown hidden when only one destination exists.
  • UI: Confirm modals now close on Escape key and click-outside; z-index raised above WP admin modals.
  • UI: Debug log modal uses white-space: pre (horizontal scroll) instead of pre-wrap (word-break wrapping) for readable log lines.

Version 1.1.41

  • Fix: Every apply step now surfaces its own failure instead of some of them failing silently. Post-meta, Yoast, media, Astra, Elementor, ACF import / field-group, child-post, and taxonomy sync each route a WP_Error through one abort_or_warn() seam — logged as a warning, returned in the response warnings, and (when Fail On Partial Sync is on) aborting with an auto-rollback rather than continuing. Previously several of these could return an error and still be reported as a clean success.
  • Fix: Auto Rollback now fires on any \Throwable (including a fatal TypeError), not just a caught \Exception / WP_Error, so a mid-apply fatal no longer leaves production half-written. A first-time create that fails before the DB transaction opens (no snapshot yet) now deletes the post it just created, so a failed first sync can't leave a blank published page.
  • Fix: A media sideload failure (WP_Error or thrown) is now logged and recorded as a sync warning instead of being swallowed to a silent null. A sync that completes with warnings is logged with status = warning (not success), and the warning list is appended to the log message and returned to the caller.
  • Fix (media): The sender now *omits* — rather than clears — a featured image whose file is missing on disk, so a broken staging thumbnail can no longer wipe the production one. An absent/empty featured image now leaves the production thumbnail unchanged (non-destructive) instead of clearing it. After a successful sync that included a featured image, clearing the thumbnail on staging emits an explicit {removed:true} so production's thumbnail is actually removed — a staging page that never had one still does not wipe production.
  • Fix: A staging↔production environment mismatch — a taxonomy or ACF field not registered on production — is now surfaced as a non-fatal warning instead of aborting and rolling back the whole sync. An ACF payload whose fields are all unregistered no longer trips the leftover "none applied" abort, and production ACF values are left alone in that case (orphan cleanup only runs after at least one field actually applied).
  • Fix (media): find_existing_attachment scopes by post_parent first and only falls back to library items (post_parent = 0) — never an unscoped match — so a shared filename can't clobber another post's attachment. Same-slug / different-extension and upload-month mismatches are now rejected.
  • Fix (media): Thumbnail and intermediate image-size URLs (not just the full-size URL) are now remapped staging→production in content and meta, and the Elementor / block ID remap also rewrites mediaId, imageId, and attachmentId keys, not only "id". Media meta is unserialized before URL rewriting and re-serialized once by update_post_meta (WP #12930) to avoid double-serialized corruption; an image attachment that generates no image sizes now logs a warning.
  • Fix (validation): The receiver rejects a non-array taxonomy term list (EZ_PS_006) and validates acf_post_map as a numeric ID→ID map, preserving keys and dropping nested / non-scalar values instead of coercing them to 0 / 1.
  • Fix (security): The rate limiter fails closed (EZ_PS_005) if the counter read returns null / non-numeric after a successful increment (replica lag or read error), instead of treating it as zero and letting requests through unmetered.
  • Compatibility: Minimum WordPress bumped to 7.0; tested up to 7.1. Plugin author display name is now "EZ Page Sync".
  • Fix: A rollback restore that fails partway through now reverts to the post's actual state immediately before the restore attempt — title, content, status, meta, ACF fields, and taxonomy terms — instead of the old best-effort cleanup (delete the meta keys it had added, delete ACF fields it had set, clear taxonomies to empty). Previously a mid-restore failure could leave a post with its terms wiped and its pre-restore meta gone rather than genuinely reverted. As a side effect, a normal (non-failed) restore now also clears any taxonomy that was empty at snapshot time, matching the snapshot's actual state instead of leaving terms added after the snapshot untouched.
  • Fix: Capturing the pre-restore state calls filterable ACF/taxonomy hooks; a throw from a broken third-party hook there could previously escape Rollback::restore() uncaught instead of failing cleanly with a WP_Error — notably the receiver's own post-sync-failure auto-rollback call. Capture failure now falls back to a placeholder prior state and restore proceeds — and that placeholder is now explicitly marked "never observed" (see next fix) rather than treated as a real empty state.
  • Fix: When prior-state capture fails entirely, revert_restore() no longer treats the resulting empty placeholder as "the post genuinely had no meta/ACF fields" and wipes accordingly — it now recognizes the placeholder as "never observed" and leaves meta, ACF, and taxonomies untouched instead of guessing. Post-field revert (title/content/status) still runs whenever a real prior state is available, and now also logs when wp_update_post() itself reports a WP_Error during revert, matching every other revert step.
  • Fix: A hook throwing while revert_restore() re-adds pre-restore meta no longer aborts the remaining keys — each key is now isolated, so one bad key can't cost meta that would otherwise have been successfully restored (the wipe step ahead of it is isolated the same way). Revert also now removes an ACF field the failed restore attempt had written if that field wasn't part of the prior state, instead of leaving it stranded.
  • Fix: A taxonomy that can't be read while capturing prior state (get_the_terms() throws or returns WP_Error) is no longer recorded as "no terms" — it's left out of the captured state entirely, so a revert can't mistake "couldn't observe this" for "genuinely empty" and wipe real terms that were simply never read.
  • Fix: The same "never observed" rule now applies to ACF: a throwing get_fields() during prior-state capture is flagged acf_observed = false, and revert_restore() skips ACF entirely instead of treating acf: [] as "the post had no fields" and delete_field()-ing every snapshot ACF key.
  • Fix: Pre-sync create_snapshot() fails closed (EZ_PS_010) if ACF or any taxonomy cannot be read, instead of persisting an empty/partial snapshot that auto-rollback could not actually restore from.
  • Fix: Media_Sync::sync_featured_image()'s "reuse existing attachment" path (no Overwrite Media and a dedup hash match — the common outcome on a resync of an unchanged featured image, now that file_hash actually reaches the receiver) didn't report the staging attachment id in its result, unlike every other media return path. Content ID-remapping (wp-image-{id} classes, Gutenberg block attribute references) was silently skipped for a reused featured image, leaving production content pointing at the staging attachment ID.
  • Internal: Media payload sanitization now preserves the staging attachment id, source url, and file_hash on featured/inline media items (id validated as a positive integer, file_hash validated as a 32-64 character hex string), plus the featured-image removed flag. These were previously stripped before reaching the receiver, so the existing hash-based dedup and staging→production attachment-ID remap logic in Media_Sync — present but dead — is now live on every sync. file_hash is trusted as claimed (format-validated only, not re-derived from the uploaded bytes); a sync only ever runs against your own paired sites over the shared HMAC secret, so this is a same-trust-boundary data-integrity note, not a new attack surface.
  • UI (list table): The native WordPress Pages/Posts list now renders the sync-status badge and inline Dry Run, Sync Now, and Baseline buttons on each row, matching the Sync Pages dashboard — you no longer have to open the dashboard to push or dry-run a single page.
  • Fix (status): The Pages/Posts list status badge now flips In Sync → Locally Modified when the destination copy was edited directly on production, using the same read-only divergence probe the dashboard already uses. Previously the native list only checked staging-side edits, so a production-only edit still showed "In Sync". Fail-open: an unreachable destination leaves the badge unchanged.
  • Fix (Elementor): An empty _elementor_data value (leftover Elementor meta with no layout JSON) now applies cleanly instead of aborting the sync as EZ_PS_ELEMENTOR — json_decode('') is a syntax error and no longer fails the step.
  • Security: Meta unserialization during apply now runs through ensure_unserialized (allowed_classes = false) and skips any value that contains an object, so a serialized-object gadget in staging meta can't be instantiated or written on production. Snapshot JSON encoding drops JSON_PARTIAL_OUTPUT_ON_ERROR, so circular or non-finite (INF / NAN) meta fails the snapshot closed instead of silently storing null / 0.

Version 1.1.40

  • New: Sync Pages shows an amber Locally Modified badge when the destination post has diverged since last sync (production restore or local edit). Staging asks the receiver via a read-only HMAC /divergence-check (ownership-scoped; unlinked / foreign IDs omitted; 200-ID cap; skipped from the 60/hr sync limiter like /ping). Fail-open if the destination is unreachable. A successful re-sync clears the badge — the 45s probe cache is invalidated on apply so a just-synced row cannot stay Locally Modified.
  • UI: Dashboard Dry Run now opens the same field-level diff report as the metabox (Field Changes, Destination vs Staging) via /admin/preview. Previously it fired /admin/sync with dry_run and discarded the result.
  • UI: Snapshot restore uses the animated sync progress dialog with restore-specific messages. Global Dry Run Mode shows an amber banner on every dashboard tab and an inline notice in the sync metabox. Sync-log detail rows are tinted green/red by outcome. In-sync rows drop their Dry Run / Sync Now buttons. Syncable-pages list is paginated, with a Last Edit column and a select2 post-type picker. Admin assets are cache-busted by filemtime.
  • Fix: Bulk Sync and Select-All now apply only to visible (current-page) rows, so pagination cannot silently push every matched page to production.
  • Fix: Preview-modal confirm no longer double-binds the list's Sync Now handler (that fired a spurious force-required error before the real sync).

Version 1.1.39

  • New (Premium): ACF Options page sync. A new "Sync Options" tab on the sender pushes registered Advanced Custom Fields Options pages (site-wide field values) to a production destination using ACF's own field APIs — not a raw options-table dump. Gated by the acf_options_sync Premium feature; both sites need ACF active with matching field groups. Apply is fail-closed: values are written via update_field() only, against an allow-list of known options pages, with no arbitrary update_option() fallback. v1 does not transfer Options media files — attachment IDs must already exist on production.
  • New: "Baseline after production refresh" (sender). After a full Duplicator restore of staging from production, Sync Pages showed "Never Synced" even when content already matched. This admin action re-links this node's local mapping/status for the selected destination(s) — local bookkeeping only, no HTTP to any destination — and skips any post that already has real sync history. Gated on manage_options + sender role.
  • Security: Pushing or previewing an ACF Options page now requires genuine manage_options, consistent with rollback and API-key rotation — not the delegatable settings-access capability.
  • Security: The receiver bounds every request body before authentication — a filterable byte cap (15 MB) and a JSON nesting-depth cap — so oversized or deeply nested payloads are rejected before any auth or decode work. License checks and runtime-limit expansion on receiver endpoints now run only after HMAC verification, so an unauthenticated caller can neither probe Premium/license state nor expand a worker's resource budget.
  • Fix: A page whose staging author account no longer exists is no longer silently reassigned to the site's first administrator on production — the existing production author is preserved when the payload carries no author info.
  • Fix: Failed connection attempts now surface the real HTTP status (401/429/etc.) instead of a flat 403, and a 403 blocked by a Cloudflare or Sucuri firewall is now named as such in the error message.
  • Fix: Rollback snapshots that would exceed a size cap (default 5 MB, filterable) fail cleanly with a clear error instead of a truncated wp_options write, and a large-payload send aborts with an actionable message when the server is too low on memory to encode media safely.
  • Internal: HMAC payload canonicalization uses a stable byte-order key sort — sender and receiver must run the same plugin version so signatures agree across an upgrade.

Version 1.1.38

  • Security: The cross-site ownership check is now unconditional — no code path lets the sender-supplied force flag clear it. The legacy pre-multi-destination sentinel (legacy_sync_origin, backfilled by the v1.0.2 migration for posts synced under the old single-destination model) is an explicit hard block (EZ_PS_016): on a multi-destination install with several distinct secrets, any holder of any valid secret could otherwise claim a post that legitimately belongs to another staging site. Clearing a legacy marker requires the admin-triggered "Reset Sync Origin" action on the destination, which is gated on real manage_options and (since v1.1.36) also rotates the API key.
  • Security: A first-time slug match against a production post with no sync history now requires force acknowledgment (new warning EZ_PS_UNLINKED_SLUG_MATCH), so a common slug (about, contact, privacy-policy) can no longer silently overwrite an independently-authored production page. Guarded by $resolved_target_existed so the receiver never flags a post it created earlier in the same request against itself.
  • Security: Every ownership conflict now reports proposed_action = 'blocked' instead of being advertised as force-clearable, and the Preview modal disables its Sync Now button for that state rather than offering an acknowledgment that cannot succeed.
  • Security: IP-whitelist rejections no longer leak network topology to the caller. The verbose detail (sender IP, REMOTE_ADDR, X-Forwarded-For, proxy-config hint) now goes only to the DB audit log via a separate audit_detail field; the REST response carries a generic message (EZ_PS_004).
  • Security: Validator::sanitize_payload() guards children, acf_field_groups / bundled_field_groups, and nested meta against non-array scalars. These keys aren't type-checked by the schema validator, so a malformed payload from a holder of the shared secret could reach a strictly-typed sanitizer and throw an uncaught TypeError.
  • Security: recursive_url_replace() now caps decode recursion at 32 levels. Each JSON-string-inside-a-string or serialized-string-inside-a-string it decodes recursed again from scratch, uncapped by json_decode()'s own nesting limit — a crafted chain of nested encoded strings could exhaust the PHP call stack. The cap counts *decode* re-entries only, not array/object traversal, so legitimately deep ACF/Elementor meta is still fully rewritten.
  • Fix: A partial sync is no longer recorded as a completed one. A 200-with-warnings response no longer stamps the last-synced timestamp, and the receiver's warnings / warning_messages now reach the caller, the audit log, and the admin UI (they were previously dropped entirely). Before this, a media failure that deferred the Astra/Elementor image-ID remap left the page reporting "In Sync" — staging's post_modified_gmt hadn't changed, so nothing ever marked it dirty again and the deferred work was never applied. On a first sync of an Elementor page that meant a blank production page showing green.
  • Fix: When media sync fails and Fail On Partial Sync is off, Astra/Elementor meta is now skipped rather than written with an incomplete image map (which on a re-sync would overwrite meta already holding correct production IDs), and reports EZ_PS_ASTRA_ELEMENTOR_REMAP_SKIPPED.
  • Fix: Ownership-rejection messages now name the one action that actually resolves them ("Reset Sync Origin" on the destination) instead of referring to a force flag the UI provides no way to set.
  • Performance: Receiver::sync_child_posts() batch-prefetches existing children instead of issuing up to 3 queries per child (parent-key resolution, existing-post lookup, post_content read) — a field group with dozens of sub-fields previously cost on the order of 3N round-trips. Both lookups chunk at 200 IDs/slugs: an oversized IN () that exceeded max_allowed_packet would return no rows, making every child look new and mass-creating duplicate ACF field posts. Map keys fold case to match the utf8mb4_*_ci collation the query runs under, first-row-wins preserves the LIMIT 1 semantics of the per-child query, negative parent-key lookups are not memoized (parents are created mid-loop and are not ordered before their sub-fields), and rows created mid-loop register back into the map — each of those otherwise turns a lookup miss into a duplicate field post.
  • Performance: Audit-log pruning elects a single pruner via a non-blocking GET_LOCK advisory lock. Previously every concurrent writer past the threshold independently re-ran COUNT(*) + DELETE — each self-correcting, but adding avoidable DB load exactly when a bulk sync makes the database busiest. A losing writer skips pruning; the 20% hysteresis means the next write over threshold retries.
  • Performance: The snapshot-retention cron reads each snapshot's creation timestamp from the option name (ez_ps_snapshot_{id}_{ts}) instead of json_decode()-ing every snapshot blob. As a side effect it now also prunes corrupt snapshots, which the old decode path skipped forever.
  • Vendor: Freemius SDK 2.13.2 → 2.13.4. Fixes a PHP warning under WP-CLI ($_SERVER['HTTP_HOST'] is now guarded with isset() — it's undefined in CLI context), a contact-form deprecation notice, and the add-on checkout regression that caused Freemius to unpublish 2.13.3. Adds payment-history currencies (ILS, CAD, AUD, PLN) and Pricing App v1.4.1 (thousands separators, default currency selection). Behavior note: the previously hardcoded 60-second API timeout is now the WP_FS__API_TIMEOUT constant, defaulting to 30 seconds — define it before the SDK loads to restore the old value if a slow connection makes license checks time out.

Version 1.1.37

  • Fix: The v1.1.35 Free-tier Plugin Role lock now also fires when a receiver has been contacted by an authenticated sender that hasn't yet completed a real content sync (e.g. Test Connection). Previous behavior only detected sync via the _ez_ps_last_synced_site post meta — which is written by Receiver::handle_sync() but NOT by test-connection pings — so a receiver that had auth-passed a sender but hadn't synced any content yet fell outside the lock's coverage. Now a lightweight ez_ps_receiver_last_contacted_at option is set on the first successful Receiver::verify_request() (any authenticated request from any sender), and Admin::has_incoming_sync_history() checks it as a fast path before falling back to the post-meta signal.
  • Fix: rest_reset_sync_origin() now also clears the ez_ps_receiver_last_contacted_at option along with the ownership meta and the API key. Without this, a receiver that had ever been contacted would stay locked into Receiver role forever, and the admin couldn't repurpose the site as a Sender post-Reset. The next authenticated request re-sets the option and re-engages the lock.

Version 1.1.36

  • Fix: Reset Sync Origin now also regenerates the API secret key, truly severing the current staging site. Previously, clicking Reset Sync Origin only cleared post-ownership metadata — the current staging (which still held the old API key) could immediately re-sync and re-claim ownership, silently defeating the reset. Now the key is rotated alongside the meta cleanup, so the current staging's HMAC signatures are instantly invalid. The new key is returned to the admin UI in the same flow the Regenerate Key button uses, so it can be copied to whichever staging the admin wants to reconnect.
  • UI: Reset Sync Origin description and confirmation dialog updated to explain both actions (ownership clear + key rotation). Rebuild instructions: copy the new key to the reconnecting staging site.

Version 1.1.35

  • Fix (Free tier): The Plugin Role selector on the Settings tab is now locked when a Free-tier site is currently a Receiver AND has already received at least one sync from an upstream Sender. Previously a Free-tier receiver could flip to Sender via the checkbox, which would silently orphan the sender's outgoing flow. The lock is enforced both in the UI (checkboxes render disabled with a lock explanation) and in the server-side sanitize (any submitted role change is rejected and existing storage returned). Premium bypasses entirely — with multi_role entitled, running both roles simultaneously is safe.
  • Fix: If disabled by the lock, the Receiver role is preserved on save via a hidden input (disabled checkboxes don't submit); the sanitize layer's early-return guard is defense-in-depth.

Version 1.1.34

  • Breaking (protocol): Completed the two-stage rebrand started in v1.1.20/v1.1.21 — renamed every internal identifier from the legacy WPS_* / wps_* / WPS\ / wps/v1 / class-wps-*.php / wp_wps_logs / _wps_* / wpsAdmin / .wps-* scheme to EZ_PS_* / ez_ps_* / EzPageSync\ / ez-page-sync/v1 / class-ez-ps-*.php / wp_ez_ps_logs / _ez_ps_* / ezPageSync… / .ez-ps-*. Sender and Receiver must upgrade in lockstep (REST namespace + HMAC payload fields changed).
  • Change: 13 class files renamed via git mv (class-wps-*.php → class-ez-ps-*.php, plus the shared-utilities trait); PHP namespace WPS\ → EzPageSync\; DB option prefix wps_* → ez_ps_*; post-meta prefix _wps_* → _ez_ps_*; log table wp_wps_logs → wp_ez_ps_logs; REST namespace wps/v1 → ez-page-sync/v1; HMAC payload fields x_wps_origin_chain/x_wps_domain_map/wps_version → x_ez_ps_*; JS globals wpsAdmin/wpsMetabox/wpsAdminColumns/wpsActiveTab/wpsElementorSyncAllowed/wpsMultiRoleAllowed → ezPageSync…; CSS classes .wps-* → .ez-ps-*; cron hooks + filter/action hooks + capability + settings group + transient prefixes + object-cache group + debug log filename + CSV export filename all shifted in lockstep.
  • New: Uninstaller::cleanup_legacy_wps_data() runs on every activation and idempotently wipes any leftover pre-rebrand data (wps_* options, _wps_* meta, wp_wps_logs table, legacy cron hooks). SQL LIKE patterns pipe-escape underscore + explicit ESCAPE '|' to prevent wildcard collision with unrelated plugin prefixes (e.g. WP Store Locator's wpsl_*, WP Shopping Cart's wpsc_*). Multisite cron cleanup runs inside the per-site loop since WP cron is site-scoped.
  • Deliberately unchanged: Yoast SEO's _yoast_wpseo_* meta, the vendored Freemius SDK, historical docs/ audit reports, plugin folder slug (ez-page-sync/), text domain (ez-page-sync), and Freemius product slug (already correct since v1.1.20).
  • Upgrade action: After update, WordPress does NOT auto-deactivate this time (main file path unchanged since v1.1.21). All plugin state resets — old wps_* options/meta are cleaned on activation, so you'll re-run through Settings once. Sync history and rollback snapshots taken pre-v1.1.34 are cleared.

Version 1.1.33

  • Security: Hardened the ACF field direct-write path — field name and label are now sanitized and length-capped at the write site (the field name could previously arrive unsanitized via the post_content blob fallback), and field settings merged from the incoming blob (type, instructions, default_value, conditional_logic, wrapper, etc.) are now type-checked against a whitelist before being re-serialized into the database.
  • Docs: The deliberate kses bypass on the content write path (required for Gutenberg unicode-escape fidelity) is now documented at every call site and in the Receiver class docblock, including the content trust model it relies on (HMAC + timestamp + nonce + IP whitelist + rate limit).
  • Docs: Marked the unreachable post_date/post_modified handling in the receiver as known-dead code with wiring instructions, so date sync isn't accidentally half-enabled.
  • Tests: New regression locks — post_content passes through verbatim (kses bypass invariant, Gutenberg unicode-escape preservation, invalid-UTF-8 stripping) and ACF direct-write bounds/type enforcement.

Version 1.1.32

  • Fix: Premium badge upgrade links no longer open in a new tab — target="_blank" and rel="noopener" removed from both the PHP premium_badge() helper and the JS-rendered badge in the sync list column.