diff --git a/components/Markdown/class-markdownconsumer.php b/components/Markdown/class-markdownconsumer.php index 768282f8..43e914c5 100644 --- a/components/Markdown/class-markdownconsumer.php +++ b/components/Markdown/class-markdownconsumer.php @@ -443,7 +443,12 @@ private function parse_frontmatter_block( $frontmatter ) { } private function parse_frontmatter_scalar( $raw ) { - if ( preg_match( '/^\[|\{|- /', $raw ) ) { + // Detect YAML flow sequences ([...]), flow mappings ({...}), and block + // sequence items (- item) by their leading indicator. The `^` must bind all + // three alternatives: without the group, `\{` and `- ` matched anywhere in + // the value, so any quoted scalar containing "{" or "- " (e.g. a title like + // "Jetpack 13.0 - AI Assistant") was wrongly parsed as an empty array. + if ( preg_match( '/^(?:\[|\{|- )/', $raw ) ) { return array(); } diff --git a/plugins/push-md/Tests/EndToEndTest.php b/plugins/push-md/Tests/EndToEndTest.php index 7d70affe..eebce6ca 100644 --- a/plugins/push-md/Tests/EndToEndTest.php +++ b/plugins/push-md/Tests/EndToEndTest.php @@ -132,6 +132,183 @@ public function testCloneFailsClosedWhenPageParentIsNotExported() { } } + /** + * Seeding mode (push_md_allow_create_on_missing_id) must resolve by slug and + * ignore the front matter id, even when that id points at a *different* post of + * the same type. Without the fix this rejects with "conflicts with the WordPress + * post already mapped to this file path". + */ + public function testSeedingResolvesBySlugWhenIdPointsAtAnotherPost() { + $this->set_allow_create_on_missing_id( true ); + try { + $suffix = uniqid( 'pmd-stype-' ); + $id_a = $this->create_page_via_rest( + array( + 'slug' => $suffix . '-a', + 'title' => 'Stype A ' . $suffix, + 'status' => 'publish', + 'content' => '
STYPE-A-ORIG
', + ) + ); + $id_b = $this->create_page_via_rest( + array( + 'slug' => $suffix . '-b', + 'title' => 'Stype B ' . $suffix, + 'status' => 'publish', + 'content' => 'STYPE-B-ORIG
', + ) + ); + + $clone = $this->clone_repo( $suffix ); + $this->configure_git( $clone ); + $page_md = $clone . '/page/' . $suffix . '-a.md'; + $this->assertFileExists( $page_md ); + // Point page A's file at page B's id (same type, different post) and edit body. + $this->edit_file( $page_md, 'id: "' . $id_a . '"', 'id: "' . $id_b . '"' ); + $this->edit_file( $page_md, 'STYPE-A-ORIG', 'STYPE-A-UPDATED' ); + $this->commit_and_push( $clone, 'page/' . $suffix . '-a.md', 'Seed: id points at another post' ); + + // The update must land on page A (slug match), not page B (the id'd post). + $this->assertStringContainsString( 'STYPE-A-UPDATED', $this->fetch_content( $id_a, 'pages' ) ); + $this->assertStringContainsString( 'STYPE-B-ORIG', $this->fetch_content( $id_b, 'pages' ) ); + } finally { + $this->set_allow_create_on_missing_id( false ); + } + } + + /** + * Seeding mode must resolve by slug even when the front matter id collides with a + * post of a *different* type. Without the fix this rejects with "references a + * different WordPress post type". + */ + public function testSeedingResolvesBySlugWhenIdCollidesWithOtherType() { + $this->set_allow_create_on_missing_id( true ); + try { + $suffix = uniqid( 'pmd-xtype-' ); + $post_id = $this->create_post_via_rest( + array( + 'slug' => $suffix . '-post', + 'title' => 'Xtype Post ' . $suffix, + 'status' => 'publish', + 'content' => 'XTYPE-POST-ORIG
', + ) + ); + $page_id = $this->create_page_via_rest( + array( + 'slug' => $suffix . '-page', + 'title' => 'Xtype Page ' . $suffix, + 'status' => 'publish', + 'content' => 'XTYPE-PAGE-ORIG
', + ) + ); + + $clone = $this->clone_repo( $suffix ); + $this->configure_git( $clone ); + $page_md = $clone . '/page/' . $suffix . '-page.md'; + $this->assertFileExists( $page_md ); + // Point the page file at the *post's* id (cross-type collision) and edit body. + $this->edit_file( $page_md, 'id: "' . $page_id . '"', 'id: "' . $post_id . '"' ); + $this->edit_file( $page_md, 'XTYPE-PAGE-ORIG', 'XTYPE-PAGE-UPDATED' ); + $this->commit_and_push( $clone, 'page/' . $suffix . '-page.md', 'Seed: cross-type id collision' ); + + $this->assertStringContainsString( 'XTYPE-PAGE-UPDATED', $this->fetch_content( $page_id, 'pages' ) ); + $this->assertStringContainsString( 'XTYPE-POST-ORIG', $this->fetch_content( $post_id, 'posts' ) ); + } finally { + $this->set_allow_create_on_missing_id( false ); + } + } + + /** + * Two files in one push whose front matter ids collide must both resolve by their + * own slugs. Without the fix this rejects with "multiple Markdown files reference + * the same WordPress post ID". + */ + public function testSeedingAllowsTwoFilesWithCollidingIds() { + $this->set_allow_create_on_missing_id( true ); + try { + $suffix = uniqid( 'pmd-dup-' ); + $page_id = $this->create_page_via_rest( + array( + 'slug' => $suffix . '-page', + 'title' => 'Dup Page ' . $suffix, + 'status' => 'publish', + 'content' => 'DUP-PAGE-ORIG
', + ) + ); + $post_id = $this->create_post_via_rest( + array( + 'slug' => $suffix . '-post', + 'title' => 'Dup Post ' . $suffix, + 'status' => 'publish', + 'content' => 'DUP-POST-ORIG
', + ) + ); + + $clone = $this->clone_repo( $suffix ); + $this->configure_git( $clone ); + $page_md = $clone . '/page/' . $suffix . '-page.md'; + $post_md = $clone . '/post/' . $suffix . '-post.md'; + $this->assertFileExists( $page_md ); + $this->assertFileExists( $post_md ); + // Make both files claim the same id (the post's), so id-based resolution + // would map both to one post; slug-based resolution keeps them distinct. + $this->edit_file( $page_md, 'id: "' . $page_id . '"', 'id: "' . $post_id . '"' ); + $this->edit_file( $page_md, 'DUP-PAGE-ORIG', 'DUP-PAGE-UPDATED' ); + $this->edit_file( $post_md, 'DUP-POST-ORIG', 'DUP-POST-UPDATED' ); + + $this->run_cmd( array( 'git', '-C', $clone, 'add', 'page/' . $suffix . '-page.md', 'post/' . $suffix . '-post.md' ) ); + $this->run_cmd( array( 'git', '-C', $clone, 'commit', '-m', 'Seed: two files, colliding ids' ) ); + $this->run_cmd( array( 'git', '-C', $clone, 'push', 'origin', 'trunk' ) ); + + $this->assertStringContainsString( 'DUP-PAGE-UPDATED', $this->fetch_content( $page_id, 'pages' ) ); + $this->assertStringContainsString( 'DUP-POST-UPDATED', $this->fetch_content( $post_id, 'posts' ) ); + } finally { + $this->set_allow_create_on_missing_id( false ); + } + } + + /** + * Production mode (option off) must still reject a front matter id that references + * a different post type -- the strict guard is preserved. + */ + public function testProductionStillRejectsCrossTypeIdMismatch() { + $this->set_allow_create_on_missing_id( false ); + $suffix = uniqid( 'pmd-strict-' ); + $post_id = $this->create_post_via_rest( + array( + 'slug' => $suffix . '-post', + 'title' => 'Strict Post ' . $suffix, + 'status' => 'publish', + 'content' => 'STRICT-POST-ORIG
', + ) + ); + $page_id = $this->create_page_via_rest( + array( + 'slug' => $suffix . '-page', + 'title' => 'Strict Page ' . $suffix, + 'status' => 'publish', + 'content' => 'STRICT-PAGE-ORIG
', + ) + ); + + $clone = $this->clone_repo( $suffix ); + $this->configure_git( $clone ); + $page_md = $clone . '/page/' . $suffix . '-page.md'; + $this->assertFileExists( $page_md ); + $this->edit_file( $page_md, 'id: "' . $page_id . '"', 'id: "' . $post_id . '"' ); + $this->edit_file( $page_md, 'STRICT-PAGE-ORIG', 'STRICT-PAGE-UPDATED' ); + + $this->run_cmd( array( 'git', '-C', $clone, 'add', 'page/' . $suffix . '-page.md' ) ); + $this->run_cmd( array( 'git', '-C', $clone, 'commit', '-m', 'Strict: cross-type id mismatch' ) ); + $push = $this->run_cmd( array( 'git', '-C', $clone, 'push', 'origin', 'trunk' ), true ); + + $this->assertNotSame( 0, $push['code'], 'Production push must reject a cross-type id mismatch.' ); + $this->assertStringContainsString( + 'Push rejected because Markdown front matter id references a different WordPress post type.', + $push['output'] + ); + } + public function testFullRoundTrip() { $hierarchy_suffix = uniqid( 'hierarchy-' ); $parent_a_slug = 'parent-a-' . $hierarchy_suffix; @@ -1064,6 +1241,50 @@ private function update_post_via_rest( $id, array $payload ) { $this->assertSame( 200, $status, "REST update failed: $response" ); } + private function create_post_via_rest( array $payload ) { + $ch = curl_init( $this->base_url . '/wp-json/wp/v2/posts?context=edit' ); + curl_setopt_array( + $ch, + array( + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CUSTOMREQUEST => 'POST', + CURLOPT_HTTPHEADER => array( $this->auth_header, 'Content-Type: application/json' ), + CURLOPT_POSTFIELDS => pmd_e2e_json_encode( $payload ), + ) + ); + $response = curl_exec( $ch ); + $status = curl_getinfo( $ch, CURLINFO_HTTP_CODE ); + curl_close( $ch ); + $this->assertTrue( 200 === $status || 201 === $status, "REST post create failed: $response" ); + + $post = json_decode( $response, true ); + $this->assertIsArray( $post, "Unexpected REST post create response: $response" ); + $this->assertArrayHasKey( 'id', $post, "REST post create response had no ID: $response" ); + + return intval( $post['id'] ); + } + + private function set_allow_create_on_missing_id( $enabled ) { + $ch = curl_init( $this->base_url . '/wp-json/push-md-test/v1/allow-create-on-missing-id' ); + curl_setopt_array( + $ch, + array( + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CUSTOMREQUEST => 'POST', + CURLOPT_HTTPHEADER => array( $this->auth_header, 'Content-Type: application/json' ), + CURLOPT_POSTFIELDS => pmd_e2e_json_encode( array( 'enabled' => (bool) $enabled ) ), + ) + ); + $response = curl_exec( $ch ); + $status = curl_getinfo( $ch, CURLINFO_HTTP_CODE ); + curl_close( $ch ); + $this->assertSame( + 200, + $status, + 'Could not toggle push_md_allow_create_on_missing_id (is ci-mu-test-helper.php installed?): ' . $response + ); + } + private function curl_get( $url ) { $ch = curl_init( $url ); curl_setopt_array( diff --git a/plugins/push-md/Tests/ExportPathTest.php b/plugins/push-md/Tests/ExportPathTest.php index 7e531a4e..49450ca7 100644 --- a/plugins/push-md/Tests/ExportPathTest.php +++ b/plugins/push-md/Tests/ExportPathTest.php @@ -12,9 +12,18 @@ class WP_Post { public $post_type = 'post'; public $post_name = ''; public $post_parent = 0; + public $post_status = 'publish'; + public $post_title = ''; + public $post_date = '2000-01-01 00:00:00'; + public $post_date_gmt = '2000-01-01 00:00:00'; + public $post_modified_gmt = '2000-01-01 00:00:00'; + public $post_content = 'Test content
'; + public $post_excerpt = ''; } } +$push_md_test_posts = array(); + if ( ! function_exists( 'sanitize_title' ) ) { function sanitize_title( $title ) { $title = strtolower( (string) $title ); @@ -38,10 +47,125 @@ function taxonomy_exists( $taxonomy ) { } } +if ( ! function_exists( 'current_user_can' ) ) { + function current_user_can( $capability ) { + unset( $capability ); + + return true; + } +} + +if ( ! function_exists( 'get_posts' ) ) { + function get_posts( $args = array() ) { + global $push_md_test_posts; + + $posts = $push_md_test_posts; + + if ( isset( $args['post_type'] ) ) { + $post_types = (array) $args['post_type']; + $posts = array_filter( + $posts, + function ( $post ) use ( $post_types ) { + return in_array( $post->post_type, $post_types, true ); + } + ); + } + + if ( isset( $args['name'] ) ) { + $posts = array_filter( + $posts, + function ( $post ) use ( $args ) { + return $post->post_name === $args['name']; + } + ); + } + + if ( isset( $args['post_parent'] ) ) { + $posts = array_filter( + $posts, + function ( $post ) use ( $args ) { + return intval( $post->post_parent ) === intval( $args['post_parent'] ); + } + ); + } + + if ( isset( $args['post_status'] ) ) { + $statuses = (array) $args['post_status']; + $posts = array_filter( + $posts, + function ( $post ) use ( $statuses ) { + return in_array( $post->post_status, $statuses, true ); + } + ); + } + + usort( + $posts, + function ( $a, $b ) { + return intval( $a->ID ) - intval( $b->ID ); + } + ); + + if ( isset( $args['fields'] ) && 'ids' === $args['fields'] ) { + return array_map( + function ( $post ) { + return intval( $post->ID ); + }, + $posts + ); + } + + return $posts; + } +} + +if ( ! function_exists( 'get_post' ) ) { + function get_post( $post_id ) { + global $push_md_test_posts; + + foreach ( $push_md_test_posts as $post ) { + if ( intval( $post->ID ) === intval( $post_id ) ) { + return $post; + } + } + + return null; + } +} + +if ( ! function_exists( 'get_option' ) ) { + function get_option( $name, $default = false ) { + unset( $name ); + + return $default; + } +} + +if ( ! function_exists( 'wp_json_encode' ) ) { + function wp_json_encode( $data, $options = 0 ) { + return json_encode( $data, $options ); + } +} + +if ( ! function_exists( 'esc_html' ) ) { + function esc_html( $text ) { + return htmlspecialchars( (string) $text, ENT_QUOTES, 'UTF-8' ); + } +} + require_once dirname( __DIR__ ) . '/class-push-md-plugin.php'; class PMD_Export_Path_Test extends TestCase { + /** + * @after + */ + public function reset_test_posts() { + global $push_md_test_posts; + + $push_md_test_posts = array(); + } + public function testPostWithEmptySlugUsesStableIdFallbackPath() { $this->assertSame( 'post/post-4937.md', @@ -100,16 +224,102 @@ public function testFallbackShapedRealSlugIsAcceptedWhenItIsCurrent() { ); } - private function post( $id, $post_type, $post_name ) { + public function testDuplicatePathThrowsActionableErrorNamingBothPosts() { + $this->set_test_posts( + array( + $this->post( 101, 'post', 'shared-slug' ), + $this->post( 202, 'post', 'shared-slug' ), + ) + ); + + try { + $this->export_wordpress_content(); + $this->fail( 'Expected duplicate export paths to be rejected.' ); + } catch ( Exception $exception ) { + $message = $exception->getMessage(); + $this->assertStringContainsString( 'post/shared-slug.md', $message ); + $this->assertStringContainsString( '101', $message ); + $this->assertStringContainsString( '202', $message ); + } + } + + public function testDetectExportPathCollisionsReturnsConflictingGroup() { + $this->set_test_posts( + array( + $this->post( 101, 'post', 'shared-slug' ), + $this->post( 202, 'post', 'shared-slug' ), + $this->post( 303, 'post', 'unique-slug' ), + ) + ); + + $collisions = Push_MD_Plugin::detect_export_path_collisions(); + + $this->assertCount( 1, $collisions ); + $this->assertSame( 'post/shared-slug.md', $collisions[0]['path'] ); + $this->assertSame( array( 101, 202 ), $collisions[0]['post_ids'] ); + } + + public function testDetectExportPathCollisionsIgnoresUniquePaths() { + $this->set_test_posts( + array( + $this->post( 101, 'post', 'first-slug' ), + $this->post( 202, 'post', 'second-slug' ), + ) + ); + + $this->assertSame( array(), Push_MD_Plugin::detect_export_path_collisions() ); + } + + public function testDetectExportPathCollisionsSkipsUnbuildablePaths() { + // A published page pointing at a missing parent makes build_markdown_path() + // throw for that page; detection must skip it instead of aborting, and + // still report the unrelated post collision. + $orphan = $this->post( 70, 'page', 'orphan' ); + $orphan->post_parent = 9999; + + $this->set_test_posts( + array( + $orphan, + $this->post( 101, 'post', 'shared-slug' ), + $this->post( 202, 'post', 'shared-slug' ), + ) + ); + + $collisions = Push_MD_Plugin::detect_export_path_collisions(); + + $this->assertCount( 1, $collisions ); + $this->assertSame( 'post/shared-slug.md', $collisions[0]['path'] ); + $this->assertSame( array( 101, 202 ), $collisions[0]['post_ids'] ); + } + + private function post( $id, $post_type, $post_name, $post_status = 'publish', $post_date_gmt = '2000-01-01 00:00:00' ) { $post = new WP_Post(); $post->ID = $id; $post->post_type = $post_type; $post->post_name = $post_name; $post->post_parent = 0; + $post->post_status = $post_status; + $post->post_title = 'Post ' . $id; + $post->post_date = $post_date_gmt; + $post->post_date_gmt = $post_date_gmt; + $post->post_modified_gmt = $post_date_gmt; return $post; } + private function set_test_posts( $posts ) { + global $push_md_test_posts; + + $push_md_test_posts = $posts; + } + + private function export_wordpress_content() { + $method = new ReflectionMethod( Push_MD_Plugin::class, 'export_wordpress_content' ); + $method->setAccessible( true ); + + return $method->invoke( null ); + } + private function assert_id_fallback_path_is_current( $path, WP_Post $post ) { $method = new ReflectionMethod( Push_MD_Plugin::class, 'assert_id_fallback_path_is_current' ); $method->setAccessible( true ); diff --git a/plugins/push-md/Tests/ci-mu-test-helper.php b/plugins/push-md/Tests/ci-mu-test-helper.php index 885a7bbb..7935184b 100644 --- a/plugins/push-md/Tests/ci-mu-test-helper.php +++ b/plugins/push-md/Tests/ci-mu-test-helper.php @@ -29,3 +29,29 @@ add_filter( 'push_md_seed_tick_reschedule_seconds', static function () { return 0; } ); + +// CI-only route so the e2e suite can flip push_md_allow_create_on_missing_id +// (local one-way seeding mode) over HTTP -- the test process has no wp-cli. +// Admin-only; this mu-plugin is dropped in only by the e2e workflow. +add_action( 'rest_api_init', static function () { + register_rest_route( + 'push-md-test/v1', + '/allow-create-on-missing-id', + array( + 'methods' => 'POST', + 'permission_callback' => static function () { + return current_user_can( 'manage_options' ); + }, + 'callback' => static function ( $request ) { + $enabled = (bool) $request->get_param( 'enabled' ); + if ( $enabled ) { + update_option( 'push_md_allow_create_on_missing_id', 1 ); + } else { + delete_option( 'push_md_allow_create_on_missing_id' ); + } + + return array( 'enabled' => $enabled ); + }, + ) + ); +} ); diff --git a/plugins/push-md/admin-shell.css b/plugins/push-md/admin-shell.css index b9f49a31..8a27f1ba 100644 --- a/plugins/push-md/admin-shell.css +++ b/plugins/push-md/admin-shell.css @@ -449,3 +449,54 @@ padding-top: 0; } } + +.push-md-collisions-panel { + border-color: var(--push-md-red); + background: #fff5f5; +} + +.push-md-collisions-panel[hidden] { + display: none; +} + +.push-md-collisions-panel h2 { + color: #8a1f11; +} + +.push-md-collisions-panel p { + margin: 0 0 12px; + color: var(--push-md-ink); + font-size: 13px; + line-height: 1.4; +} + +.push-md-collisions-list { + display: flex; + flex-direction: column; + gap: 12px; + margin: 0; +} + +.push-md-collisions-list > li { + margin: 0; + font-size: 12px; + line-height: 1.35; +} + +.push-md-collisions-list > li > code { + color: #8a1f11; + background: #ffe3e3; + border-radius: 4px; + padding: 1px 4px; +} + +.push-md-collisions-list ul { + margin: 6px 0 0; + padding-left: 18px; + list-style: disc; + color: var(--push-md-muted); +} + +.push-md-collisions-list ul li { + margin: 2px 0; +} diff --git a/plugins/push-md/admin-shell.js b/plugins/push-md/admin-shell.js index f7286d73..c64f1808 100644 --- a/plugins/push-md/admin-shell.js +++ b/plugins/push-md/admin-shell.js @@ -30,6 +30,8 @@ var cwdEl = document.getElementById( 'push-md-prompt-cwd' ); var titleEl = document.getElementById( 'push-md-terminal-title' ); var commitListEl = document.getElementById( 'push-md-commit-list' ); + var collisionPanelEl = document.getElementById( 'push-md-collisions-panel' ); + var collisionListEl = document.getElementById( 'push-md-collisions-list' ); var checkout = normalizeCheckout( progress.checkout ); var cwd = '/'; var history = []; @@ -58,6 +60,7 @@ ); messageEl.textContent = progress.message; renderCommits( progress.commits || [] ); + renderCollisions( progress.collisions || [] ); if (previousState !== 'done' && progress.state === 'done' && ! hasAnnouncedReady) { hasAnnouncedReady = true; appendLine( __( 'remote: Initial import complete. The checkout is ready.', 'push-md' ), 'is-success' ); @@ -121,6 +124,51 @@ ); } + function renderCollisions(collisions) { + if ( ! collisionPanelEl || ! collisionListEl) { + return; + } + collisionListEl.textContent = ''; + if ( ! collisions.length) { + collisionPanelEl.hidden = true; + return; + } + collisionPanelEl.hidden = false; + collisions.forEach( + function (collision) { + var item = document.createElement( 'li' ); + var pathCode = document.createElement( 'code' ); + pathCode.textContent = collision.path; + item.appendChild( pathCode ); + + var postsList = document.createElement( 'ul' ); + (collision.posts || []).forEach( + function (post) { + var label = sprintf( + /* translators: 1: Post title, 2: Post ID, 3: Post status. */ + __( '%1$s (ID %2$d, %3$s)', 'push-md' ), + post.title || __( '(untitled)', 'push-md' ), + post.id, + post.status + ); + var postItem = document.createElement( 'li' ); + if (post.edit_url) { + var link = document.createElement( 'a' ); + link.href = post.edit_url; + link.textContent = label; + postItem.appendChild( link ); + } else { + postItem.textContent = label; + } + postsList.appendChild( postItem ); + } + ); + item.appendChild( postsList ); + collisionListEl.appendChild( item ); + } + ); + } + function appendLine(text, className) { var line = document.createElement( 'div' ); line.className = 'push-md-terminal-line'; diff --git a/plugins/push-md/class-push-md-admin.php b/plugins/push-md/class-push-md-admin.php index 9af1ecb6..f610c3f2 100644 --- a/plugins/push-md/class-push-md-admin.php +++ b/plugins/push-md/class-push-md-admin.php @@ -9,11 +9,12 @@ */ class Push_MD_Admin { - const PAGE_SLUG = 'push-md'; - const REST_NAMESPACE = 'push-md/v1'; - const STATUS_ROUTE = '/seed-status'; - const RETRY_ROUTE = '/seed-retry'; - const ASSET_VERSION = '0.6.7'; + const PAGE_SLUG = 'push-md'; + const REST_NAMESPACE = 'push-md/v1'; + const STATUS_ROUTE = '/seed-status'; + const RETRY_ROUTE = '/seed-retry'; + const ASSET_VERSION = '0.6.7'; + const COLLISIONS_TRANSIENT = 'push_md_collisions'; public static function bootstrap() { add_action( 'admin_menu', array( __CLASS__, 'register_menu' ) ); @@ -81,15 +82,66 @@ public static function admin_only() { public static function rest_status() { Push_MD_Seeder::drive( 1.5 ); - return rest_ensure_response( Push_MD_Seeder::get_progress() ); + $progress = Push_MD_Seeder::get_progress(); + $progress['collisions'] = self::collisions_for_display(); + + return rest_ensure_response( $progress ); } public static function rest_retry() { Push_MD_Seeder::reset(); Push_MD_Seeder::on_activation(); Push_MD_Seeder::tick(); + delete_transient( self::COLLISIONS_TRANSIENT ); + + $progress = Push_MD_Seeder::get_progress(); + $progress['collisions'] = self::collisions_for_display(); - return rest_ensure_response( Push_MD_Seeder::get_progress() ); + return rest_ensure_response( $progress ); + } + + /** + * Build the collision report consumed by the admin page and status route. + * + * Detection mirrors the export query and can scan every supported post, so + * the result is cached briefly because the status route is polled. + * + * @return array List of array( 'path' => string, 'posts' => array ) where + * each post carries id, title, status, and an edit URL. + */ + private static function collisions_for_display() { + $cached = get_transient( self::COLLISIONS_TRANSIENT ); + if ( is_array( $cached ) ) { + return $cached; + } + + $payload = array(); + foreach ( Push_MD_Plugin::detect_export_path_collisions() as $collision ) { + $posts = array(); + foreach ( $collision['post_ids'] as $post_id ) { + $post = get_post( $post_id ); + if ( ! $post ) { + continue; + } + + $edit_url = get_edit_post_link( $post_id, 'raw' ); + $posts[] = array( + 'id' => intval( $post_id ), + 'title' => $post->post_title, + 'status' => $post->post_status, + 'edit_url' => $edit_url ? $edit_url : '', + ); + } + + $payload[] = array( + 'path' => $collision['path'], + 'posts' => $posts, + ); + } + + set_transient( self::COLLISIONS_TRANSIENT, $payload, 30 ); + + return $payload; } private static function add_username_to_url( $url, $username ) { @@ -122,12 +174,14 @@ public static function render_page() { // Drive the seeder before painting so the first screen already // has real checkout state whenever the host can do quick work. Push_MD_Seeder::drive( 1.5 ); - $progress = Push_MD_Seeder::get_progress(); - $nonce = wp_create_nonce( 'wp_rest' ); - $status_url = esc_url_raw( rest_url( self::REST_NAMESPACE . self::STATUS_ROUTE ) ); - $retry_url = esc_url_raw( rest_url( self::REST_NAMESPACE . self::RETRY_ROUTE ) ); - $git_url = esc_url_raw( rest_url( Push_MD_Plugin::ROUTE_NAMESPACE . '/md.git' ) ); - $user = wp_get_current_user(); + $progress = Push_MD_Seeder::get_progress(); + $collisions = self::collisions_for_display(); + $progress['collisions'] = $collisions; + $nonce = wp_create_nonce( 'wp_rest' ); + $status_url = esc_url_raw( rest_url( self::REST_NAMESPACE . self::STATUS_ROUTE ) ); + $retry_url = esc_url_raw( rest_url( self::REST_NAMESPACE . self::RETRY_ROUTE ) ); + $git_url = esc_url_raw( rest_url( Push_MD_Plugin::ROUTE_NAMESPACE . '/md.git' ) ); + $user = wp_get_current_user(); if ( $user && $user->exists() ) { $git_url = self::add_username_to_url( $git_url, $user->user_login ); } @@ -192,6 +246,12 @@ public static function render_page() { +