Skip to content

feat(site-exporter): network export — bundle whole network into single ZIP with site selection + main-site reassignment #1149

@superdav42

Description

@superdav42

Summary

Today's site exporter (inc/site-exporter/) only handles one site at a time. The "Bulk Export Sites" action just iterates wu_exporter_export() per blog and produces N separate ZIPs (inc/site-exporter/class-site-exporter.php:1809-1816) — there is no concept of a single "network bundle" that captures wp_blogs, wp_site, wp_sitemeta, the global users table, network-active plugins, and all selected subsites in one archive.

This issue introduces a new network export flow that produces a single ZIP capable of re-creating an entire multisite network on another host.

Scope (this issue)

  • New "Export Network" UI in network admin (Sites bulk action OR new Tools page — implementer's call, see Open Questions).
  • Site selection: checklist of all sites in the network, defaulting to all-included, with per-row exclude toggle.
  • Main-site reassignment: if blog_id = 1 (the main site) is excluded, the form must force the user to pick a replacement main site from the included list (radio button). The chosen site becomes the new main site on import.
  • Output: a single ZIP wu-network-export-{date}-{ts}.zip written to wp-content/uploads/wu-site-exports/, containing the network bundle format documented under "Bundle Format" below.
  • Re-use the existing per-site export logic (\TenUp\MU_Migration\Commands\ExportCommand::all()) for each included site under sites/{blog_id}/ — do NOT rewrite per-site export.
  • New code path for the network-level artifacts (network.sql, users.csv, usermeta.csv, network.json manifest).

Out of scope (separate issues)

  • Network import (selective import of a network bundle) — Issue B, blocked by this one.
  • Self-booting export ZIPs (index.php installer + readme.txt) — Issue C, blocked by this one.
  • Cross-network "merge into existing network" import — explicitly out of scope; the import always treats the bundle as a fresh network restore.

Bundle Format (defines the contract for Issues B and C)

The export ZIP root MUST have:

wu-network-export-{date}-{ts}.zip
├── network.json                     # manifest (see schema below)
├── users.csv                        # all wp_users rows (global)
├── usermeta.csv                     # all wp_usermeta rows
├── network.sql                      # dump of wp_blogs, wp_site, wp_sitemeta,
│                                    #   wp_signups, wp_registration_log,
│                                    #   wp_blogmeta (if BerlinDB installed)
├── sites/
│   ├── {blog_id}/                   # one mu-migration-format bundle per site
│   │   ├── *.json                   # site meta (existing format)
│   │   ├── *.csv                    # users (existing format, per-blog)
│   │   ├── *.sql                    # blog tables (existing format)
│   │   └── wp-content/              # only if uploads/themes/plugins toggle on
│   └── ...
└── wp-content/                      # network-shared assets (only if toggled)
    ├── plugins/                     # network-active plugins
    ├── themes/                      # network-installed themes
    ├── mu-plugins/                  # always include if present
    └── uploads/                     # only the network-root upload dir
                                     #   (per-site uploads live under sites/*)

network.json schema (minimum required fields):

{
  "format_version": 1,
  "exported_at": "2026-05-06T12:00:00Z",
  "exported_by_plugin_version": "2.5.x",
  "source": {
    "url": "https://source.example.com",
    "is_subdomain_install": true,
    "main_site_blog_id": 1,
    "db_prefix": "wp_",
    "wp_version": "7.0",
    "php_version": "8.2"
  },
  "designated_main_site_blog_id": 1,
  "included_blog_ids": [1, 3, 5, 7],
  "excluded_blog_ids": [2, 4],
  "network_active_plugins": ["plugin-a/plugin-a.php"],
  "sitemeta_keys_restored": ["site_admins", "registration", "..."],
  "options": {
    "include_plugins": true,
    "include_themes": true,
    "include_uploads": true,
    "include_mu_plugins": true
  }
}

format_version: 1 — bump on any backwards-incompatible bundle change. Importer (Issue B) will refuse unknown versions.

Files to modify

Path What
inc/site-exporter/class-site-exporter.php Add register_network_export_form(), render_network_export_modal(), handle_network_export_modal(); add the entry point (network admin Tools menu OR Sites-list bulk action).
inc/site-exporter/class-network-exporter.php (NEW) Orchestrator: builds the bundle, calls per-site export N times, writes network.sql / users.csv / usermeta.csv / network.json, packages the ZIP.
inc/functions/exporter.php Add wu_exporter_export_network(array $included_blog_ids, int $main_site_blog_id, array $options) — async-capable, mirrors wu_exporter_export() shape. Returns ZIP filename or WP_Error.
inc/site-exporter/mu-migration/includes/commands/class-mu-migration-export.php Likely no changes — call its all() method as-is per included site. If a refactor is needed to redirect output to sites/{blog_id}/, do it in the new class-network-exporter.php, not here.
inc/site-exporter/mu-migration/includes/helpers.php Possibly extend Helpers\zip() to accept multiple roots — only if needed. Prefer building a staging directory and zipping that.
tests/WP_Ultimo/Site_Exporter_Test.php Add tests for: bundle format, main-site reassignment validation, exclude-all-fails-validation, network.json schema, ZIP layout assertions.

Use the staging-directory pattern (build everything under a temp dir, then zip the dir) — do NOT try to compose a multi-root ZIP in memory. Helpers\zip() already supports a directory-tree map.

Reference patterns to copy

  • Per-site export call shape: inc/site-exporter/class-site-exporter.php:2032-2088 (handle_site_export()). Copy the try/catch + file_exists() post-check + wu_exporter_save_generation_time() pattern.
  • mu-migration export entry: inc/site-exporter/mu-migration/includes/commands/class-mu-migration-export.php:397-556 (ExportCommand::all()). The orchestrator must call this once per included blog, redirecting the output filename to {staging}/sites/{blog_id}/site.zip (or unzipping immediately into {staging}/sites/{blog_id}/).
  • Form/modal registration: inc/site-exporter/class-site-exporter.php:1298-1326 (register_forms()) and :1334-1399 (render_export_site_modal()).
  • Background async pattern: inc/functions/exporter.php:29 (wu_exporter_export() with $async = true). Reuse wu_exporter_set_transient() for the pending state.
  • Helper functions in mu-migration: inc/site-exporter/mu-migration/includes/helpers.php — see Helpers\zip(), Helpers\get_db_prefix(), Helpers\maybe_switch_to_blog().

Implementation notes / gotchas

  1. mu-migration export all writes intermediate files to sys_get_temp_dir() (see comment block at class-mu-migration-export.php:461-482). The network export's staging dir should also live under sys_get_temp_dir() to inherit the same writability and CWD-safety properties — do not stage under wp-content/uploads/ directly.
  2. wp_users is global in multisite. Export the whole table once; do NOT call the per-blog users() exporter inside class-mu-migration-export.php for each site (that would produce N copies of the same global users with mismatched usermeta blog-prefixed capability rows).
  3. wp_usermeta capability rows are blog-prefixed (wp_2_capabilities, wp_3_capabilities, …). When a blog is excluded, its capability/usermeta rows MUST also be excluded from usermeta.csv. Filter on the meta_key prefix matching {$wpdb->base_prefix}{blog_id}_* for non-included IDs.
  4. wp_blogs.path and wp_site.domain must reflect the source network as-is. The importer (Issue B) will rewrite these. Do not rewrite at export time.
  5. wp_sitemeta carries site_admins, registration, add_new_users, etc. Whitelist the keys to restore in network.json.sitemeta_keys_restored rather than dumping the whole table — some keys (e.g. recently_edited, transient-like keys) shouldn't propagate. Implementer should propose the whitelist in the PR description and we'll review.
  6. Main-site reassignment is metadata-only at export time. Only network.json.designated_main_site_blog_id records the choice — the actual swap happens on import (Issue B). Validation in this issue: refuse to export if included_blog_ids is empty, or if designated_main_site_blog_id is not in included_blog_ids.
  7. BerlinDB tables (wp_wu_*) are global Ultimate Multisite tables and SHOULD be included in network.sql. Use \WP_Ultimo\Database\Sites\Site_Schema::get_table_name() etc. to discover them — do not hardcode names. Treat their inclusion as optional but on-by-default.
  8. Filesize: a network with 50 sites and uploads can be 5+ GB. The progress UI must show overall progress (X of N sites exported) and the background path must be the default (not the on-by-default sync path). Reuse the existing wu_pending_site_export_* transient pattern.
  9. Capability for the form: manage_network (matches existing register_forms() calls).
  10. Filename collision: include time() and a random suffix in the ZIP filename to avoid clobbering parallel exports.

Verification

  • npm run check passes (lint + phpstan + phpunit).
  • New tests in tests/WP_Ultimo/Site_Exporter_Test.php pass:
    • test_network_export_produces_expected_layout() — assert ZIP root contains network.json, users.csv, usermeta.csv, network.sql, sites/.
    • test_network_export_excludes_unselected_sites()sites/ only contains selected blog IDs.
    • test_network_export_main_site_reassignment_recorded_in_manifest().
    • test_network_export_refuses_empty_included_blog_ids().
    • test_network_export_refuses_main_site_not_in_included().
    • test_network_json_schema_has_required_fields() — at least the keys listed in the schema above.
  • Manual: from wordpress.local:8080 network admin, export a network with 3+ sites. Inspect the ZIP. Verify network.json matches the schema, users.csv row count equals SELECT COUNT(*) FROM wp_users, network.sql contains wp_blogs rows for only included blog IDs, and per-site bundles under sites/{id}/ are valid mu-migration ZIPs (or extracted directories).
  • Manual: exclude main site (blog_id=1), force-pick blog_id=3 as new main, export, verify network.json.designated_main_site_blog_id == 3 and included_blog_ids does not contain 1.
  • No PHPCS/PHPStan regressions.

Open questions for the implementer (decide in PR description)

  1. Entry point: add to existing Sites bulk action ("Export Network") or new Tools-menu page ("Export Network")? Bulk action is faster to ship; Tools page is more discoverable. I lean toward a new Tools page (network admin → Tools → Export Network) so the UI has room for the site checklist + main-site reassignment without crowding the Sites list.
  2. Sitemeta whitelist: propose the exact list of wp_sitemeta keys to restore (defaults to a hardcoded safe-list with a wu_network_export_sitemeta_keys filter for extension).
  3. Per-site uploads location: under sites/{blog_id}/wp-content/uploads/ (mirrors mu-migration's per-site bundle) or hoist to wp-content/uploads/sites/{blog_id}/ at export time (matches the live multisite layout)? Decide based on what makes the importer (Issue B) simpler.

Dependencies / blocks

  • Blocks Issue B (network import — selective). Issue B can start design but cannot ship until this issue's bundle format lands.
  • Blocks Issue C (self-booting export ZIPs). Issue C is a pure addition once the bundle format is stable.

Metadata

Metadata

Assignees

Labels

auto-dispatchenhancementNew feature or requestorigin:interactiveCreated by interactive user sessionpriority:highHigh severity — significant quality issuesolved:interactiveTask was solved by an interactive sessionstatus:queuedWorker dispatched, not yet startedtier:thinkingRoute to opus-tier model for dispatch

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions