true. * * @var array */ private static array $touched = array(); /** * Menu names before a rename this request: term id => name. * * @var array */ private static array $names_before = array(); /** * Menus created this request. * * @var array */ private static array $created = array(); /** * Menus deleted this request, with the name they had. * * @var array */ private static array $deleting = array(); /** * Widget changes this request. * * @var array{added: string[], removed: string[], moved: string[], edited: string[]} */ private static array $widgets = array( 'added' => array(), 'removed' => array(), 'moved' => array(), 'edited' => array(), ); /** * @return array Event type => label for the log's filter. */ public static function event_types(): array { return array( self::MENU_CREATED => 'Menu Created', self::MENU_UPDATED => 'Menu Updated', self::MENU_DELETED => 'Menu Deleted', self::WIDGETS_UPDATED => 'Widgets Changed', ); } public static function register(): void { add_action( 'wp_create_nav_menu', array( __CLASS__, 'log_menu_created' ) ); add_action( 'edit_terms', array( __CLASS__, 'remember_menu_name' ), 10, 2 ); add_action( 'wp_update_nav_menu', array( __CLASS__, 'touch_menu' ) ); add_action( 'wp_add_nav_menu_item', array( __CLASS__, 'touch_menu' ) ); add_action( 'wp_update_nav_menu_item', array( __CLASS__, 'touch_menu' ) ); add_action( 'before_delete_post', array( __CLASS__, 'touch_menu_of_deleted_item' ), 10, 2 ); add_action( 'pre_delete_term', array( __CLASS__, 'remember_deleted_menu' ), 10, 2 ); add_action( 'wp_delete_nav_menu', array( __CLASS__, 'log_menu_deleted' ) ); add_action( 'update_option_sidebars_widgets', array( __CLASS__, 'record_sidebars_change' ), 10, 2 ); add_filter( 'widget_update_callback', array( __CLASS__, 'record_widget_edit' ), 10, 4 ); add_action( 'shutdown', array( __CLASS__, 'flush_pending' ) ); } /** * @param int $menu_id The new menu's term id. */ public static function log_menu_created( int $menu_id ): void { self::$created[ $menu_id ] = true; EventLogger::log( self::MENU_CREATED, sprintf( 'Menu "%s" created', self::menu_name( $menu_id ) ), 'nav_menu', $menu_id ); } /** * `edit_terms`: a menu's name before core renames it. Only the first one * this request is kept, so two renames read as one, first to last. * * @param int $term_id The term. * @param string $taxonomy Its taxonomy. */ public static function remember_menu_name( $term_id, $taxonomy ): void { if ( 'nav_menu' !== $taxonomy || isset( self::$names_before[ (int) $term_id ] ) ) { return; } self::$names_before[ (int) $term_id ] = self::menu_name( (int) $term_id ); } /** * Any menu write: mark the menu touched. Every hook here passes the menu * id first; core passes 0 for an item not (yet) in a menu. * * @param mixed $menu_id The menu's term id. */ public static function touch_menu( $menu_id ): void { $menu_id = (int) $menu_id; if ( $menu_id > 0 ) { self::$touched[ $menu_id ] = true; } } /** * `before_delete_post` for a menu item: its menu was changed. * * @param int $post_id The post being deleted. * @param mixed $post It. */ public static function touch_menu_of_deleted_item( int $post_id, $post ): void { if ( ! $post instanceof \WP_Post || 'nav_menu_item' !== $post->post_type ) { return; } $menus = wp_get_object_terms( $post_id, 'nav_menu', array( 'fields' => 'ids' ) ); foreach ( is_array( $menus ) ? $menus : array() as $menu_id ) { self::touch_menu( $menu_id ); } } /** * `pre_delete_term`: a menu's name, while it still has one. * * @param int $term_id The term. * @param string $taxonomy Its taxonomy. */ public static function remember_deleted_menu( $term_id, $taxonomy ): void { if ( 'nav_menu' === $taxonomy ) { self::$deleting[ (int) $term_id ] = self::menu_name( (int) $term_id ); } } /** * @param int $menu_id The deleted menu's term id. */ public static function log_menu_deleted( int $menu_id ): void { $name = self::$deleting[ $menu_id ] ?? (string) $menu_id; unset( self::$touched[ $menu_id ], self::$names_before[ $menu_id ] ); // Kept in $deleting so a later touch this request is not flushed. EventLogger::log( self::MENU_DELETED, sprintf( 'Menu "%s" deleted', $name ), 'nav_menu', $menu_id ); } /** * What moved between widget areas. `array_version` is core's own * bookkeeping, not an area. * * @param mixed $before `sidebars_widgets` before. * @param mixed $after `sidebars_widgets` after. * @return array{added: string[], removed: string[], moved: string[]} */ public static function sidebars_diff( $before, $after ): array { $where = static function ( $sidebars ): array { $map = array(); foreach ( is_array( $sidebars ) ? $sidebars : array() as $area => $widgets ) { if ( 'array_version' === $area || ! is_array( $widgets ) ) { continue; } foreach ( $widgets as $widget ) { if ( is_string( $widget ) ) { $map[ $widget ] = (string) $area; } } } return $map; }; $old = $where( $before ); $new = $where( $after ); $out = array( 'added' => array(), 'removed' => array(), 'moved' => array(), ); foreach ( $new as $widget => $area ) { if ( ! isset( $old[ $widget ] ) ) { $out['added'][] = $widget . ' to ' . $area; } elseif ( $old[ $widget ] !== $area ) { $out['moved'][] = $widget . ' from ' . $old[ $widget ] . ' to ' . $area; } } foreach ( $old as $widget => $area ) { if ( ! isset( $new[ $widget ] ) ) { $out['removed'][] = $widget . ' from ' . $area; } } return $out; } /** * `update_option_sidebars_widgets`. * * @param mixed $before The option before. * @param mixed $after The option after. */ public static function record_sidebars_change( $before, $after ): void { if ( did_action( 'after_switch_theme' ) ) { return; } foreach ( self::sidebars_diff( $before, $after ) as $kind => $changes ) { self::$widgets[ $kind ] = array_values( array_unique( array_merge( self::$widgets[ $kind ], $changes ) ) ); } } /** * `widget_update_callback`: a widget's settings were saved. A filter; the * instance passes through untouched and is never read. * * @param mixed $instance What will be stored. * @param mixed $new_instance What was submitted. * @param mixed $old_instance What was stored. * @param mixed $widget The WP_Widget. * @return mixed */ public static function record_widget_edit( $instance, $new_instance, $old_instance, $widget ) { if ( false !== $instance && is_object( $widget ) && isset( $widget->id ) && is_string( $widget->id ) && $instance !== $old_instance ) { self::$widgets['edited'][] = $widget->id; self::$widgets['edited'] = array_values( array_unique( self::$widgets['edited'] ) ); } return $instance; } /** * `shutdown`: one row per menu touched and not created or deleted this * request, and one row for all the widget changes. */ public static function flush_pending(): void { $touched = self::$touched + array_fill_keys( array_keys( self::$names_before ), true ); $names = self::$names_before; self::$touched = array(); self::$names_before = array(); foreach ( array_keys( $touched ) as $menu_id ) { // A menu deleted this request is ALSO caught by the object lookup // below (it no longer exists); mutation-checking showed that lookup, // not this $deleting test, is what holds today. Both stay: the // $deleting test does not depend on the cache having been cleared. if ( isset( self::$created[ $menu_id ] ) || isset( self::$deleting[ $menu_id ] ) ) { continue; } $menu = wp_get_nav_menu_object( $menu_id ); if ( ! $menu ) { continue; } $name = (string) $menu->name; $before = $names[ $menu_id ] ?? $name; $items = wp_get_nav_menu_items( $menu_id ); $details = array( 'items' => is_array( $items ) ? count( $items ) : 0 ); if ( $before !== $name ) { $details['renamed'] = array( 'before' => $before, 'after' => $name, ); } EventLogger::log( self::MENU_UPDATED, $before !== $name ? sprintf( 'Menu "%1$s" renamed to "%2$s" and saved', $before, $name ) : sprintf( 'Menu "%s" updated', $name ), 'nav_menu', $menu_id, $details ); } self::$created = array(); self::$deleting = array(); self::flush_widgets(); } private static function flush_widgets(): void { $changes = self::$widgets; self::$widgets = array( 'added' => array(), 'removed' => array(), 'moved' => array(), 'edited' => array(), ); $counts = array_map( 'count', $changes ); if ( 0 === array_sum( $counts ) ) { return; } $parts = array(); $details = array(); foreach ( $changes as $kind => $list ) { if ( array() === $list ) { continue; } $parts[] = sprintf( '%d %s', count( $list ), $kind ); $details[ $kind ] = array_slice( $list, 0, self::WIDGET_CHANGES_KEPT ); if ( count( $list ) > self::WIDGET_CHANGES_KEPT ) { $details[ $kind . '_more' ] = count( $list ) - self::WIDGET_CHANGES_KEPT; } } EventLogger::log( self::WIDGETS_UPDATED, 'Widgets changed: ' . implode( ', ', $parts ), 'widget', null, $details ); } /** * @param int $menu_id A menu's term id. */ private static function menu_name( int $menu_id ): string { $menu = wp_get_nav_menu_object( $menu_id ); return $menu ? (string) $menu->name : (string) $menu_id; } /** * Forget everything held for this request. Tests only. */ public static function reset(): void { self::$touched = array(); self::$names_before = array(); self::$created = array(); self::$deleting = array(); self::$widgets = array( 'added' => array(), 'removed' => array(), 'moved' => array(), 'edited' => array(), ); } }