=== Miroir Local Sync ===
Contributors: tommybordas
Tags: migration, deployment, database, staging, sync
Requires at least: 6.0
Tested up to: 7.1
Requires PHP: 8.1
Stable tag: 1.2.0
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html

Bidirectional sync between a local WordPress and one or more production sites: push, pull, search-replace, per-component file and content deployment.

== Description ==

Miroir keeps a local WordPress environment and one or more remote sites in sync, with an interface designed for non-technical users.

The same plugin embeds two roles: an orchestrator on the local site and an agent on each remote site. They communicate over the WordPress REST API (authenticated with Application Passwords) and, optionally, SFTP.

Features:

* Push (local to production) and Pull (production to local) of the database.
* Safe search-replace that handles PHP-serialized data, escaped JSON (Gutenberg blocks) and `wp_options`.
* Automatic snapshot and rollback before a database import.
* Comparison screen with a per-component diff and data-loss warnings.
* File deployment of specific themes and plugins.
* Editorial content merge by post type (GUID-matched, no deletion; WooCommerce excluded).
* One-click deployment shortcuts.
* REST (chunked) and SFTP transport.
* Live data protection on push: WooCommerce orders, customers, users, comments and reviews on the target keep their current state.
* Scheduled sync (WP-Cron): refresh your local copy from production automatically.
* WP-CLI commands (`wp miroir status|compare|push|pull`) for CI/CD and system crons.
* Webhook notifications (Slack, Discord, custom endpoint) in addition to email.
* CSV export of the activity log.
* File rollback: themes and plugins are snapshotted before deployment and can be restored, alongside database snapshots, from the Snapshots view.
* Streaming database import (constant memory) and one-pass search-replace, safe on hosts without shell access.
* SSH host key pinning on first SFTP pairing, and checksum-verified transfers.
* Activity log with per-site filters and pagination; keyboard- and screen-reader-friendly screens.

== External services ==

Miroir only ever talks to servers you configure yourself. **No data is sent to Sumotori or to any service belonging to the plugin's author.** The plugin contains no telemetry, no analytics, no licence check and no phone-home of any kind. If you pair no site and configure no SFTP host or webhook, Miroir makes no outbound request at all.

**1. The remote WordPress sites you pair with**

Address: the URL you type yourself when adding a site (for example `https://example.com`). There is no default and no suggested host.

What is sent: your WordPress username and Application Password for that site (HTTP Basic Auth, over the connection scheme of the URL you gave); database dumps produced from the current site; ZIP archives of the themes, plugins or media folders you selected; JSON bundles of the post types you chose to merge; and, when you ask for it, a ZIP of the Miroir plugin itself in order to update the remote agent.

What is received: the remote site's status, its list of themes, plugins, media and content, its database dump when you pull, and the result of each operation.

When: only on an explicit action of yours (a push, a pull, a comparison, a deployment shortcut, an agent update) or on the schedule you enable yourself in Settings → Scheduled sync. Nothing is transmitted in the background otherwise.

This exchange is the entire purpose of the plugin: the site you pair with is your own, and the terms that apply are those of its host.

**2. An SFTP host (optional)**

If, and only if, you fill in the SFTP settings of a profile, Miroir connects to the host, port and account you entered, to deposit or fetch the transfer files (database dumps, ZIP archives) instead of passing them through the REST API. The host key is pinned on first connection and a changed key aborts the transfer. Leave these fields empty and no SFTP connection is ever made.

**3. A notification webhook (optional)**

If you fill in a webhook URL in Settings, Miroir sends one HTTP POST to that address after each real push or pull. The body is JSON and contains only: the source site address, the event name, the operation type, the target site name, whether it succeeded, and the list of steps with their status. No credentials and no site content are included. Leave the field empty and nothing is sent.

== Installation ==

1. Install and activate Miroir on the remote (production) site.
2. Install and activate Miroir on the local site.
3. On the local site, open Miroir, add the remote URL and approve the connection.

