Skip to content

feat(site-exporter): network import — restore network bundle (#1149) into target multisite with selective sites + ID remapping #1150

@superdav42

Description

@superdav42

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() 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.
  • New "Import Network" entry point (mirror Issue feat(site-exporter): network export — bundle whole network into single ZIP with site selection + main-site reassignment #1149's choice of Tools page vs. bulk action).
  • Selective import UI:
    • 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:
    1. Validate network.json.format_version — refuse versions newer than what this importer knows.
    2. 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).
    3. Restore network tables from network.sql with the new network's domain/path substituted into wp_blogs / wp_site rows.
    4. 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.
    5. 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).
    6. Restore wp_sitemeta for the whitelisted keys recorded in network.json.sitemeta_keys_restored.
    7. 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().

Out of scope

  • Self-booting installer (Issue C, feat(site-exporter): self-booting export ZIPs — bundle index.php + readme.txt for fresh-host install #1151) — that's about creating an index.php that runs WP install + this importer on a fresh server. This issue assumes the target is an already-running multisite install.
  • 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.
inc/site-exporter/class-network-importer.php (NEW) Orchestrator: extract → validate manifest → restore users/network tables → loop per-site import → apply main-site reassignment → restore sitemeta → move shared assets.
inc/functions/importer.php Add wu_exporter_import_network(string $zip_path, array $options, bool $async = true) mirroring wu_exporter_import().
inc/site-exporter/mu-migration/includes/commands/class-mu-migration-import.php 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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.
  9. 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.
  10. Capability for the form: manage_network.
  11. 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).

Verification

  • npm run check passes.
  • New tests pass:
    • 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's designated_main_site_blog_id == 3, verify target's main site is the imported blog_id 3 (and posts/options match).
    • test_network_import_partial_failure_does_not_rollback_completed_sites().
  • Manual: export from one network (Issue feat(site-exporter): network export — bundle whole network into single ZIP with site selection + main-site reassignment #1149), import into a fresh multisite at a different domain, verify all selected sites are reachable, login works for migrated users, network-active plugins are activated.
  • 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)

  1. 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.
  2. Sitemeta clobber policy: clobber-with-warning vs. skip-if-set. I lean clobber-with-warning since the operator initiated the import.
  3. 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.
  4. Cross-version refusal threshold: refuse if WP major version differs? Differs by 2+? Always allow with warning?

Dependencies


Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestorigin:interactiveCreated by interactive user sessionpriority:highHigh severity — significant quality issuestatus:availableTask is available for claiming

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions