From 82d1298c59bc42106578b0c7eae8f45380a864d3 Mon Sep 17 00:00:00 2001 From: selul Date: Thu, 23 Apr 2026 13:20:37 +0300 Subject: [PATCH] fix(importer): resolve nav_menu widget term_id from slug hint Widget instances exported from another site reference menus by numeric term_id (e.g. `nav_menu` => 3), which never match on the target after a fresh import. `Slug_Mapping::rewrite_value()` only rewrites URL strings, so the integer passes through and `wp_nav_menu(['menu' => 3])` silently renders nothing. Add a pre-import resolver that consumes a `_ti_nav_menu_slug` hint (carried by companion exporters), looks the slug up on the target site via `wp_get_nav_menu_object()`, rewrites `nav_menu` to the fresh term_id, and strips the hint so it never persists into `widget_nav_menu`. Unresolved slugs are non-fatal. Expose the resolution step as a `ti_tpc_widget_pre_import` filter so future ID-by-reference widgets (taxonomies, products) can plug in without further core changes. Covered by `tests/widgets-import-test.php` with three cases: successful resolution, unresolved-slug fallback, and filter-hook invocation. Made-with: Cursor --- includes/Importers/Widgets_Importer.php | 43 ++++++ tests/widgets-import-test.php | 179 ++++++++++++++++++++++++ 2 files changed, 222 insertions(+) create mode 100644 tests/widgets-import-test.php diff --git a/includes/Importers/Widgets_Importer.php b/includes/Importers/Widgets_Importer.php index 02132c86..5461d27e 100755 --- a/includes/Importers/Widgets_Importer.php +++ b/includes/Importers/Widgets_Importer.php @@ -136,6 +136,14 @@ public function actually_import( $data ) { $widget = json_decode( wp_json_encode( $widget ), true ); $widget = Slug_Mapping::rewrite_value( $widget ); + // [dde-patch v1] pre-import widget filter + nav_menu slug resolver. + // Resolve ID-by-reference fields (menus, etc.) using slug hints carried + // in the widget instance, then let third parties hook in before persist. + $widget = $this->resolve_known_references( $widget, $id_base ); + // Filters a widget instance right before it is persisted. Receives + // ($widget, $id_base, $widget_instance_id, $sidebar_id). + $widget = apply_filters( 'ti_tpc_widget_pre_import', $widget, $id_base, $widget_instance_id, $sidebar_id ); + // Does widget with identical settings already exist in same sidebar? if ( ! $fail && isset( $widget_instances[ $id_base ] ) ) { @@ -266,6 +274,41 @@ public function available_widgets() { } + /** + * Resolve known ID-by-reference fields on a widget instance using + * slug hints carried in the exported payload. + * + * Currently handles: + * - `nav_menu` widget: reads `_ti_nav_menu_slug`, looks up the menu on + * the target site, rewrites `nav_menu` to the fresh term_id, and + * strips the hint so it never persists into `widget_nav_menu`. + * + * Behaviour on failure is intentionally non-fatal - if the slug does + * not resolve (menu import failed) we leave the stale id in place so + * the frontend renders an empty menu widget just like before this + * patch, rather than throwing and aborting the whole import. + * + * @param array $widget Widget instance. + * @param string $id_base Widget id_base (e.g. `nav_menu`). + * + * @return array + */ + private function resolve_known_references( $widget, $id_base ) { + if ( ! is_array( $widget ) ) { + return $widget; + } + + if ( 'nav_menu' === $id_base && ! empty( $widget['_ti_nav_menu_slug'] ) ) { + $menu = wp_get_nav_menu_object( $widget['_ti_nav_menu_slug'] ); + if ( $menu && ! is_wp_error( $menu ) ) { + $widget['nav_menu'] = (int) $menu->term_id; + } + unset( $widget['_ti_nav_menu_slug'] ); + } + + return $widget; + } + /** * Moves widgets to inactive widgets. */ diff --git a/tests/widgets-import-test.php b/tests/widgets-import-test.php new file mode 100644 index 00000000..4758510b --- /dev/null +++ b/tests/widgets-import-test.php @@ -0,0 +1,179 @@ + 'Footer Two', + 'id' => 'footer-two-widgets', + ) + ); + + // Make sure the classic nav_menu widget is registered so the + // importer's available_widgets() check passes. + if ( ! isset( $GLOBALS['wp_registered_widget_controls']['nav_menu-1'] ) ) { + wp_widgets_init(); + } + + // Start from a known-clean widget state. + update_option( 'widget_nav_menu', array( '_multiwidget' => 1 ) ); + update_option( + 'sidebars_widgets', + array( + 'wp_inactive_widgets' => array(), + 'footer-two-widgets' => array(), + ) + ); + } + + public function tearDown(): void { + delete_option( 'widget_nav_menu' ); + delete_option( 'sidebars_widgets' ); + parent::tearDown(); + } + + /** + * When a nav_menu widget carries `_ti_nav_menu_slug`, the importer + * must rewrite `nav_menu` to the term_id of the menu that matches + * that slug on the current site and strip the hint before persist. + */ + public function test_nav_menu_widget_slug_is_resolved_to_target_term_id() { + $menu_id = wp_create_nav_menu( 'Services' ); + $this->assertNotInstanceOf( WP_Error::class, $menu_id ); + + $menu = wp_get_nav_menu_object( $menu_id ); + $target = (int) $menu->term_id; + $this->assertNotSame( 999, $target, 'Freshly created menu should not collide with the stale stub id.' ); + + $payload = array( + 'footer-two-widgets' => array( + 'nav_menu-2' => array( + 'title' => 'Services', + 'nav_menu' => 999, + '_ti_nav_menu_slug' => $menu->slug, + ), + ), + ); + + $importer = new Widgets_Importer(); + $result = $importer->actually_import( $payload ); + + $this->assertNotInstanceOf( WP_Error::class, $result ); + + $stored = get_option( 'widget_nav_menu' ); + $this->assertIsArray( $stored ); + + $instance = null; + foreach ( $stored as $key => $value ) { + if ( '_multiwidget' === $key ) { + continue; + } + if ( isset( $value['title'] ) && 'Services' === $value['title'] ) { + $instance = $value; + break; + } + } + + $this->assertNotNull( $instance, 'Imported nav_menu widget instance should be present.' ); + $this->assertSame( $target, (int) $instance['nav_menu'], 'Resolver should rewrite nav_menu to the target term_id.' ); + $this->assertArrayNotHasKey( '_ti_nav_menu_slug', $instance, 'Slug hint should be stripped before persist.' ); + } + + /** + * When the hinted slug does not match any existing menu on the + * target site, the importer must leave the original `nav_menu` + * value intact (non-fatal fallback) and still strip the hint. + */ + public function test_nav_menu_widget_unresolvable_slug_keeps_stale_id() { + $payload = array( + 'footer-two-widgets' => array( + 'nav_menu-2' => array( + 'title' => 'Services', + 'nav_menu' => 999, + '_ti_nav_menu_slug' => 'does-not-exist-on-target', + ), + ), + ); + + $importer = new Widgets_Importer(); + $result = $importer->actually_import( $payload ); + + $this->assertNotInstanceOf( WP_Error::class, $result ); + + $stored = get_option( 'widget_nav_menu' ); + $instance = null; + foreach ( $stored as $key => $value ) { + if ( '_multiwidget' === $key ) { + continue; + } + if ( isset( $value['title'] ) && 'Services' === $value['title'] ) { + $instance = $value; + break; + } + } + + $this->assertNotNull( $instance ); + $this->assertSame( 999, (int) $instance['nav_menu'], 'Unresolved slug should leave the stale id alone.' ); + $this->assertArrayNotHasKey( '_ti_nav_menu_slug', $instance, 'Hint is always stripped, even on failure.' ); + } + + /** + * The `ti_tpc_widget_pre_import` filter must be invoked after the + * built-in resolver and be able to mutate the widget instance. + */ + public function test_ti_tpc_widget_pre_import_filter_runs() { + $captured = array(); + $filter = function ( $widget, $id_base, $instance_id, $sidebar_id ) use ( &$captured ) { + $captured[] = compact( 'widget', 'id_base', 'instance_id', 'sidebar_id' ); + $widget['title'] = 'Filtered'; + return $widget; + }; + add_filter( 'ti_tpc_widget_pre_import', $filter, 10, 4 ); + + $payload = array( + 'footer-two-widgets' => array( + 'nav_menu-2' => array( + 'title' => 'Services', + 'nav_menu' => 0, + ), + ), + ); + + $importer = new Widgets_Importer(); + $importer->actually_import( $payload ); + + remove_filter( 'ti_tpc_widget_pre_import', $filter, 10 ); + + $this->assertCount( 1, $captured ); + $this->assertSame( 'nav_menu', $captured[0]['id_base'] ); + $this->assertSame( 'nav_menu-2', $captured[0]['instance_id'] ); + $this->assertSame( 'footer-two-widgets', $captured[0]['sidebar_id'] ); + + $stored = get_option( 'widget_nav_menu' ); + $titles = array_column( array_filter( $stored, 'is_array' ), 'title' ); + $this->assertContains( 'Filtered', $titles, 'Filter mutation should be persisted.' ); + } +}