== Frequently Asked Questions ==

= Does this replace a backup plugin? =

No. Miroir is a development and deployment tool, complementary to a general backup solution.

= Is WordPress Multisite supported? =

Not yet. Miroir targets standard (single-site) WordPress installs on both ends: the database engines (full dump/import, search-replace) are not designed for the shared tables of a network. To prevent any data loss, Miroir disables its operations when it detects a Multisite network. Syncing with multiple *separate* production sites is fully supported.

= Is WooCommerce supported? =

Yes for full database sync. Per-type content merge intentionally excludes WooCommerce products and orders.

= Can I push my local database without overwriting new orders on the shop? =

Yes. Enable "Protect live data on the target" in the push wizard (on by default when WooCommerce is active): orders (HPOS), order items, sessions, analytics lookups, users and comments/reviews are excluded from the push, so the live shop keeps them. Note: if the site still stores orders in wp_posts (legacy mode, HPOS disabled), those specific rows cannot be protected at table level.

= Can syncs run automatically? =

Yes. Settings → Scheduled sync lets you run a pull (or push) hourly, twice daily, daily or weekly via WP-Cron, with email/webhook notification. For large sites, prefer a real system cron calling WP-CLI, e.g.:

`0 3 * * * cd /path/to/site && wp miroir pull --profile=<id> --db --yes`

= Is there a WP-CLI integration? =

Yes: `wp miroir status` (connected sites), `wp miroir compare --profile=<id>` (data-loss alerts), `wp miroir push|pull --profile=<id> [--db --themes --plugins --media] [--dry-run] [--protect] [--yes]`.

= Will a push delete data on the target? =

A full database push replaces the target database, so rows that exist only on the
target are removed. The comparison screen warns about this before you confirm, and
a snapshot is taken so a rollback is possible. Content merge (by post type) and file
deployment are additive and never delete.

= What if my host has no SSH/SFTP? =

Everything works over the REST API (chunked transfer). SFTP is optional and only
speeds up file transfers on hosts that provide SSH.

= Are my credentials safe? =

Yes. Application Passwords and SFTP secrets are encrypted at rest (AES-256-GCM) and
never logged in clear text. The agent only answers users with `manage_options`.

= Will large sites time out? =

The database search-replace is resumable (processed in time-bounded chunks), file
transfers are chunked, and native mysqldump/mysql are used when available.

= Are my dumps reachable from the web? =

Miroir stores its dumps and snapshots in a folder under `wp-content/uploads` whose
name carries a random key, protected by an `.htaccess` file and an `index.php`. On
Apache that is enough. **On nginx the `.htaccess` is ignored**, so the files would be
downloadable by anyone who guessed the address. The system status screen warns you
when it detects nginx. Add this to the server configuration, on both sites:

`location ~* /uploads/miroir-local-sync-[^/]+/ { deny all; return 404; }`

A dump contains everything the database contains — password hashes, e-mail
addresses, orders. This block is not optional on nginx.

== Screenshots ==

1. Dashboard: connected sites and one-click deployment shortcuts.
2. Comparison screen with per-component diff and data-loss warnings.
3. Global settings and system status.

== Changelog ==

= 1.2.0 =
* Fixed: a failed import that used a different table prefix no longer leaves its half-imported tables behind after the rollback. Only tables the import itself created under the source prefix are removed — never anything that existed before, so a second WordPress install sharing the database is safe.

This release repairs the safety net a database push relies on, closes the ways an operation could run twice or write outside its own folder, and finishes the screens that had been left half-wired. **Update the Agent on every paired site**: reliable rollback, file restore and single-pass search-replace all need 1.2.0 on both ends, and Miroir now says so on the operation screen when the remote side is too old.

Safety net

* Automatic rollback after a failed push works again. The import renames the storage folder to match the target's own key, so the snapshot taken minutes earlier was no longer where the rollback looked for it: every restore ended on "Snapshot not found" and the target stayed on the half-written database. The Agent now returns the snapshot's absolute path, and the restore falls back to searching the renamed folder.
* A failed push now also puts the files back. Deployment takes a snapshot of the components it is about to replace, and the rollback restores them. Until now a theme overwritten halfway through an operation stayed overwritten, with nothing on screen saying so; when the components are too large to archive beforehand, the report says that plainly instead of implying a rollback that will not happen.
* An import that stops halfway no longer blocks its own rollback. A dump is replayed statement by statement, and mysqldump writes its data inside `LOCK TABLES … WRITE` blocks: an import that failed inside one of them left the connection holding the lock, so the restore that followed died on its first `DROP TABLE` — the safety net gave way at the exact moment it was needed. Table locks are now released whatever happens, and new dumps no longer contain any.
* New Snapshots screen, per site: the restore points held on each side, their date and size, with Restore (confirmation by site name) and Delete. Snapshots existed but there was no way to see or use one outside an automatic rollback.
* A push whose two sites have nothing to rewrite is refused before the snapshot, not after the import. The operation used to run to the end and leave the target carrying the local addresses.
* Search-replace applies every address in a single pass. Replacing pairs one after another could rewrite a URL twice and produce "example.local.local"; the same value was also counted twice in the report.
* A search-replace that cannot write a row no longer reports success. Failures are counted, and past a threshold the operation stops and says how many rows still carry the old address — so the rollback can still be triggered.
* `wp_options` is processed in the first slice of a search-replace. Between the end of the import and the end of a resumable search-replace, the target was serving the local site's addresses for minutes; that window is now seconds.
* The plugin's own options are left out of the search-replace. They were already kept out of the import, but not out of the rewrite that follows: on a pull the reversed pairs rewrote the remote site's address stored in the profile, so the very next call — the `/unlock` that ends the operation — went to the wrong host. The pairing came back as an authentication error and the remote site stayed locked until the lock expired. A filter, `sumotori_sync_search_replace_skip_options`, lets an add-on protect its own options the same way.

Refusing what should never have been accepted

* A table prefix or a table list that does not match the expected shape is refused, and the answer says what was wrong. A crafted request could previously name tables outside the current prefix, contradict the live-data protections, or make the import rename tables it should never have touched. Nothing is renamed unless the dump really contains that prefix's `options` and `posts` tables.
* Every file path used by a transfer is checked against the plugin's own storage folder before anything is read, written or deleted.
* Incoming archives are bounded: an archive that announces more data than the disk can take, or whose compression ratio no real set of files could produce, is refused before extraction rather than filling the disk.
* Symbolic links are no longer followed when building an archive. A link pointing outside the site could pull an arbitrary part of the disk into a deployment; links are now skipped, counted, and named in the result message.
* SFTP transfers carry a sha256 checksum, verified on arrival. A truncated or corrupted deposit is refused, and nothing is imported.
* The SSH host key of an SFTP server is pinned on first connection. A key that changes afterwards aborts the transfer and says what it may mean; the profile's SFTP settings offer "Forget the fingerprint" for a legitimate server change.
* Stored credentials are re-encrypted with a derived key bound to the plugin version. Existing profiles are migrated transparently the first time they are read — nothing to redo. When the site's WordPress salts have changed since pairing, the message now says the credentials can no longer be decrypted and that the site must be paired again, instead of reporting a connection failure no amount of retrying could fix.
* Pairing over `http://` is refused, because the application password would travel in clear text. A local address is still accepted, and a checkbox lets you accept the risk explicitly; sites already paired over http keep working and are flagged on the Sites screen.

One operation at a time

* Two operations can no longer run on the same site at once. Starting a second push while one is running is refused, naming the operation that holds the lock and how long it has been running, instead of two imports interleaving on the same database.
* The lock records which operation owns it: only that operation releases it, and "Force unlock" now clears both ends. An interrupted operation no longer blocks the site until a transient expires.
* The lock survives the import that used to erase it. It is now kept, along with your profiles, settings, shortcuts and logs, when the incoming database replaces `wp_options`.
* A long import keeps the lock alive while it works, so a slow operation is no longer declared dead and restarted underneath itself.

