You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Once Issue #1149 ships the network export bundle format, this issue adds the matching network import flow: a single-ZIP upload that recreates an entire network on the target install with selective site inclusion.
The existing single-site import modal (render_import_site_modal() in inc/site-exporter/class-site-exporter.php:1469) only handles a one-site bundle and assumes the target is a working multisite (or single-site) install with the plugin already active. This issue extends the import path to detect a network bundle (manifest-keyed) and orchestrate restoring it.
Status
Blocked by #1149 — bundle format is defined there. Do not start implementation until #1149's bundle format is merged or at minimum frozen in PR. Design work (UI mockups, function shape) can begin in parallel.
Scope (this issue)
Detect bundle type at upload: presence of network.json at ZIP root → network bundle; presence of *.json site-meta in ZIP root → existing single-site bundle. Reject mixed/unknown formats with a clear error.
Read network.json.included_blog_ids from the uploaded ZIP without extracting the whole archive (use ZipArchive::getFromName('network.json')).
Show a checklist of sites with their source URLs + post counts (from network.json if recorded, otherwise the per-site *.json), defaulting all-checked.
Per-site optional URL override: Original URL → New URL text field per row, with a "Map all by prefix" helper (e.g. source *.source.example.com → target *.target.example.com).
Refuse to import zero sites (must include at least one).
Import orchestration:
Validate network.json.format_version — refuse versions newer than what this importer knows.
Restore global wp_users / wp_usermeta from users.csv / usermeta.csv with ID remapping if the target already has users (default: skip-if-exists by user_login + user_email, with a manifest-recorded id_map for downstream remapping).
Restore network tables from network.sql with the new network's domain/path substituted into wp_blogs / wp_site rows.
For each selected site: call into the existing per-site import path (\TenUp\MU_Migration\Commands\ImportCommand::all()) against sites/{blog_id}/, passing the new-URL override and the user id_map.
Apply main-site reassignment: if network.json.designated_main_site_blog_id != 1, swap so the chosen site becomes blog_id = 1 on the target (or update wp_site.blog_id + wp_sitemeta.main_site accordingly — implementer to decide cleanest approach).
Restore wp_sitemeta for the whitelisted keys recorded in network.json.sitemeta_keys_restored.
Move wp-content/{plugins,themes,mu-plugins,uploads}/ into the target, network-activating plugins listed in network.json.network_active_plugins.
Background-run support — same transient-driven pattern as wu_exporter_import().
Merge-into-existing-network. The importer replaces the network's site list; merging across two live networks is a separate problem with hairier ID-collision semantics.
Cross-version migrations (e.g. WP 5.x bundle into WP 8.x target). Refuse if network.json.source.wp_version differs by a major version from the target — flag for user confirmation, do not silently proceed.
Files to modify
Path
What
inc/site-exporter/class-site-exporter.php
Add register_network_import_form(), render_network_import_modal(), handle_network_import_modal(). Extend maybe_handle_import() to dispatch to network-import path when manifest present.
Likely no changes — the per-site import is called as-is. If the user id_map needs to be threaded through, do it via $assoc_args rather than editing this vendored file. The vendored library follows upstream — minimise local changes.
tests/WP_Ultimo/Site_Exporter_Test.php (or new Network_Importer_Test.php)
Tests as listed under Verification.
Reference patterns to copy
Existing single-site import:inc/site-exporter/class-site-exporter.php:1540-1578 (handle_import_site_modal()) and :2096-2168 (handle_site_import()).
Per-site import entry:inc/site-exporter/mu-migration/includes/commands/class-mu-migration-import.php:400-664 (ImportCommand::all()). Call this once per selected sites/{blog_id}/ extracted directory.
Pending-import async pattern:inc/functions/importer.php:84 (wu_exporter_get_pending_imports()) and the wu_pending_site_import_* transients.
Zip inspection without full extraction: standard ZipArchive::open() + getFromName('network.json'). The codebase uses PHP's built-in ZipArchive directly — do not pull in a new dependency.
Implementation notes / gotchas
User ID remapping is the hard part. When the target already has users, you can't just LOAD DATA INFILE-style insert source IDs — they will collide. Strategy:
For each row in users.csv: look up by user_login first, fall back to user_email. If found → record source_id → target_id in id_map. If not found → insert with a fresh AUTO_INCREMENT id and record the mapping.
Then process usermeta.csv, rewriting user_id via the id_map. Capability rows ({$prefix}{blog_id}_capabilities) also need their meta_key blog-prefix rewritten to the new blog IDs once those are assigned.
Pass the id_map into the per-site import calls so wp_posts.post_author, wp_comments.user_id, etc. get rewritten too. mu-migration's per-site path already supports a users_map.json file (see class-mu-migration-import.php:522-540) — write the network-level id_map into each per-site bundle as users_map.json before calling import.
Blog ID remapping. The target's blog_id AUTO_INCREMENT is independent of source. After inserting each new row into wp_blogs, capture the new ID and add to the id_map (source_blog_id → target_blog_id). Capability meta_key rewriting (point 1) depends on this map being built first.
Main-site reassignment on import. WordPress hardcodes assumptions about blog_id = 1 being main in many places. Two options:
(A) During wp_blogs restore, insert the designated main site first so it gets blog_id = 1 on the target, and shift the others. Cleanest for downstream code.
(B) Insert in original order, then update wp_site.blog_id and get_main_site_id() filters. Brittle — many plugins query blog_id = 1 directly.
Recommendation: (A). Document the chosen approach in the PR.
network.json.format_version handling. Refuse format_version > 1 with a clear error. Accept format_version == 1 only. Future versions should be additive; the importer should warn on unknown keys but not refuse.
wp_sitemeta restore is partial. Only restore keys listed in network.json.sitemeta_keys_restored. Skip keys already set on the target (don't clobber the operator's local config), or clobber and warn — implementer to propose, default to clobber-with-warning.
Network-active plugins. After the import, walk network.json.network_active_plugins and call activate_plugin($file, '', true) (network-wide). If the plugin file is missing under wp-content/plugins/, skip with a notice — do not abort the whole import.
mu-plugins. Network exports SHOULD include mu-plugins/ because they're often essential for the network's behaviour. Conflict policy: never overwrite existing mu-plugins on the target — log and skip per file.
DB transaction. Wrap each per-site import in mu-migration's --mysql-single-transaction flag (default for the network import, even though it's optional in the single-site flow). The whole-network import should NOT be a single transaction (locks too long); per-site is the right granularity.
Idempotency / partial failures. If site 3 of 7 fails, do not roll back sites 1-2. Mark the import as partial in the response, list which sites succeeded/failed, and surface a "retry failed sites" follow-up action.
Capability for the form:manage_network.
Upload size limits. The existing single-site importer calls reset_upload_limits() (sets PHP ini to 2048M). Network bundles can be much larger. Document the practical limit and recommend WP-CLI import path for large networks (the orchestrator function should be CLI-callable).
test_network_import_creates_blogs_for_selected_only() — uncheck blog_id 3, verify only 2/3 blogs created.
test_network_import_user_id_remapping() — pre-create a user with same email on target, verify remapping into id_map and that imported posts reference the target's user ID.
test_network_import_main_site_reassignment() — bundle's designated_main_site_blog_id == 3, verify target's main site is the imported blog_id 3 (and posts/options match).
Manual: import again into the same network (idempotency edge case) — should error cleanly with "site at this domain already exists" rather than silently corrupting.
WP-CLI path: implementer should expose a wp wp-ultimo import-network <zip> command (thin wrapper around wu_exporter_import_network()) and verify it works from CLI on wordpress.local:8080.
Open questions for the implementer (decide in PR description)
ID-collision policy for users: match by user_login first, fall back to user_email, or strict user_login-only? Loose matching is more user-friendly but can merge unrelated accounts that happen to share an email.
Sitemeta clobber policy: clobber-with-warning vs. skip-if-set. I lean clobber-with-warning since the operator initiated the import.
Main-site reassignment approach: (A) insert designated main first to get blog_id = 1, or (B) insert in original order and remap. PR should justify the choice.
Cross-version refusal threshold: refuse if WP major version differs? Differs by 2+? Always allow with warning?
Summary
Once Issue #1149 ships the network export bundle format, this issue adds the matching network import flow: a single-ZIP upload that recreates an entire network on the target install with selective site inclusion.
The existing single-site import modal (
render_import_site_modal()ininc/site-exporter/class-site-exporter.php:1469) only handles a one-site bundle and assumes the target is a working multisite (or single-site) install with the plugin already active. This issue extends the import path to detect a network bundle (manifest-keyed) and orchestrate restoring it.Status
Blocked by #1149 — bundle format is defined there. Do not start implementation until #1149's bundle format is merged or at minimum frozen in PR. Design work (UI mockups, function shape) can begin in parallel.
Scope (this issue)
network.jsonat ZIP root → network bundle; presence of*.jsonsite-meta in ZIP root → existing single-site bundle. Reject mixed/unknown formats with a clear error.network.json.included_blog_idsfrom the uploaded ZIP without extracting the whole archive (useZipArchive::getFromName('network.json')).network.jsonif recorded, otherwise the per-site*.json), defaulting all-checked.Original URL → New URLtext field per row, with a "Map all by prefix" helper (e.g. source*.source.example.com→ target*.target.example.com).network.json.format_version— refuse versions newer than what this importer knows.wp_users/wp_usermetafromusers.csv/usermeta.csvwith ID remapping if the target already has users (default: skip-if-exists byuser_login+user_email, with a manifest-recorded id_map for downstream remapping).network.sqlwith the new network'sdomain/pathsubstituted intowp_blogs/wp_siterows.\TenUp\MU_Migration\Commands\ImportCommand::all()) againstsites/{blog_id}/, passing the new-URL override and the user id_map.network.json.designated_main_site_blog_id != 1, swap so the chosen site becomesblog_id = 1on the target (or updatewp_site.blog_id+wp_sitemeta.main_siteaccordingly — implementer to decide cleanest approach).wp_sitemetafor the whitelisted keys recorded innetwork.json.sitemeta_keys_restored.wp-content/{plugins,themes,mu-plugins,uploads}/into the target, network-activating plugins listed innetwork.json.network_active_plugins.wu_exporter_import().Out of scope
index.phpthat runs WP install + this importer on a fresh server. This issue assumes the target is an already-running multisite install.network.json.source.wp_versiondiffers by a major version from the target — flag for user confirmation, do not silently proceed.Files to modify
inc/site-exporter/class-site-exporter.phpregister_network_import_form(),render_network_import_modal(),handle_network_import_modal(). Extendmaybe_handle_import()to dispatch to network-import path when manifest present.inc/site-exporter/class-network-importer.php(NEW)inc/functions/importer.phpwu_exporter_import_network(string $zip_path, array $options, bool $async = true)mirroringwu_exporter_import().inc/site-exporter/mu-migration/includes/commands/class-mu-migration-import.php$assoc_argsrather than editing this vendored file. The vendored library follows upstream — minimise local changes.tests/WP_Ultimo/Site_Exporter_Test.php(or newNetwork_Importer_Test.php)Reference patterns to copy
inc/site-exporter/class-site-exporter.php:1540-1578(handle_import_site_modal()) and:2096-2168(handle_site_import()).inc/site-exporter/mu-migration/includes/commands/class-mu-migration-import.php:400-664(ImportCommand::all()). Call this once per selectedsites/{blog_id}/extracted directory.inc/functions/importer.php:84(wu_exporter_get_pending_imports()) and thewu_pending_site_import_*transients.ZipArchive::open()+getFromName('network.json'). The codebase uses PHP's built-inZipArchivedirectly — do not pull in a new dependency.Implementation notes / gotchas
LOAD DATA INFILE-style insert source IDs — they will collide. Strategy:users.csv: look up byuser_loginfirst, fall back touser_email. If found → recordsource_id → target_idin id_map. If not found → insert with a fresh AUTO_INCREMENT id and record the mapping.usermeta.csv, rewritinguser_idvia the id_map. Capability rows ({$prefix}{blog_id}_capabilities) also need theirmeta_keyblog-prefix rewritten to the new blog IDs once those are assigned.wp_posts.post_author,wp_comments.user_id, etc. get rewritten too. mu-migration's per-site path already supports ausers_map.jsonfile (seeclass-mu-migration-import.php:522-540) — write the network-level id_map into each per-site bundle asusers_map.jsonbefore calling import.blog_idAUTO_INCREMENT is independent of source. After inserting each new row intowp_blogs, capture the new ID and add to the id_map (source_blog_id → target_blog_id). Capabilitymeta_keyrewriting (point 1) depends on this map being built first.blog_id = 1being main in many places. Two options:wp_blogsrestore, insert the designated main site first so it getsblog_id = 1on the target, and shift the others. Cleanest for downstream code.wp_site.blog_idandget_main_site_id()filters. Brittle — many plugins queryblog_id = 1directly.network.json.format_versionhandling. Refuseformat_version > 1with a clear error. Acceptformat_version == 1only. Future versions should be additive; the importer should warn on unknown keys but not refuse.wp_sitemetarestore is partial. Only restore keys listed innetwork.json.sitemeta_keys_restored. Skip keys already set on the target (don't clobber the operator's local config), or clobber and warn — implementer to propose, default to clobber-with-warning.network.json.network_active_pluginsand callactivate_plugin($file, '', true)(network-wide). If the plugin file is missing underwp-content/plugins/, skip with a notice — do not abort the whole import.mu-plugins/because they're often essential for the network's behaviour. Conflict policy: never overwrite existing mu-plugins on the target — log and skip per file.--mysql-single-transactionflag (default for the network import, even though it's optional in the single-site flow). The whole-network import should NOT be a single transaction (locks too long); per-site is the right granularity.manage_network.reset_upload_limits()(sets PHP ini to 2048M). Network bundles can be much larger. Document the practical limit and recommendWP-CLIimport path for large networks (the orchestrator function should be CLI-callable).Verification
npm run checkpasses.test_network_import_detects_bundle_type_by_manifest().test_network_import_refuses_unknown_format_version().test_network_import_creates_blogs_for_selected_only()— uncheck blog_id 3, verify only 2/3 blogs created.test_network_import_user_id_remapping()— pre-create a user with same email on target, verify remapping into id_map and that imported posts reference the target's user ID.test_network_import_main_site_reassignment()— bundle'sdesignated_main_site_blog_id == 3, verify target's main site is the importedblog_id 3(and posts/options match).test_network_import_partial_failure_does_not_rollback_completed_sites().wp wp-ultimo import-network <zip>command (thin wrapper aroundwu_exporter_import_network()) and verify it works from CLI onwordpress.local:8080.Open questions for the implementer (decide in PR description)
user_loginfirst, fall back touser_email, or strictuser_login-only? Loose matching is more user-friendly but can merge unrelated accounts that happen to share an email.blog_id = 1, or (B) insert in original order and remap. PR should justify the choice.Dependencies
wu_exporter_import_network()once it lands.