Simple Export & Import
A simple plugin to export and import WordPress posts with full data preservation.
by Vitalii Kaplia · github.com/vitaliikaplia/simple-export-import · website
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/vitaliikaplia/simple-export-import/archive/refs/heads/master.zipReadme
Simple Export & Import
A WordPress plugin for exporting and importing posts as JSON with full Gutenberg / ACF / multilingual support, optional embedded media with ID remapping, and GitHub-backed auto-update.
Description
Simple Export & Import exports any post to a portable JSON file and imports it back on another site without losing data — even when the donor and recipient sites are decoupled. It correctly preserves:
- Gutenberg blocks (including InnerBlocks and nested structures)
- ACF blocks (the JSON inside `` block comments)
- ACF Repeater / Group / Flexible Content fields (via
_field_*references) - Custom fields (post meta), taxonomies, featured image, attached media
- Cyrillic and other UTF-8 content (no escaping)
- WPML translations and WP-LOC translations (any plugin exposing the WPML hook surface) — bidirectional
- Per-language attachment translations (one file, multiple language rows) — WPML Media Translation model
- Yoast SEO postmeta (and the
wp_yoast_indexablecache that backs it)
Why a dedicated plugin?
Two pitfalls of naive post-export-import scripts:
- Theme/plugin
save_posthooks mutate imported content, throw validation errors, hit external APIs, or recurse. This plugin imports via direct$wpdbwrites, deliberately bypassingsave_post,transition_post_status,added_post_meta,set_object_terms, and friends — content lands exactly as exported. wp_unslash()strips backslashes from Gutenberg/ACF block JSON when content goes throughwp_insert_post()/update_post_meta(). The plugin sidesteps the wholewp_unslashproblem by writing directly via$wpdb(which does not unslash) — so ACF block JSON attributes and any string with\survive intact.
After the raw write, the plugin selectively re-invokes the few SEO-specific rebuilders (Yoast Indexable_Builder, Rank Math link cache) that would otherwise have run from save_post — so the data shows up on the frontend and in admin UI immediately.
File Structure
simple-export-import/
├── simple-export-import.php # Bootstrap (constants, requires, init)
├── uninstall.php # Cleans up options on plugin delete
├── README.md
├── .gitignore
├── includes/
│ ├── helpers.php # Defaults, capabilities, validation, multilingual detection
│ ├── class-sei-multilingual.php # WPML / WP-LOC compatibility layer
│ ├── class-sei-media.php # Base64 embed + ID/URL remap + per-language attachment translations
│ ├── class-sei-settings.php # Settings page, option registration
│ ├── class-sei-export.php # JSON export + post row actions
│ ├── class-sei-import.php # Tools → Import Post page + $wpdb-direct insert
│ └── class-sei-github-updater.php # WP admin auto-update from GitHub master branch
└── languages/
├── simple-export-import.pot # Translation template
├── simple-export-import-uk.po/.mo # Ukrainian
├── simple-export-import-de_DE.po/.mo # German
└── simple-export-import-ru_RU.po/.mo # Russian
No Composer / external dependencies — plain require_once.
Installation
One-time setup
- Upload the
simple-export-importfolder to/wp-content/plugins/(or install the zip via Plugins → Add New → Upload). - Activate via Plugins menu.
- Configure under Settings → Export & Import.
Updates
Once installed, updates ship through the regular WordPress Updates screen — no FTP, no zip uploads. The plugin watches the master branch of its GitHub repository and surfaces a "new version available" notice when the header version changes. One-click Update now does the rest.
See GitHub auto-update below for details.
Settings
The settings page is grouped into six sections.
General
- Post Types — which post types get the Export row action.
Permissions
- Export Capability — minimum capability required to export.
- Import Capability — minimum capability required to import.
Import Behavior
- Import Status — default post status for imported posts (Draft / Published / Pending / Private).
- Import Title Suffix — text appended to imported titles. Default
- imported; leave empty to keep the original title unchanged. - Preserve Original Author — when enabled and the original author's user ID exists on this site, they are kept as the author; otherwise the current user becomes the author.
Export Behavior
- Pretty-print JSON — toggle indentation (off = smaller files).
- Extra Meta Keys to Skip — additional meta keys to exclude from export, one per line. WordPress internals (
_edit_lock,_wp_trash_meta_status, etc.) are already skipped. - Max Upload Size (MB) — limit on uploaded JSON file size at import time. Default and ceiling are derived from the server's PHP config (
min(upload_max_filesize, post_max_size)viawp_max_upload_size()). The setting can only tighten the cap, never raise it past what PHP physically accepts. The current PHP cap is shown in the field's description.
Media
- Embed Media Files — when on, every attachment referenced by the post (featured image, attached media, Gutenberg/ACF blocks, post meta including nested ACF Repeater/Group/Flexible) is embedded as base64 in the JSON. On import, each is recreated on the target site and all references are remapped from old IDs to new IDs across post_content (via
parse_blocks/serialize_blocks), block attributes, meta fields, andwp-image-N/data-id/[gallery ids=""]legacy markup. URLs are remapped too. Multilingual-aware: the same image referenced across translation posts is uploaded once and all language posts get the same new ID. WPML Media Translation (per-language metadata on one file) round-trips: one file is written touploads/, and N sibling attachment rows share_wp_attached_file/_wp_attachment_metadatawhile carrying per-language title/alt/caption/description; they are connected viawpml_set_element_language_detailswithelement_type=post_attachment. - Max Embedded File Size (KB) — files above this limit are exported as references only (no embed). Default 10240 (10 MB). The current server
memory_limitis shown in the field's description so you can pick a value that won't OOM during base64 decode (which inflates files by ~33%).
Multilingual
- Multilingual Translations — when active, exporting any post bundles all of its translations into the same JSON. On import, translations are recreated and re-connected via the multilingual plugin. Auto-disabled if no compatible plugin is detected.
Usage
Export
- Open any post list (Posts, Pages, or a custom post type enabled in Settings).
- Hover a row → click Export under the row actions.
- A JSON file downloads with everything needed to recreate the post.
Import
- Go to Tools → Import Post.
- Pick a JSON file produced by this plugin.
- Click Import Post.
- A success notice with a link to the new post appears.
Multilingual support (WPML & WP-LOC)
The plugin talks to multilingual backends through the WPML hook surface only: wpml_element_language_details, wpml_get_element_translations, wpml_element_trid, wpml_set_element_language_details. This means it works transparently with:
- WPML itself
- WP-LOC (lightweight multilingual plugin that emulates the WPML hooks)
- Any other plugin registering the same hooks
Detection is automatic (sei_is_multilingual_active() in includes/helpers.php). Language codes round-trip in WPML format (en, uk, de...) regardless of the underlying plugin's internal representation.
Round-trip matrix
| Export from | Import to | Posts | Attachments (with embed enabled) |
|---|---|---|---|
| WPML | WPML | ✅ connected | ✅ per-language siblings connected |
| WPML | WP-LOC | ✅ connected | ✅ per-language siblings connected |
| WP-LOC | WP-LOC | ✅ connected | ✅ per-language siblings connected |
| WP-LOC | WPML | ✅ connected | ✅ per-language siblings connected |
| any | site without multilingual plugin | ⚠️ imported as separate posts (notice shown) | ⚠️ siblings imported but not linked |
Yoast SEO
Yoast meta (_yoast_wpseo_*) round-trips automatically — none of those keys are in the skip-list. Since the import bypasses save_post, the plugin explicitly calls Indexable_Builder::build_for_id_and_type( $post_id, 'post' ) after meta restore so the wp_yoast_indexable cache is rebuilt and SEO tags actually render on the frontend. Done per post and per imported translation. Rank Math link cache is cleared similarly (rank_math/links/clear_cache_for_post).
What gets exported
{
"ID": 123,
"post_title": "Sample Post",
"post_content": "<!-- wp:paragraph --><p>...</p><!-- /wp:paragraph -->",
"post_excerpt": "...",
"post_status": "publish",
"post_name": "sample-post",
"post_type": "post",
"post_author": "1",
"post_date": "2024-01-01 12:00:00",
"post_date_gmt": "2024-01-01 12:00:00",
"comment_status": "open",
"ping_status": "closed",
"menu_order": 0,
"meta_fields": { "custom_field": "value", "_field_5e0a1b2c": "...", "_yoast_wpseo_title": "..." },
"taxonomies": { "category": [ { "term_id": 5, "name": "News", "slug": "news" } ] },
"featured_image": { "id": 456, "url": "...", "file": "..." },
"attachments": [ { "id": 789, "title": "...", "url": "...", "file": "..." } ],
"embedded_attachments": [
{
"id": 456,
"filename": "hero.jpg",
"mime_type": "image/jpeg",
"base64": "...",
"language_code": "en",
"translations": [
{ "id": 457, "language_code": "uk", "title": "...", "alt": "...", "caption": "...", "description": "..." }
]
}
],
"wpml_enabled": true,
"original_language": "en",
"translations": [
{ "language_code": "uk", "post_title": "...", "post_content": "...", "meta_fields": {...}, "embedded_attachments": [...], ... }
]
}
embedded_attachmentsis only present when Embed Media Files is on. Without it, attachments are still referenced by ID infeatured_image,attachments, and insidepost_content— but the importer can't recreate them (target must already have those attachment IDs).translationsis only present when a multilingual plugin is active and the Multilingual Translations setting is on.
GitHub auto-update
The plugin watches the master branch of its public GitHub repository. The flow:
- WordPress's normal update check (twice-daily cron, or Check Again on the Updates screen with
?force-check=1) fires thepre_set_site_transient_update_pluginsfilter. SEI_GitHub_Updaterfetches the plugin header fromhttps://raw.githubusercontent.com/vitaliikaplia/simple-export-import/master/simple-export-import.php(12-hour cache, 1-hour on failure).- If the remote
Version:is greater than the installed version, an update entry is injected into the transient. - The Plugins screen and Dashboard → Updates show Update now, which downloads
https://github.com/vitaliikaplia/simple-export-import/archive/refs/heads/master.zip. upgrader_source_selectionrenames GitHub'ssimple-export-import-master/extraction tosimple-export-import/so WP installs it in the right place.
No GitHub API token, no rate-limit headache — raw.githubusercontent.com and archive/refs/heads/<branch>.zip are static endpoints.
To track a different branch (e.g. a staging branch) on a particular site, define the constant in wp-config.php:
define( 'SEI_GITHUB_BRANCH', 'develop' );
Localization
Bundled translations: Ukrainian (uk), German (de_DE), Russian (ru_RU).
To add a new language:
- Open
languages/simple-export-import.potin Poedit or any PO editor. - Save as
simple-export-import-{locale}.po(e.g.simple-export-import-pl_PL.po). - Compile to
.mo:msgfmt -o simple-export-import-{locale}.mo simple-export-import-{locale}.po. - WordPress picks the right file based on the site locale.
Requirements
- WordPress 4.7+
- PHP 7.4+
- JSON support (default in PHP)
Security
- Nonce verification on every export request and import form
- Capability checks (configurable per action)
- File size + extension + JSON-validity check on upload
- All input sanitized; output escaped
- Direct
$wpdbimport path intentionally bypassessave_post/add_attachment/transition_post_statusand similar hooks — content is written exactly as exported. If a theme or plugin needs to react to imported posts (e.g. custom search indexing), it must hook a later admin action or re-save the post manually.