Database engine

* Databases of any size import without loading the dump into memory. The dump is read and replayed statement by statement; a 2 GB dump no longer needs 2 GB of memory on a host without `mysqldump`. Dumps using `DELIMITER` or MySQL conditional comments are read correctly.
* An import that fails on a statement says which one: its rank in the dump and its first characters, instead of a bare SQL error with no way to find the cause.
* Note: **database views and triggers are not transferred.** They were being dumped as if they were tables, which produced a dump that could not be imported at all — and therefore a snapshot that could not be restored. Views are now left out and named in the report, so you can recreate them on the other side.
* Note: **a table prefix that does not end in an underscore is not reconciled.** Tables are then imported under their source names and the operation says so, rather than renaming tables on a guess.

Content merge

* An item can no longer be given the type of another. Matching is now constrained to the post type, so a custom post type sharing an identifier with a post is skipped and named, instead of quietly turning one into the other.
* Modification dates survive the merge. Items arrived stamped with the time of the sync, which made every later comparison show the target as "recently edited".
* Featured images follow. When the media does not exist on the other side, the item is named in the report and you are told to sync media first — instead of arriving silently without its image.
* Items whose author does not exist on the target are named, with the account they were reassigned to.
* Note: **metadata whose key starts with an underscore is still transferred by default.** That is where ACF, Meta Box and Yoast keep their field references, and excluding it would break those merges. Settings → Protected metadata offers an allowlist, or full exclusion, for sites that need it.

Screens

* An operation that stops answering says so. The progress card used to spin forever, with no way to tell whether the operation was still running: it now reports the silence after 90 seconds, stops polling, and points to the Logs. Losing the network shows an inline error instead of a browser alert box.
* The content selection now works in both directions. The checkboxes were displayed on a pull, but no button ever ran the selection.
* A batch sync that fails on one type says "Finished with errors: 1 of 3", marks the failing row and re-enables the button, instead of concluding with a check mark.
* An operation whose comparison could not be run no longer starts on trust. The screen states that the two sites could not be compared, offers to analyse them there and then, and requires the site name to be typed before running without a loss check.
* Confirmation prompts say what is deployed, where, what is lost and what is not, instead of asking a generic question.
* Updating the Agent no longer reloads the page: the version badge updates in place and the result message stays readable.
* Logs: a Site column, filters by level and by site, pages of 50, and a "Clear the log" action. The CSV export now opens with its accents intact in Excel.
* Accessibility: contrast raised on badges and secondary labels, the information button is a 24 px target with a visible focus ring, live regions announce progress without re-reading the whole card, and keyboard focus returns where it was after a refresh.
* Step names in reports and e-mails are translated ("Connection", "Search-replace") instead of showing their internal identifier.
* On nginx, the system status screen warns that the `.htaccess` protecting the storage folder has no effect, and gives the `location` block to add (see the FAQ).
* An invalid notification e-mail or webhook URL is now refused with an explanation, instead of being saved as a notification that would never be sent. Deleting a site also disarms any schedule pointing at it.

WP-CLI and scheduled runs

* `wp miroir push|pull` runs the same data-loss check as the screen and asks for confirmation, listing what it found. `--yes` keeps its meaning (warn, log and continue), `--strict` makes those warnings fatal, and `--no-loss-check` skips the check outright.
* Command-line and scheduled runs take the same lock as the interface, send the same notifications, and name their steps in plain words.

