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_idfor 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:ordata: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_enabledfilter 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 solinkfields configured with Return Value = "Link URL" transmit their full['title', 'url', 'target']array, andReceiver::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) insidegroup,repeater,clone, andflexible_contentcontainers while deduplicating envelope key discovery, and raw-decodes URL paths inReceiver::is_probable_media_url()beforewp_check_filetype(). - Fixed: First-Edit Baseline Snapshot Capture —
Sender::maybe_capture_baseline_snapshot()hookspre_post_updateandelementor/document/before_saveto capture a local_ez_ps_last_sent_{dest}restore point on the first edit of an alreadyin_syncpage 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_optionscounters (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()rejectspost_parent_slugssegments outside[A-Za-z0-9_-]and valid UTF-8 percent-triplets (%80–%FF), blocks percent-encoded ASCII (%00–%7F), and fails closed whensanitize_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()includesacf-field-groupin the Sync Pages "All Post Types" query, filter dropdown, list-table columns, post-edit metabox, first-edit baseline capture, andReceiver::is_allowed_post_type()whenever ACF is active andcustom_post_typesis entitled (Premium), while keeping Free tier capped topage+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_slugschain. 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_FOUNDas 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%2fis rejected, so one segment cannot be decoded into two ancestors. Stored slugs are sent as saved (Servicesstill 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_acfoption. - Fix (style): Scoped dry run and discard button styles in
admin.cssto.ez-ps-admin-wrapto 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 CSSurl(...)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 torest_can_manage_securitypermission 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 bothEZ_PS_CONCURRENT_SYNCandEZ_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->poststo ensurepost_type = 'attachment'andpost_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
forceandsync_modeparameters.
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, andcatch (\Throwable)→EZ_PS_037. Guards: editor lock (EZ_PS_034), MySQLGET_LOCKmutex (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_mediafilter, default true). A URL-only stub is sent so domain rewriting still works; production plays from its local copy. Filter returnsfalseto 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 viahome_url()(subdirectory-install safe). CSSbackground-imageis not detected — documented limitation. - New: Media hash backfill on reuse — when the receiver reuses an existing attachment, it stamps
_ez_ps_media_hashfrom its ownmd5_file()if missing, so subsequent syncs and the preflight resolve via the fast hash path instead of the slow_wp_attached_filescan. - New: Ambiguous slug match detection (
EZ_PS_AMBIGUOUS_SLUG_MATCH,ez_ps_detect_ambiguous_slug_matchfilter, 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_existedto avoid flagging a just-created post. - New: Debug logging subsystem — file-based logging via
Logger::debug()towp-content/ez-page-sync-debug.php(always-on;ez_ps_debug_modecheckbox 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_Classobject (from a deactivated plugin's serialized data) caused a fatalTypeErroron 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 viaez_ps_muted_receive_observers. - Fix (discard): Error codes
EZ_PS_007,EZ_PS_030,EZ_PS_031,EZ_PS_033now 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_datacorruption from non-scalar map entries. - Fix (preview):
__PHP_Incomplete_Classobjects in production ACF/Yoast meta no longer fatal the Dry Run diff —normalize_diff_value()safely JSON-encodes objects viaget_object_vars(). - Fix (sender): WP_Error codes from the sender are now normalized (both
$body['code']and$body['data']['code']are uppercased before comparison), soEZ_PS_FORCE_REQUIREDandEZ_PS_CONCURRENT_SYNCresponses 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
bulkSyncandupdateBulkButtonJS 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 ofpre-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_Errorthrough oneabort_or_warn()seam — logged as awarning, returned in the responsewarnings, 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 fatalTypeError), 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_Erroror thrown) is now logged and recorded as a sync warning instead of being swallowed to a silentnull. A sync that completes with warnings is logged withstatus = warning(notsuccess), 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_attachmentscopes bypost_parentfirst 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, andattachmentIdkeys, not only"id". Media meta is unserialized before URL rewriting and re-serialized once byupdate_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 validatesacf_post_mapas a numeric ID→ID map, preserving keys and dropping nested / non-scalar values instead of coercing them to0/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 aWP_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 whenwp_update_post()itself reports aWP_Errorduring 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 returnsWP_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 flaggedacf_observed = false, andrevert_restore()skips ACF entirely instead of treatingacf: []as "the post had no fields" anddelete_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 (noOverwrite Mediaand a dedup hash match — the common outcome on a resync of an unchanged featured image, now thatfile_hashactually reaches the receiver) didn't report the staging attachmentidin 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, sourceurl, andfile_hashon featured/inline media items (idvalidated as a positive integer,file_hashvalidated as a 32-64 character hex string), plus the featured-imageremovedflag. These were previously stripped before reaching the receiver, so the existing hash-based dedup and staging→production attachment-ID remap logic inMedia_Sync— present but dead — is now live on every sync.file_hashis 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_datavalue (leftover Elementor meta with no layout JSON) now applies cleanly instead of aborting the sync asEZ_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 dropsJSON_PARTIAL_OUTPUT_ON_ERROR, so circular or non-finite (INF/NAN) meta fails the snapshot closed instead of silently storingnull/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/syncwithdry_runand 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_syncPremium feature; both sites need ACF active with matching field groups. Apply is fail-closed: values are written viaupdate_field()only, against an allow-list of known options pages, with no arbitraryupdate_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_optionswrite, 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
forceflag 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 realmanage_optionsand (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_existedso 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 separateaudit_detailfield; the REST response carries a generic message (EZ_PS_004). - Security:
Validator::sanitize_payload()guardschildren,acf_field_groups/bundled_field_groups, and nestedmetaagainst 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 uncaughtTypeError. - 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 byjson_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_messagesnow 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'spost_modified_gmthadn'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 Syncis 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 reportsEZ_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_contentread) — 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 oversizedIN ()that exceededmax_allowed_packetwould return no rows, making every child look new and mass-creating duplicate ACF field posts. Map keys fold case to match theutf8mb4_*_cicollation the query runs under, first-row-wins preserves theLIMIT 1semantics 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_LOCKadvisory lock. Previously every concurrent writer past the threshold independently re-ranCOUNT(*)+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 ofjson_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 withisset()— 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 theWP_FS__API_TIMEOUTconstant, 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_sitepost meta — which is written byReceiver::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 lightweightez_ps_receiver_last_contacted_atoption is set on the first successfulReceiver::verify_request()(any authenticated request from any sender), andAdmin::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 theez_ps_receiver_last_contacted_atoption 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_roleentitled, 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 toEZ_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 namespaceWPS\→EzPageSync\; DB option prefixwps_*→ez_ps_*; post-meta prefix_wps_*→_ez_ps_*; log tablewp_wps_logs→wp_ez_ps_logs; REST namespacewps/v1→ez-page-sync/v1; HMAC payload fieldsx_wps_origin_chain/x_wps_domain_map/wps_version→x_ez_ps_*; JS globalswpsAdmin/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_logstable, legacy cron hooks). SQL LIKE patterns pipe-escape underscore + explicitESCAPE '|'to prevent wildcard collision with unrelated plugin prefixes (e.g. WP Store Locator'swpsl_*, WP Shopping Cart'swpsc_*). 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, historicaldocs/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"andrel="noopener"removed from both the PHPpremium_badge()helper and the JS-rendered badge in the sync list column.