{"id":349219,"date":"2026-08-11T12:21:48","date_gmt":"2026-08-11T12:21:48","guid":{"rendered":"https:\/\/wordpress.org\/plugins\/xml-media-importer\/"},"modified":"2026-08-16T08:11:24","modified_gmt":"2026-08-16T08:11:24","slug":"migravo-wxr-media-migration","status":"publish","type":"plugin","link":"https:\/\/de.wordpress.org\/plugins\/migravo-wxr-media-migration\/","author":23542765,"comment_status":"closed","ping_status":"closed","template":"","meta":{"version":"1.2.2","stable_tag":"1.2.2","tested":"7.0.5","requires":"5.0","requires_php":"7.4","requires_plugins":null,"header_name":"Migravo WXR Media Migration","header_author":"Qui Nguyen","header_description":"Import a WordPress WXR export and automatically download the media it references from the source site, with ACF field mapping and relationship remapping.","assets_banners_color":"131b28","last_updated":"2026-08-16 08:11:24","external_support_url":"","external_repository_url":"","donate_link":"","header_plugin_uri":"","header_author_uri":"","rating":0,"author_block_rating":0,"active_installs":0,"downloads":228,"num_ratings":0,"support_threads":0,"support_threads_resolved":0,"author_block_count":0,"sections":["description","installation","faq","changelog"],"tags":{"1.1.3":{"tag":"1.1.3","author":"dinhqui","date":"2026-08-11 12:21:38"},"1.2.0":{"tag":"1.2.0","author":"dinhqui","date":"2026-08-12 03:57:38"},"1.2.1":{"tag":"1.2.1","author":"dinhqui","date":"2026-08-16 06:35:48"},"1.2.2":{"tag":"1.2.2","author":"dinhqui","date":"2026-08-16 08:11:24"}},"upgrade_notice":{"1.2.2":"<p>Reporting fix. A relationship pointing at a trashed post was counted as definitely broken\nrather than as a trashed reference \u2014 a wrong diagnosis, since the fix is to restore the post,\nnot to import a missing post type. Stored values are unaffected.<\/p>","1.2.1":"<p>Fixes three date columns the import was letting WordPress invent: last-modified became the\nimport time or the publish date, GMT times shifted by the destination&#039;s timezone offset, and\nre-importing a draft reset its publish date to today.<\/p>","1.2.0":"<p>Imported page trees were arriving flat: wp:post_parent was read by nothing, so every child\nsat at the top level. Image alt text, comment and ping status, sticky, and post password now\ncome across too \u2014 a password-protected post used to arrive public with nothing said.<\/p>","1.1.3":"<p>Fixes an image being matched to the wrong local file. Identification was by filename\nalone, so a source image at 2025\/06\/logo.png could resolve to a local 2024\/01\/logo.png \u2014\nthe wrong picture, silently, and the right one never downloaded. Matching is now on the\nupload path.<\/p>","1.1.2":"<p>Security fix: temporary import files sat at a predictable path protected only by an\n.htaccess, which nginx, LiteSpeed and IIS ignore \u2014 the export and every imported post\nwere readable by anyone during an import. The directory name is now random. Upgrading\nremoves the old one.<\/p>","1.1.1":"<p>Security fix: on 1.1.0 a media URL that redirected could reach an internal address,\nincluding the cloud metadata endpoint. 1.0.0 refused redirects and is unaffected.\nAlso fixes imports over an hour being deleted by the plugin&#039;s own cleanup.<\/p>","1.1.0":"<p>Imported the same file twice with 1.0.0? Check for duplicates: slugs with characters\nWordPress strips (such as a leading dollar sign) were not recognised, so re-imports made\nextra copies ending in -2. Sort by slug to find them. 1.1.0 cannot remove them.<\/p>"},"ratings":[],"assets_icons":{"icon-128x128.png":{"filename":"icon-128x128.png","revision":3641959,"resolution":"128x128","location":"assets","locale":"","width":128,"height":128},"icon-256x256.png":{"filename":"icon-256x256.png","revision":3641959,"resolution":"256x256","location":"assets","locale":"","width":256,"height":256}},"assets_banners":{"banner-1544x500.png":{"filename":"banner-1544x500.png","revision":3641959,"resolution":"1544x500","location":"assets","locale":"","width":1544,"height":500},"banner-772x250.png":{"filename":"banner-772x250.png","revision":3641959,"resolution":"772x250","location":"assets","locale":"","width":772,"height":250}},"assets_blueprints":{},"all_blocks":[],"tagged_versions":["1.1.3","1.2.0","1.2.1","1.2.2"],"block_files":[],"assets_screenshots":[],"screenshots":[]},"plugin_section":[],"plugin_tags":[2211,87,84,4155,222667],"plugin_category":[50,59],"plugin_contributors":[275187],"plugin_business_model":[],"class_list":["post-349219","plugin","type-plugin","status-publish","hentry","plugin_tags-acf","plugin_tags-import","plugin_tags-media","plugin_tags-migration","plugin_tags-wxr","plugin_category-media","plugin_category-utilities-and-tools","plugin_contributors-dinhqui","plugin_committers-dinhqui"],"banners":{"banner":"https:\/\/ps.w.org\/migravo-wxr-media-migration\/assets\/banner-772x250.png?rev=3641959","banner_2x":"https:\/\/ps.w.org\/migravo-wxr-media-migration\/assets\/banner-1544x500.png?rev=3641959","banner_rtl":false,"banner_2x_rtl":false},"icons":{"svg":false,"icon":"https:\/\/ps.w.org\/migravo-wxr-media-migration\/assets\/icon-128x128.png?rev=3641959","icon_2x":"https:\/\/ps.w.org\/migravo-wxr-media-migration\/assets\/icon-256x256.png?rev=3641959","generated":false},"screenshots":[],"raw_content":"<!--section=description-->\n<p>Import an export into a new WordPress site and two things break that nothing in the box will fix for you.<\/p>\n\n<p><strong>ACF Relationship, Post Object and Gallery fields stop working.<\/strong> They store post IDs, and the new site assigns different IDs, so every one of those fields now points at the wrong post or at nothing at all. Nothing reports it. The fields simply look empty, or worse, quietly reference unrelated content \u2014 and on a site built around related posts, professionals, case studies or products, that is most of the site.<\/p>\n\n<p><strong>Every image still lives on the old server.<\/strong> The core importer copies the references, not the files. The content looks fine right up to the day the source site is switched off.<\/p>\n\n<p>Migravo fixes both, on a site that has already been imported or as part of importing it.<\/p>\n\n<h4>Repairing relationships<\/h4>\n\n<p>Upload the export from the <em>source<\/em> site on the ID Map tab. Migravo matches each old post to its new one by slug, builds a map of old ID to new ID, then rewrites every relationship, post-object and gallery field on the site in one pass.<\/p>\n\n<p>It sweeps the whole database rather than one post type, so both sides of a two-way relationship are repaired as long as both are present \u2014 the field on the Artist and the field on the Song are just two more fields to fix, and it does not matter which order you imported them in. Import one post type now and the other later, then run the repair again: the map keeps what it already knows and grows as you import.<\/p>\n\n<p>It only rewrites values it can account for. A flat list of numeric IDs is remapped; an ACF Link field, a repeater row, a phone number that merely starts with digits, and any ID with no entry in the map are all left exactly as they were. An ID whose target has since been deleted is reported rather than written as a dead reference.<\/p>\n\n<p><strong>And it tells you what it could not fix, as a verdict rather than a guess.<\/strong> An ID it has no mapping for is left alone \u2014 correct in itself, but the field still points somewhere wrong and the number still looks like a valid ID, so nothing errors. Migravo checks each one against the field's own definition: ACF records which post types a Relationship or Post Object field may reference, so a <code>practice_areas<\/code> field declared as accepting <code>service<\/code> while holding an ID that is a <code>trade_report<\/code> here is broken as a matter of fact. Those are reported as broken and listed first, with how many fields hold each ID and what it actually is. Values that match the type the field accepts are marked as almost certainly already repaired, so re-running the tool does not raise false alarms. Import the missing post type and run it again; the map grows as you import, so order does not matter.<\/p>\n\n<h4>Re-hosting media<\/h4>\n\n<p>During import, Migravo finds every media URL in the post content and the featured image, downloads each file from the source site into the Media Library, and rewrites the content to point at the local copy. Files are deduplicated by source URL and filename, so importing the same export twice does not produce a second copy of every image.<\/p>\n\n<h4>What else the export carries, and what arrives with it<\/h4>\n\n<p>A WXR file says more about each post than its title and content, and a field that is read by nothing is a field that goes missing without a word. These now come across:<\/p>\n\n<ul>\n<li><strong>Page hierarchy.<\/strong> <code>wp:post_parent<\/code> holds an ID from the source site, so it means nothing here until it is translated \u2014 and an untranslated one is why imported trees arrive flat, every child at the top level. Order in the file does not matter: a child that arrives before its parent is placed when the parent turns up.<\/li>\n<li><strong>Image alt text.<\/strong> Written on the attachment item in the export, which is not a post that gets imported, so it was being left behind for every image at once. Alt text already on this site is never overwritten.<\/li>\n<li><strong>Comment and ping status<\/strong>, rather than falling back to whatever this site's defaults happen to be.<\/li>\n<li><strong>Post password.<\/strong> A protected post used to arrive public, with nothing to say so.<\/li>\n<li><strong>Sticky posts<\/strong>, for <code>post<\/code> \u2014 the one type WordPress reads that list for.<\/li>\n<li><strong>The dates, all four of them.<\/strong> Publish and last-modified, local and UTC. WordPress will fill these in itself if nothing supplies them, and what it fills in is wrong: last-modified becomes the time the import ran, and the UTC columns are recalculated from the <em>destination's<\/em> timezone, so moving a site between two zones shifts every one of them. A draft keeps its publish date on a re-import too, which it did not before.<\/li>\n<\/ul>\n\n<p>Each is applied only where the export actually states it. An empty tag is a statement \u2014 \"no password\", \"not sticky\" \u2014 and is honoured on a re-import; a tag that is not in the file at all says nothing, and nothing on this site is overwritten on the strength of it.<\/p>\n\n<p><strong>One limitation worth knowing.<\/strong> Posts are tied back to the source by the ID the export gives them, and that ID is not qualified by which site it came from. Import two different sites into one destination and a post from each can claim the same source ID; the hierarchy lookup then has two candidates and takes the most recent. This is inherent in what a WXR file records, not something the plugin can detect, and it applies equally to the relationship repair.<\/p>\n\n<h4>The rest of a real migration<\/h4>\n\n<ul>\n<li><strong>Resumable, non-blocking import<\/strong> \u2014 preview what the file contains, then import with a live progress bar and 1\u201310 concurrent requests. Close the tab or press Stop and the import offers to continue where it left off. Posts lost to a server hiccup are retried automatically, and any that still fail are listed with a button to retry just those.<\/li>\n<li><strong>Safe re-imports<\/strong> \u2014 choose to skip, update in place, or delete and replace posts that already exist. Matching accounts for the fact that WordPress rewrites slugs on insert, so re-running an import does not silently duplicate posts.<\/li>\n<li><strong>Taxonomy mapping<\/strong> \u2014 send a taxonomy from the export into a different taxonomy on this site, or skip it.<\/li>\n<li><strong>Author mapping<\/strong> \u2014 map each author in the export to an existing user, or create one.<\/li>\n<li><strong>Field renaming<\/strong> \u2014 map meta\/ACF field keys when the source and destination names differ.<\/li>\n<li><strong>Featured image from a custom field<\/strong> \u2014 for source sites that store it outside the standard thumbnail meta.<\/li>\n<li><strong>Multisite aware<\/strong> \u2014 choose which site in the network to import into, or to build the ID map against.<\/li>\n<li><strong>Built for large exports<\/strong> \u2014 tested against a 52 MB export of 5,179 posts, which it parses in about 44 seconds using around 4 MB of memory.<\/li>\n<\/ul>\n\n<h4>Security<\/h4>\n\n<ul>\n<li>Every AJAX action requires a valid nonce and the <code>import<\/code> capability.<\/li>\n<li>All database queries are parameterized via <code>$wpdb-&gt;prepare()<\/code>.<\/li>\n<li>Media downloads are guarded against SSRF \u2014 loopback, private-network and link-local addresses (including the cloud metadata endpoint at 169.254.169.254) are rejected. Redirects are followed, and every hop is checked by the same rule as the first, so a public URL that redirects to an internal address is refused at the redirect rather than fetched. A source site on a private network can be allowed deliberately, per host, using the <code>wxmi_allow_private_media_host<\/code> filter \u2014 see the FAQ.<\/li>\n<li>Restoring serialized meta values blocks PHP object injection (unserialize is called with <code>allowed_classes =&gt; false<\/code>).<\/li>\n<li>The temporary directory used during import is given a random, unguessable name, so the export and the per-post cache written inside it cannot be requested by anyone who does not already know the path. An .htaccess and an index.php are written alongside as a second layer \u2014 they help on Apache, but they are not relied on, because nginx, LiteSpeed and IIS do not read .htaccess at all.<\/li>\n<\/ul>\n\n<h4>Why not just use the core WordPress Importer?<\/h4>\n\n<p>Use it \u2014 and then use this. The core importer creates the posts, which is the part that already works. What it has no concept of is what became of everything that referenced those posts by ID, or of the files they pointed at:<\/p>\n\n<ul>\n<li>It does not know that ACF Relationship, Post Object and Gallery fields hold post IDs, so it leaves every one of them pointing at whatever now happens to own that ID on the new site.<\/li>\n<li>It copies image references, not images. The content depends on the old server staying online forever.<\/li>\n<li>Re-running it creates another copy of everything, so a partial or interrupted import cannot simply be repeated.<\/li>\n<\/ul>\n\n<h4>Is this a full-site migration tool?<\/h4>\n\n<p>No, and deliberately not. If you can copy an entire site \u2014 database, uploads and all \u2014 a tool built for that is the right choice and this is not it.<\/p>\n\n<p>Migravo is for the cases where you cannot: you have a WXR export and a destination that already exists, you are pulling a few post types across rather than cloning a whole site, or you are on the other side of an import that already happened and left broken relationships behind. The ID Map tab works on a site that was imported months ago by something else entirely.<\/p>\n\n<p>Two things in a WXR file are deliberately not imported, and are not planned:<\/p>\n\n<ul>\n<li><strong>Comments.<\/strong> Migravo is about posts, the fields that point between them and the media they use. Comment threads, their authors and their moderation state are a separate body of data with their own spam and approval concerns, and the core WordPress Importer already handles them.<\/li>\n<li><strong>Navigation menus.<\/strong> A menu is a set of posts referring to other posts by ID, so importing one without also owning the pages it points at produces a menu of dead links. Rebuild menus on the destination once the content is in place.<\/li>\n<\/ul>\n\n<p>Run the core importer for either of those, then use this for the parts it leaves behind.<\/p>\n\n<!--section=installation-->\n<ol>\n<li>Upload the plugin folder to <code>\/wp-content\/plugins\/<\/code>.<\/li>\n<li>Activate the plugin through the \"Plugins\" menu in WordPress.<\/li>\n<li>Go to <strong>Tools \u2192 Migravo Migration<\/strong>.<\/li>\n<\/ol>\n\n<!--section=faq-->\n<dl>\n<dt id=\"my%20acf%20relationship%20fields%20are%20empty%20after%20importing%20%E2%80%94%20can%20this%20fix%20it%3F\"><h3>My ACF Relationship fields are empty after importing \u2014 can this fix it?<\/h3><\/dt>\n<dd><p>Yes, and that is the main reason the plugin exists. Those fields store post IDs, and the new site handed out different IDs, so the values that survived the import now point at nothing \u2014 or at whatever unrelated post happens to hold that ID.<\/p>\n\n<p>Open the ID Map tab and upload the export from the <em>source<\/em> site. Migravo matches every old post to its new one by slug, then rewrites every Relationship, Post Object and Gallery field on the site to the correct new IDs \u2014 every post type at once, so both directions of a two-way relationship are fixed provided both sides are on the site.<\/p><\/dd>\n<dt id=\"can%20i%20use%20it%20on%20a%20site%20that%20was%20already%20imported%20by%20something%20else%3F\"><h3>Can I use it on a site that was already imported by something else?<\/h3><\/dt>\n<dd><p>Yes. The ID Map tab needs only the original export file and the site as it stands now; it does not care what performed the import or how long ago. If a migration left broken relationships behind months ago, this repairs them without re-importing anything.<\/p><\/dd>\n<dt id=\"which%20field%20types%20get%20remapped%2C%20and%20which%20are%20left%20alone%3F\"><h3>Which field types get remapped, and which are left alone?<\/h3><\/dt>\n<dd><p>Remapped: flat lists of post IDs, which is how ACF stores Relationship, Post Object, Gallery and similar fields.<\/p>\n\n<p>Left untouched: anything that is not a plain list of IDs. An ACF Link field (title\/url\/target), repeater rows, a value that merely starts with digits such as a phone number, and any ID with no entry in the map are all written back exactly as they were. An ID whose target has since been deleted or trashed is counted and reported rather than written as a dead reference.<\/p><\/dd>\n<dt id=\"what%20xml%20format%20does%20this%20accept%3F\"><h3>What XML format does this accept?<\/h3><\/dt>\n<dd><p>Standard WordPress eXtended RSS (WXR) \u2014 the same file produced by any WordPress site's Tools \u2192 Export.<\/p><\/dd>\n<dt id=\"does%20it%20download%20media%20from%20the%20old%20site%20automatically%3F\"><h3>Does it download media from the old site automatically?<\/h3><\/dt>\n<dd><p>Yes, when \"Download Media\" is checked. It scans the post content and featured image for URLs on the source site's domain, downloads each into the Media Library, and \u2014 if \"Update URLs in Content\" is also checked \u2014 rewrites the content to point at the new local URLs.<\/p><\/dd>\n<dt id=\"what%20happens%20if%20a%20post%20already%20exists%3F\"><h3>What happens if a post already exists?<\/h3><\/dt>\n<dd><p>You choose per import: <strong>skip<\/strong> it, <strong>update<\/strong> its content and media in place, or <strong>replace<\/strong> it (delete the existing post and re-import fresh). Matching is by title + slug + post type.<\/p><\/dd>\n<dt id=\"no%20media%20was%20imported%20from%20my%20staging%20site%20%E2%80%94%20why%3F\"><h3>No media was imported from my staging site \u2014 why?<\/h3><\/dt>\n<dd><p>Almost certainly because that site is on a private network. Staging and development sites usually resolve to a private address (10.x, 172.16\u201331.x, 192.168.x), and the plugin refuses to fetch from those by default: an import file comes from outside your site, and blindly following the URLs inside it is how a server ends up reading its own internal services.<\/p>\n\n<p>If you know the source site and want to allow it, name that one host:<\/p>\n\n<pre><code>add_filter( 'wxmi_allow_private_media_host', function ( $allow, $host ) {\n    return 'staging.example.com' === $host ? true : $allow;\n}, 10, 2 );\n<\/code><\/pre>\n\n<p>Only the host you name is allowed. Loopback, link-local and every other private host stay blocked, and there is no setting in the admin screen to switch this on \u2014 it takes deliberate code on your own site.<\/p><\/dd>\n<dt id=\"does%20it%20work%20on%20multisite%3F\"><h3>Does it work on Multisite?<\/h3><\/dt>\n<dd><p>Yes \u2014 a site selector appears wherever it's relevant (import, ID map, ACF field lookup) so you can target any site in the network without switching your admin session.<\/p><\/dd>\n<dt id=\"what%20if%20a%20scheduled%20cleanup%20is%20needed%3F\"><h3>What if a scheduled cleanup is needed?<\/h3><\/dt>\n<dd><p>An hourly cron job (<code>wxmi_cleanup_temp_files<\/code>) removes temporary uploaded XML\/cache files older than one hour, in case an import session is abandoned partway through.<\/p><\/dd>\n\n<\/dl>\n\n<!--section=changelog-->\n<h4>1.2.2<\/h4>\n\n<ul>\n<li><p>A relationship pointing at a post you have since trashed is now reported as that, instead\nof as definitely broken. The two need opposite responses \u2014 one says import the post type\nthat is missing, the other says restore something from the trash \u2014 so naming the second as\nthe first is worse than saying nothing.<\/p>\n\n<p>The \"Skipped N stale\/trashed reference(s)\" line existed for this and could never appear.\nIt fires when the ID map holds an entry whose target is trashed, but the map is built from\na query that excludes trashed posts, so the value fell through to the unresolved branch\ninstead. There it was judged by what post currently holds that ID on this site \u2014 nothing,\nsince the post was trashed rather than replaced \u2014 and \"points at nothing\" reads as broken.<\/p>\n\n<p>Reproduced before the change: a post imported and then trashed gave skipped=0 and was\ncounted among the definitely-broken. Afterwards, the same script on the same fixture gives\nskipped=1, one fewer definitely-broken, and no entry in the broken-reference list. What is\nstored does not change either way; the value was already left alone, and only the report\nwas wrong.<\/p>\n\n<p>Costs one query per run, not one per value.<\/p><\/li>\n<\/ul>\n\n<h4>1.2.1<\/h4>\n\n<p>Dates. Three columns were being filled in by WordPress rather than read from the export,\nand each was wrong in its own way.<\/p>\n\n<ul>\n<li><strong>post_modified<\/strong> is now what the source says. It cannot be passed to wp_insert_post() -\nthat function reads it from nowhere and decides for itself, writing the current time on an\nupdate and copying post_date on an insert - so it is written to the column afterwards,\nwhich is the only way there is. Anything ordered by \"recently modified\" therefore described\nwhen the import ran, or repeated the publish date. Measured on a post last edited\n2026-05-06 and published 2019-01-02: it arrived dated 2019-01-02.<\/li>\n<li><strong>post_date_gmt<\/strong> is taken from the export instead of being recomputed. Left out, WordPress\nderives it from post_date using the DESTINATION's timezone - so migrating between two zones\nshifted every post's UTC time by the difference while the local time looked untouched.\nMeasured importing a UTC export into a UTC+7 site: every GMT column out by exactly seven\nhours. Feeds, the REST API and anything scheduling from GMT read that column.<\/li>\n<li><strong>A draft no longer has its publish date reset when it is re-imported.<\/strong> wp_update_post()\nmoves post_date to the current time for any draft whose GMT date is the unfixed-time\nsentinel, which is exactly what a WXR file holds for a draft. The rule exists so that saving\na draft does not backdate it; an import is the opposite case, and edit_date is what tells\ncore the date was chosen deliberately. This one predates 1.2.0 - it needs a draft AND a\nre-import to show at all, which is why it went unnoticed.<\/li>\n<\/ul>\n\n<p>Checked against WXR generated by WordPress itself, on a first import and on a re-import, with\nthe destination in a different timezone from the export so that leaving a field out could not\naccidentally produce the right answer. 16 comparisons across 4 posts: before, 10 wrong; after,\n0.<\/p>\n\n<h4>1.2.0<\/h4>\n\n<p>Five fields the export carries were read by nothing, so an import produced a post that only\npartly resembled the source. Each was checked against WXR generated by WordPress itself\nrather than a file written by hand, and against real exports, on a first import and on a\nre-import.<\/p>\n\n<ul>\n<li>Page hierarchy is rebuilt instead of flattened. <code>wp:post_parent<\/code> holds an ID belonging to\nthe source site and was not being read at all, so every tree arrived flat \u2014 each child at\nthe top level with nothing to say it had ever had a parent. On one real 153-item export\nthat is 59 relationships lost in silence. Order in the file does not matter: a child that\narrives before its parent is placed when the parent turns up, which is not a hypothetical \u2014\n3 of those 59 appear before the parent they name. Verified with <code>get_post_ancestors()<\/code> and\n  get_pages() on a three-level page tree, imported parent-first and child-first; before\nthis, both came out with no ancestors at all and no children under the root.<\/li>\n<li>Loops are refused. WordPress does check for them, but its check begins \"new post can't\ncause a loop\" and returns \u2014 so a post created against a chain that is already looped is\nattached to it, and everything that walks ancestors then follows a line that never ends.\nReproduced by looping two pages by hand and importing a third naming one of them.<\/li>\n<li>Image alt text comes across. <code>_wp_attachment_image_alt<\/code> appeared nowhere in the plugin, so\nevery image arrived blank and the accessibility work done on the source site was lost in\nthe move. It is written on the attachment item, which is not a post that gets imported, so\nit is collected during the pass that already reads those items. Alt text on this site is\nnever overwritten \u2014 the export is authoritative about what the source said, not about what\nthe destination has since decided \u2014 and images already in the library pick it up without\nbeing fetched again.<\/li>\n<li>Post password is imported. A protected post arrived public, with nothing to indicate that\nanything had been dropped.<\/li>\n<li>Comment status, ping status and sticky are imported, instead of falling back to this\nsite's defaults. Comment and ping are taken only when they read <code>open<\/code> or <code>closed<\/code>;\n  comments_open() tests for the exact string, so any other value would behave as closed\nwhile claiming to be something else.<\/li>\n<li>An empty tag and a missing tag are told apart, which only shows on a re-import. Empty is\nthe export saying \"no password\", \"not sticky\", and is applied; a tag absent from the file\nsays nothing, and nothing here is overwritten on the strength of it. Real exports exist\nthat omit these fields entirely \u2014 two items from one were imported, given a password by\nhand, and re-imported, and the password was gone.<\/li>\n<li>Importing an export twice under different titles no longer leaves the second hierarchy in\nhalves. Where two posts on this site both claim the same source ID, the most recent is\ntaken, and a child that had to settle for an older copy is moved when its real parent\narrives \u2014 scoped to the import in progress, so the earlier tree is not pulled apart to\nbuild the new one.<\/li>\n<\/ul>\n\n<h4>1.1.3<\/h4>\n\n<ul>\n<li>An existing image is now identified by where it sits, not by what it is called.\nMatching was on the bare filename, and WordPress only keeps filenames unique inside one\nmonth folder \u2014 so a site with any history has \/2024\/01\/logo.png alongside\n\/2025\/06\/logo.png, and logo.png, banner.jpg and hero.jpg are exactly the names that\nrepeat. Reproduced with two such images: asking for the 2025 one returned the 2024 one,\nand the right one was then never downloaded, because the import believed it already had\nit. The wrong picture, with nothing reported. Matching is now on the upload path \u2014\n2025\/06\/logo.png \u2014 compared for equality against what WordPress records for each file.\nThis is the case the plugin is written for, a destination that already holds media, and\nnot an edge case.<\/li>\n<li>A match found that way is no longer written back as if it were fact. The old code\nstamped its guess onto the attachment, so the next lookup accepted it without\nre-examining and a wrong match could never correct itself. That record is now written in\none place only, after a file has genuinely been downloaded from that URL.<\/li>\n<li>When media download is switched off, the filename is still used, but only after the path\nhas been tried and found nothing. In that mode the user has said the files are already\non the site, so a missed match means no image at all rather than a re-download \u2014 an\nimperfect match beats a missing one there, while a right answer still wins wherever both\nare available.<\/li>\n<li>Removed the Screenshots section from this file. It described two screenshots that were\nnot shipped.<\/li>\n<li>Post content containing \"]]&gt;\" is no longer mangled. A CDATA section cannot hold that\nsequence, so an exporter meeting one has to break out and back in \u2014 WordPress writes\n\"]]]]&gt;&lt;![CDATA[&gt;\" \u2014 and an XML parser rejoins the halves. Reading the file with a regular\nexpression did not, so those six characters were stored as though the author had typed\nthem. Any post quoting XML, a regular expression or a code sample was affected, silently.\nChecked against a real XML parser on all 5,179 items of a real export: 36,253 fields\ncompared, no differences.<\/li>\n<li>Values held outside CDATA now have their XML escapes decoded, so \"Tom &amp;amp; Jerry\"\nreads as \"Tom &amp; Jerry\". Nothing in the import path reads such a field today, because\nWordPress wraps everything carrying text in CDATA; this is so that the next field that\ndoes read one does not inherit the problem.<\/li>\n<li>Importing with media download switched off now reports what it did. That route sets a\nfeatured image from a file already in the library, and it was the one media path that\nsaid nothing at all - a post that should have had an image and did not get one looked\nexactly like a post that never wanted one. Images attached this way are counted as\nalready in the library, since nothing is transferred, and a post whose image this site\ndoes not have is now listed. Measured against the database on the same run: what the\nscreen reports and what was actually attached now agree.<\/li>\n<li>The plugin no longer loads itself on front-end requests. Every hook it registers is an\nadmin screen, an admin-ajax action or the cleanup cron, none of which can fire for a\nvisitor, yet all eleven were being registered on every page view. This is tidiness\nrather than speed - the work avoided measures at 0.0092 ms and no query - but eleven\nfewer entries in the hook table is worth having.<\/li>\n<li>Every message the plugin shows can now be translated. Twenty-four of them \u2014 everything\nfrom \"No file uploaded\" to \"Import cache not found\" \u2014 were returned as bare English\nstrings that never passed through WordPress's translation layer, so a translated site\nshowed them in English no matter what. Confirmed by marking every string that goes\nthrough that layer and watching the responses: none of them did before, all of them do\nnow.<\/li>\n<\/ul>\n\n<h4>1.1.2<\/h4>\n\n<p>Security:<\/p>\n\n<ul>\n<li>Files written during an import are no longer readable by anyone who asks. They were\nplaced in a fixed uploads\/wxmi-temp\/ protected by an .htaccess containing \"deny from\nall\" \u2014 which is Apache configuration. nginx, LiteSpeed and IIS do not read .htaccess at\nall, so on any of them there was no protection, and the accompanying index.php only\nprevents a directory listing, not a request for a file whose name is already known. The\nnames were known: the uploaded export is wxmi__.xml and each parsed\npost is items_\/.txt, so on a normal site the first one is items_1\/0.txt \u2014\nnothing to guess. Confirmed on nginx: that URL returned the file's contents without\nlogging in, as did the export. The directory now carries a random 128-bit component in\nits name, which is the only defence that does not depend on the web server; the\n.htaccess and index.php are still written as a second layer. Upgrading removes the old\ndirectory.<\/li>\n<\/ul>\n\n<p>Housekeeping:<\/p>\n\n<ul>\n<li>Two names that did not use the plugin's <code>wxmi<\/code> prefix now do: the filter\n  migravo_allow_private_media_host is now <code>wxmi_allow_private_media_host<\/code>, and the main\nclass is <code>WXMI_Media_Migration<\/code>. Nothing outside the plugin can have been relying on\neither \u2014 this is its first release under this name.<\/li>\n<li>The ID map option is no longer rewritten once per imported post. It was read,\nre-serialised and written back for every post, to add one entry: on one site that is\n4,881 entries at 74.8 KB with autoload off, so roughly 45 seconds and 189 MB of writes\nacross a 5,179-post import. The <code>_wxmi_old_post_id<\/code> meta written beside it already\nrecorded the same thing per post, and the map is assembled from both, so nothing is\nlost \u2014 verified by importing the same export with the option emptied and confirming\nrelationships still remapped to the same ids.<\/li>\n<\/ul>\n\n<h4>1.1.1<\/h4>\n\n<p>Every item below was reproduced before it was fixed, and the same test was run again\nafterwards. Two of them were found only because the plugin was pointed at real exports\nrather than a test site.<\/p>\n\n<p>Security:<\/p>\n\n<ul>\n<li>A media URL that redirected was followed wherever it led. 1.1.0 blocked the cloud\nmetadata address 169.254.169.254 when the import file named it directly, then allowed\nredirects to be followed \u2014 and the check that catches that address ran only on the URL\nfrom the file, never on a redirect target. What guarded the hops was WordPress's own\nwp_http_validate_url(), which rejects loopback and private ranges but allows link-local.\nSo a public URL returning a 302 to the metadata endpoint was fetched: measured, it\nproduced a connection error rather than a refusal, meaning the request left the machine.\nOn a host where that address answers, the response \u2014 instance credentials \u2014 would have\nbeen stored in the media library. Every hop is now checked by the same rule as the first.\nFollowing redirects at all is what made this reachable, so it is worth saying plainly\nthat 1.0.0, which refused them outright, was not exposed to this.<\/li>\n<\/ul>\n\n<p>Reliability:<\/p>\n\n<ul>\n<li>An import running longer than an hour was destroyed by the plugin's own cleanup, in two\nindependent ways, either of which was enough. The transients holding the cache location\nand the attachment and author maps expired after an hour, and the hourly cron deleted\nany cache directory older than an hour \u2014 and nothing had written to that directory since\nthe upload, so an import three hours in looked exactly as abandoned as one given up on\nat the start. Both were reproduced in isolation. The cache directory is now touched on\nevery post imported, so \"abandoned\" is measured from the last request instead of from\nthe upload; the transients last a day; and the cron reaps at twelve hours.<\/li>\n<li>A failed write to the item cache is now reported. The result of that write was discarded,\nso when it failed \u2014 a full disk, a permissions change \u2014 the upload still answered success\nand still listed every post it had found, because that list comes from parsing the XML\nand not from what reached disk. The run only came apart later, at import, blaming the\nexport: \"Content, title, and excerpt are empty.\"<\/li>\n<\/ul>\n\n<p>Interface:<\/p>\n\n<ul>\n<li>The ACF field mapping row appeared for some sites and not others, with nothing to\nexplain it. Field keys were matched against a hexadecimal pattern, which only fits the\nkeys ACF generates for itself in the admin UI. A field declared in PHP or in\nhand-written JSON normally carries a readable key \u2014 field_practice_areas \u2014 and every one\nof those failed the test, leaving the list empty and the row hidden. Across the 1,974\ndistinct field keys on one machine the old pattern matched 1,229 of them, and 15% of\nfield groups were entirely unmatched, meaning importing that post type offered nothing\nto map at all.<\/li>\n<li>\"Media downloaded\" now says what actually happened. It was one number that answered\nneither question it was asked. It missed the featured-image-field route entirely \u2014 that\nbranch reported neither successes nor failures, so an import driven by that setting\nfinished reporting 0 whether every image had arrived or none had; measured on a 47-item\nexport, 47 images downloaded and the screen said 0. And \"downloaded\" was never what it\ncounted: files already in the library counted too, so a second import of the same export\nreported a large figure while transferring nothing. It now reads \"Media attached: 47\n(47 uploaded, 0 already in library)\", and failures from that route are listed with the\nfield they came from.<\/li>\n<li>A featured-image URL is kept when its download fails. It used to be discarded either\nway, so a failure left no thumbnail and no record of what the image should have been.<\/li>\n<\/ul>\n\n<h4>1.1.0<\/h4>\n\n<p>Tested against a real 52 MB \/ 5,179-post WXR export. Every item below is a problem that\nexport exposed.<\/p>\n\n<p>Reliability:<\/p>\n\n<ul>\n<li>Large files no longer exhaust memory. Parsing an upload made five passes over the whole\ndocument, holding it and a match array of every item at the same time \u2014 roughly 8x the\nfile size at peak, enough for a host to kill the process before PHP could report\nanything. It now streams the file one item at a time: the 52 MB export peaks at 4 MB\nand parses in about 44 seconds.<\/li>\n<li>Failed posts are retried instead of being lost. A brief server hiccup used to lose every\npost whose request was in flight \u2014 790 posts in one run \u2014 while the screen still said\n\"Import Complete!\". Transport failures are now retried up to three times with a growing\ndelay, which recovered all but 11 of those posts on the same data.<\/li>\n<li>Posts that still fail are reported and can be retried. The import summary lists them and\noffers a retry button; the parsed data is kept on the server so retrying does not mean\nuploading again.<\/li>\n<li>Stopping an import no longer discards it. Pressing Stop used to make resuming impossible\n\u2014 while closing the browser did not \u2014 so stopping properly was penalised. Reopen the\npage and the import offers to continue where it left off.<\/li>\n<\/ul>\n\n<p>Data integrity:<\/p>\n\n<ul>\n<li>Relationship repair now reports what it could not fix, and says which of those are\ndefinitely broken. An ID with no mapping was left alone and never mentioned, so a field\ncould still point at the wrong post with nothing to indicate it \u2014 the number looks like a\nvalid ID, so nothing errors. Each one is now checked against the field's own definition:\nACF records which post types a Relationship or Post Object field accepts, so a value of\nanother type is wrong as a matter of fact and is reported as broken, while a value of an\naccepted type is marked as almost certainly already repaired. On one real site this\nidentified 1,240 broken references across 964 fields that had previously gone unmentioned.<\/li>\n<li>Where a post came from is now recorded on the post itself, not only in one option. The\nmap can be rebuilt from the database, so it survives clearing it and no longer depends\non still having the export file.<\/li>\n<li><p>Removed a \"sync two-way relationships\" step that wrote back more than it should. After\nrepairing a relationship it also wrote the reverse link onto each post the field pointed\nat, storing it under the same field name. That name belongs to the forward field, which is\nusually restricted to the post type on the other side, so the reverse values it wrote sit\nin a field that does not accept them \u2014 the same thing this plugin's own check reports as\n\"points at the wrong kind of post\". It did this whether or not the field asked for a\ntwo-way relationship, which ACF has its own setting for, and it wrote no field-key row\nbeside the value, so the result depended on some field group happening to apply to that\npost. Nothing replaces it: repairing relationships sweeps every post type in one pass, so\nboth sides are fixed whenever both are present. Values an earlier version wrote are left\nexactly as they are \u2014 they are stored under a real field's name and cannot be told apart\nfrom relationships you set yourself, so removing them is a decision only you can make.<\/p><\/li>\n<li><p>Re-importing the same file no longer creates duplicate posts. Slugs containing\ncharacters WordPress strips (for example a leading \"$\") were stored differently from\nwhat the file said, so \"Skip\" could not recognise those posts and made a second copy of\neach one on every import. The example export contained 112 such slugs.<\/p><\/li>\n<li>Relationship remapping and the ID map now find those posts too. They were previously\nreported as missing, and their relationships were left pointing at the old site's IDs.<\/li>\n<li>Remapping relationships no longer scans the whole postmeta table in one query. It works\nin pages, so the operation stays within limits on large sites.<\/li>\n<\/ul>\n\n<p>Interface:<\/p>\n\n<ul>\n<li>Updated admin scripts now reach the browser. The script URL never changed between\nreleases, so browsers kept running the cached older copy and fixes appeared to have no\neffect.<\/li>\n<li>The results panel no longer announces \"Import Complete!\" for an import that was stopped\nor that lost posts.<\/li>\n<\/ul>\n\n<p>Media download:<\/p>\n\n<ul>\n<li>Media now downloads from source sites that redirect. Redirects were refused outright,\nso an http-to-https redirect \u2014 near universal \u2014 failed every download with an error the\nuser could do nothing about. Following them is no less safe: the internal-address check\nis re-applied to each redirect, so a public URL still cannot be used to reach an\ninternal one.<\/li>\n<li>Importing no longer stalls waiting for shared images. When one image was already being\nfetched, the import slept up to ten seconds for it \u2014 and since a single image is often\nreused across many posts, several imports could be asleep at once and exhaust the\nserver's PHP workers, taking the whole run down. The wait is now about a second.<\/li>\n<li>Cloud metadata addresses are blocked. WordPress's own URL check rejects loopback and\nprivate ranges but allows 169.254.169.254, the address AWS and Azure serve instance\ncredentials on, so an import file could have had that response stored in the media\nlibrary. Link-local and reserved addresses are now rejected too.<\/li>\n<li>AVIF images are imported. The patterns that scan post content for media listed every\nother web image format but not AVIF, so an AVIF image was never even found, let alone\ndownloaded - its URL was left pointing at the source site with nothing reported.<\/li>\n<li>A source site on a private network can be allowed on purpose, one host at a time, with\nthe new <code>wxmi_allow_private_media_host<\/code> filter \u2014 which is what makes migrating from\na staging site possible without weakening the default for everyone else.<\/li>\n<\/ul>\n\n<p>Housekeeping:<\/p>\n\n<ul>\n<li>Deactivating the plugin now removes its temporary files. Previously it only cancelled\nthe hourly cleanup task, so anything left over from an abandoned or failed import \u2014 the\nuploaded file plus one cache file per item \u2014 stayed in the uploads folder with nothing\nleft to reclaim it.<\/li>\n<li>Deleting the plugin now removes what it stored under its own name: the accumulated post\nID map, the record it keeps on each imported post, the source URL noted against each\ndownloaded attachment, its transients, and the temporary folder \u2014 around 4,600 rows on one\nreal site. On Multisite this runs for every site. Imported posts, media and field values\nare yours and stay; the plugin only cleans up its own bookkeeping.<\/li>\n<\/ul>\n\n<h4>1.0.0<\/h4>\n\n<ul>\n<li>Initial release.<\/li>\n<\/ul>","raw_excerpt":"Repairs ACF Relationship and Gallery fields broken by a WXR import, and re-hosts the media the import left behind on the old site.","jetpack_sharing_enabled":true,"_links":{"self":[{"href":"https:\/\/de.wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin\/349219","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/de.wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin"}],"about":[{"href":"https:\/\/de.wordpress.org\/plugins\/wp-json\/wp\/v2\/types\/plugin"}],"replies":[{"embeddable":true,"href":"https:\/\/de.wordpress.org\/plugins\/wp-json\/wp\/v2\/comments?post=349219"}],"author":[{"embeddable":true,"href":"https:\/\/de.wordpress.org\/plugins\/wp-json\/wporg\/v1\/users\/dinhqui"}],"wp:attachment":[{"href":"https:\/\/de.wordpress.org\/plugins\/wp-json\/wp\/v2\/media?parent=349219"}],"wp:term":[{"taxonomy":"plugin_section","embeddable":true,"href":"https:\/\/de.wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin_section?post=349219"},{"taxonomy":"plugin_tags","embeddable":true,"href":"https:\/\/de.wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin_tags?post=349219"},{"taxonomy":"plugin_category","embeddable":true,"href":"https:\/\/de.wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin_category?post=349219"},{"taxonomy":"plugin_contributors","embeddable":true,"href":"https:\/\/de.wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin_contributors?post=349219"},{"taxonomy":"plugin_business_model","embeddable":true,"href":"https:\/\/de.wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin_business_model?post=349219"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}