= 1.1.24 =
* Fixed: media with accented filenames arrived on the target under a mangled name and returned 404, even though the file was there. Archive entry names were being re-decoded as CP437 on extraction, turning "d’écran" into "dΓÇÖ╠ücran". Names are now read as the raw UTF-8 they were written in.
* Fixed: macOS stores filenames in decomposed Unicode while the database records the composed form. The two resolve alike on macOS but not on Linux, so accented media could land under a name their own URL did not match. Archive entries are now normalized.
* The media card no longer reports "up to date" when both sides hold the same number of files but a different total size — a sign that files were replaced, which an additive transfer does not carry over.
* Logs: the date column no longer wraps onto two lines, and the header names the timezone so an hour is never misread on a site left on UTC.
* Completed the French translation.

= 1.1.23 =
* Content syncs now name what they touched — created and updated items are listed by title, in the result message and in the log, instead of bare counts.
* When the target holds items the source does not, they are now named and explained. A content sync merges and never deletes, so that gap could never close; the screen showed an unexplained difference that repeated syncing would never resolve.
* Fixed: a content sync could create a duplicate of a page or post instead of updating it. Items are matched on a normalized identifier because search-replace rewrites GUIDs with the site URL, but the merge still compared the raw value and so failed to find the existing item. The duplicate was then invisible — it collapsed into one index entry — leaving a permanent, unexplained gap in the counts.
* Duplicates already present on the target are now detected and named, with the reason and what to do about them.

= 1.1.22 =
* A category no longer claims to be "up to date" while the rows inside it report content edited on the target. It now says how many items were modified there, so the summary and the detail agree.
* The comparison header states how old the figures are, and says "analyzed just now" after a refresh — previously nothing distinguished freshly computed numbers from cached ones, so refreshing looked like it did nothing.
* Following the refresh link without JavaScript now really re-runs the analysis instead of re-reading the cache.
* Shortened the badge legend to a single line.

= 1.1.21 =
* The amber badge on the comparison table now explains itself. Its tooltip states the consequence — these items were changed directly on the target, syncing this way would overwrite them — and a legend under the table says the badge is a warning, not an error, and that the item count can read 0 while the content itself differs.

= 1.1.20 =
* Fixed: deleting components on the target always failed with a bare "Error" and left the button stuck on "Operation in progress…". The request was sent malformed, so WordPress never routed it to the plugin.
* Fixed: selecting several components at once sent them as a single run-together name, so none matched and nothing was deleted.
* An expired session during an action now says so, instead of showing an error with no explanation.

= 1.1.19 =
* Fixed: a full database push could leave the target unreachable, every later connection being refused, and the automatic rollback unable to run. WordPress only attempts application-password authentication when the site has recorded that it uses them; that record travelled with the database, so a source that had never created one switched the feature off on arrival and locked the target out even though the credential itself was intact. The setting is now preserved along with the identity.
* Fixed: the identity was restored while the object cache still described the pre-import database, so part of the restore silently did nothing. The cache is now cleared before restoring, not only after.

= 1.1.18 =
* Updating the remote Agent now shows what it is doing — checking, packaging, sending (with a byte counter), installing, verifying — instead of a frozen button. The plain link still works without JavaScript.

= 1.1.17 =
* Sync several content types at once: tick Posts, Pages or any custom type in the comparison and run them with a single button, one after another, with per-type progress and result. Each type still transfers only what actually changed.

= 1.1.16 =
* A content sync now always states its outcome, including "already up to date: nothing to transfer". It previously reloaded the screen unchanged, which read as a failure.
* Reworded the target-activity badge so it reads as what it is — a warning that the target was edited recently — rather than a number of items waiting to be synced.

= 1.1.15 =
* Themes and plugins present on the target but absent from this site can now be deleted: tick them in the comparison, then confirm. Never implicit — a sync still adds and updates only. Active themes and plugins are refused, and deletion is confined to the theme and plugin directories.
* The confirmation step no longer asks for the scope a second time: it states what was chosen and offers a way back to the comparison to change it.

= 1.1.14 =
* Content sync is now differential: the plugin asks the target for a compact index and transfers only the items that are missing or older there, instead of the whole content type. Matching ignores the site part of the GUID, which search-replace rewrites.
* The per-type sync button no longer appears only when the counts differ: two sites can hold the same number of pages with different content, and the button was simply missing.

= 1.1.13 =
* New "Reconnect" action on a site: re-runs the pairing without deleting the profile, so a broken credential no longer forces you to recreate the site and lose the deployment shortcuts pointing at it.

= 1.1.12 =
* The comparison screen now shows editorial activity on the target: how many items of each content type changed there in the last 7 days, and when. Counts alone never revealed that a sync was about to overwrite fresher work.
* When an import invalidates the connection credential, the failure now says so and how to recover, instead of a bare 401 on the next step.

= 1.1.11 =
* Rollback is no longer given up on the first "busy" answer: it waits for the operation still running on the target, and the lock now reports which operation holds it and for how long.
* Live-data protection works across different table prefixes: the agent translates the incoming patterns to its own prefix instead of silently protecting nothing.
* Database reads are ordered by primary key: LIMIT/OFFSET without ORDER BY could skip or duplicate rows in exports and in the resumable search-replace.
* Live-data protection extended to download permissions, saved payment tokens and the Action Scheduler queue.
* SFTP transfers verify the transferred size on both upload and download, like the REST transport already did.

= 1.1.10 =
* Security: the plugin's own working folder (database snapshots, transfers) is no longer swept into a "media" push. Full SQL dumps — password hashes, e-mails, orders — were being copied onto the target site. Folders left by earlier installs are excluded too.
* Search-replace now covers every URL variant: http and https, protocol-relative //host, and www/non-www. Only one form was handled, so a migration silently left the others pointing at the source site.

= 1.1.9 =
* Selective rollback: a failed push no longer restores the tables it had deliberately protected. Those tables were never touched by the import, so replaying them from the snapshot rolled them BACK, destroying orders and accounts created during the operation. The snapshot stays complete; only the restore is filtered.

= 1.1.8 =
* "Protect live data on the target" is now checked by default in the push form, whether or not WooCommerce is installed: overwriting the target's users, comments and orders should be a deliberate choice. This aligns the UI with WP-CLI (--protect was already the default) and with scheduled runs.

= 1.1.7 =
* A site acting as an Agent now says so: the Sites screen shows which site controls it, with which account and when it was last contacted, instead of only offering to add a site (which suggested nothing was connected).

= 1.1.6 =
* Fixed data corruption in the PHP-fallback export: percent signs were written to the dump as wpdb's internal placeholder token (wpdb::_real_escape applies add_placeholder_escape, which only prepare() undoes). Any value containing a "%" — permalink_structure, theme settings — was corrupted on the target. Existing corrupted values can be repaired with a search-replace of the token back to "%".

= 1.1.5 =
* The agent now preserves the identity that authorizes an import (REST user, its Application Passwords, capabilities and session) across the import, and flushes the object cache afterwards. A database push that includes users/usermeta can no longer lock the plugin out of its own operation (401 on the following search-replace/rollback).

= 1.1.4 =
* Table prefix reconciliation: when source and target use different table prefixes, imported tables are renamed to the target prefix (and prefixed option/meta keys realigned) instead of silently creating ghost tables. The agent and export endpoints now announce their prefix.
* Correct connection charset (wpdb-determined, e.g. utf8mb4) used for dump/import instead of the raw DB_CHARSET constant: no more silent loss of 4-byte characters.
* PHP-fallback export now fails loudly on SQL read errors and short writes instead of producing a truncated dump; empty safety snapshots abort the operation before any destructive write.
* A mid-dump SQL failure of the native mysql import is no longer replayed by the PHP fallback (the original error is reported); rollback is no longer triggered by a pre-import transfer failure.
* Search-replace: serialized PHP 8.1 enums and uncloneable objects no longer crash the engine (left intact).

= 1.1.3 =
* Live progress during every long phase: snapshot, database export and import, compression, remote deployment, extraction, search-replace and final check each show a running step, and every transfer channel (REST and SFTP, upload and download) reports transferred bytes against the total. Progress could previously expire mid-operation, leaving the UI stuck on "waiting…".
* The progress view no longer stays on "pending…" while the server has not published a step yet: after a few seconds it states that the operation is still running.
* Clearer error when the remote Agent is missing: an unknown REST route or a non-JSON answer now says the plugin is not installed/activated on the remote site, instead of a generic "invalid remote response".

= 1.1.2 =
* Redesigned the live progress view (Push/Pull, shortcuts, comparison analyses): status chip with elapsed time, readable step labels, dimmed skipped steps and highlighted errors.

= 1.1.1 =
* Fixed the one-click Agent update: it now targets the plugin folder actually used on the remote site (announced by the Agent, wp.org slug as fallback) instead of a hardcoded name, and verifies the remote version after deployment (a failed update now reports failure instead of success).

= 1.1.0 =
* Background operations: Push/Pull now run in a detached server-side request (wp-cron spawn pattern) while the browser only polls progress, so strict shared-hosting proxies (60-120 s) can no longer kill a long sync. Automatic fallback to the previous synchronous mode when loopback requests are blocked.
* Scheduled shortcuts: each one-click deployment shortcut can now run automatically (hourly, twice daily, daily or weekly) via its own WP-Cron event, with anti-overlap lock and email/webhook notification. A clock chip on the dashboard shows the frequency.
* Settings screen reorganized into clear cards (Notifications, Database snapshots, Scheduled sync, Advanced), with the next scheduled run displayed, schedule fields dimmed while scheduling is off, and the live-data protection option shown only for push.
* Extension hooks for add-ons: sumotori_sync_run_args / sumotori_sync_after_run (every entry point), sumotori_sync_preset / sumotori_sync_after_preset, sumotori_sync_live_data_exclusions, sumotori_sync_webhook_payload, sumotori_sync_settings_sections / sumotori_sync_save_settings, sumotori_sync_site_menu.
* Typography cleanup across UI strings, readme and translations.

= 1.0.0 =
* Live data protection on push: new option (on by default with WooCommerce) that keeps the target's orders (HPOS tables, order items, sessions, analytics lookups, reserved stock), users and comments/reviews untouched: push your local work to a live shop without clobbering what happened since.
* Scheduled sync: run a pull (or push) automatically via WP-Cron (hourly/twice daily/daily/weekly), with anti-overlap lock, last-run status, and email/webhook notification.
* WP-CLI commands: `wp miroir status`, `wp miroir compare`, `wp miroir push`, `wp miroir pull`: for CI/CD pipelines and robust system crons.
* Webhook notifications (JSON POST) after each real operation, alongside email.
* Incremental comparison: per-file hashes are now cached (invalidated by size+mtime), making repeat analyses near-instant on large sites.
* Force unlock: new action in the site menu to release the Agent's operation lock immediately after an interrupted operation (previously a 30-minute wait).
* Daily cleanup of orphaned transfer files in the storage folder.
* CSV export of the activity log.
* Test suite for the search-replace engine (serialized PHP, escaped Gutenberg JSON, multibyte, idempotence, 21 assertions), runnable with `php tests/test-replace.php`.

= 0.5.1 =
* Compatibility: tested up to WordPress 7.1.
* Multisite safety guard: Miroir now detects WordPress Multisite and disables all operations (admin UI, AJAX, and Agent REST endpoints) with a clear message: the full-database dump/import and search-replace engines are not designed for a network's shared tables, so this prevents accidental data loss. Multiple separate production sites remain fully supported.
* Clarified the plugin description ("multiple separate production sites") to avoid any confusion with WordPress Multisite.

= 0.5.0 =
* Internationalization: all source strings are now in English (the WordPress.org standard), so the plugin can be translated on translate.wordpress.org and the directory page reads consistently with the code.
* Bundled French translation (fr_FR) in /languages: the admin interface and the activity log display in French on French sites out of the box.
* Loads the bundled text domain on init (load_plugin_textdomain) so translations work on WordPress 6.0–6.6; 6.7+ auto-loads them.
* Included a translation template (miroir-local-sync.pot) for further locales.

= 0.4.11 =
* Progress transients: the literal prefix sumotori_sync_prog_ is now visible at every set_transient() call site (no more dynamic keys), so all options/transients are verifiably prefixed.
* Renamed the JS localized object MiroirSync to sumotori_sync_cfg (consistent unique prefix).
* Hardened storage confinement: chunk writes (Agent) and downloads (Transport) now refuse any path outside the protected storage folder under wp_upload_dir() (uploads/miroir-local-sync-{key}/). No data is ever written inside the plugin folder.

= 0.4.10 =
* Removed ini_set('display_errors') from the AJAX guard (no error-reporting changes in production code).
* Progress transient key is now force-prefixed with sumotori_sync_ in the push/pull setters.

= 0.4.9 =
* Storage folder in uploads now uses the plugin slug prefix (miroir-local-sync-<random>).

= 0.4.8 =
* Portability: use WP_PLUGIN_DIR and get_theme_root() instead of hardcoded WP_CONTENT_DIR paths. Correct readme contributor.

= 0.4.7 =
* Renamed to Miroir Local Sync (slug miroir-local-sync) for a distinctive, descriptive name.

= 0.4.6 =
* Text domain aligned to the plugin slug (miroir). Plugin Check fixes: escaped CTA button attribute, translators comment, composer.json kept in the package.

= 0.4.5 =
* Security hardening: search-replace unserialize restricted to stdClass (no object injection); transfer chunks now capped (size guard against disk-fill); manual connection validates the URL like the auto flow.
* i18n: Push/Pull button label no longer hardcoded after an operation.

= 0.4.4 =
* After synchronizing a component, its row status flips from "modifié" to "à jour" (kept visible until the next refresh).

= 0.4.3 =
* The site "⋯" menu now closes on outside click or Escape.
* Removed the per-component "Détails" button (the (i) panel covers it).

= 0.4.2 =
* Comparer: clickable (i) on each component reveals file/size details below the row; "N components up to date" collapsible to find any already-synced component.
* Per-component detail now includes total size (LOCAL/PROD files + size).

= 0.4.1 =
* Per-component "Synchroniser" button on the Comparer page: push a single plugin/theme to prod in delta, with a live per-phase spinner.
* Component versions shown per side (LOCAL vX · PROD vY); file-count detail moved to an (i) tooltip to keep rows clean.
* Consistent "Synchroniser" terminology across components and database.
* Manifest hardening: version "0" handled, null-safe count/size reads.

= 0.4.0 =
* Delta deployment: one-click shortcuts now send only changed files (hash diff vs the remote), ~90% less data on the wire.
* Comparer page redesigned into per-category cards: including in the sync and expanding the detail are now decoupled (inspect without checking).
* Local and remote analysis run in parallel (~2x faster), with live per-phase progress.
* Lightweight component signature (version + size + file count) for an instant overview; exact per-file hashing kept for drill-down and deployment.
* Dashboard redesigned: centered layout, compact collapsible "add a site" tile, denser sections.
* Fixes: shortcut edit/delete (id casing), Comparer scope now actually filters, raised memory limit for heavy operations.

= 0.3.0 =
* Editorial content merge by post type.
* One-click deployment shortcuts.
* Per-component file deployment and one-click agent update.

= 0.2.0 =
* File synchronization, SFTP transport, per-component diff, AJAX progress.

= 0.1.0 =
* Initial connection flow, database push/pull, search-replace engine.

== Upgrade Notice ==

= 1.2.0 =
Major safety release: automatic rollback fixed, input validation, atomic locks, one-pass search-replace, file rollback. Update Miroir on BOTH the local site and every remote site ("Update the Agent" on the site card) to get the reliable rollback.
