(string) get_option( 'permalink_structure' ), 'category_base' => (string) get_option( 'category_base' ), 'tag_base' => (string) get_option( 'tag_base' ), 'captured_at' => current_time( 'mysql' ), ); if ( null === get_option( self::OPTION_KEY, null ) ) { return add_option( self::OPTION_KEY, $snapshot, '', false ); } return update_option( self::OPTION_KEY, $snapshot ); } /** * Detect whether the live permalink structure differs from the snapshot. * * @return bool True if the structure has changed (migration needed). */ public static function detect_change() { $snapshot = self::get(); if ( ! $snapshot ) { // No snapshot yet — nothing to migrate from. Capture the current // structure as the baseline so future changes are detectable. self::capture_current(); return false; } $live_structure = (string) get_option( 'permalink_structure' ); $live_category_base = (string) get_option( 'category_base' ); $live_tag_base = (string) get_option( 'tag_base' ); return ( $snapshot['structure'] !== $live_structure || $snapshot['category_base'] !== $live_category_base || $snapshot['tag_base'] !== $live_tag_base ); } /** * Compute the OLD URLs (under the snapshot structure) for a batch of posts. * * Temporarily swaps the permalink structure to the snapshot's value, calls * get_permalink() for each post, then restores the live structure. This * reuses WP's own permalink-building logic instead of reimplementing it. * * @param array $post_ids Array of post IDs. * @return array Associative array: [ post_id => old_url ]. */ public static function compute_old_post_urls( $post_ids ) { $snapshot = self::get(); if ( ! $snapshot || empty( $post_ids ) ) { return array(); } return self::with_swapped_structure( $snapshot, function () use ( $post_ids ) { $result = array(); foreach ( $post_ids as $pid ) { $pid = (int) $pid; $url = get_permalink( $pid ); if ( $url && ! is_wp_error( $url ) ) { $result[ $pid ] = $url; } } return $result; } ); } /** * Compute the OLD URLs for a batch of terms (under the snapshot structure). * * @param array $term_ids Array of term IDs. * @return array Associative array: [ term_id => old_url ]. */ public static function compute_old_term_urls( $term_ids ) { $snapshot = self::get(); if ( ! $snapshot || empty( $term_ids ) ) { return array(); } return self::with_swapped_structure( $snapshot, function () use ( $term_ids ) { $result = array(); foreach ( $term_ids as $tid ) { $tid = (int) $tid; $url = get_term_link( $tid ); if ( $url && ! is_wp_error( $url ) ) { $result[ $tid ] = $url; } } return $result; } ); } /** * Run a callback with the permalink structure temporarily swapped to the * snapshot's values, then restore the live values. * * This is the core trick that lets us reuse WP's get_permalink() to * compute "what the URL used to be" without reimplementing the rewrite * rules. The swap is process-local and restored in a finally block. * * @param array $snapshot Snapshot array. * @param callable $callback Callback returning the computed result. * @return mixed Callback return value. */ private static function with_swapped_structure( $snapshot, $callback ) { // Override the permalink-related options in memory using filters, then // re-initialize $wp_rewrite so permastructs are rebuilt from the // snapshot structure. This is necessary because get_permalink() uses // the permastructs (built during init), not just the permalink_structure // property — a simple property swap does NOT affect URLs that contain // %category%, %post_id%, %author%, etc. (Bug #2). // // The filters are in-memory only (no DB writes), so concurrent requests // and the DB are unaffected. Everything is restored in a finally block. $rewrite = $GLOBALS['wp_rewrite']; // Save live values for restoration. $live_structure = (string) get_option( 'permalink_structure' ); $live_category_base = (string) get_option( 'category_base' ); $live_tag_base = (string) get_option( 'tag_base' ); // Register filters that return the snapshot values when WP reads the // options during $wp_rewrite->init(). $structure_filter = function () use ( $snapshot ) { return $snapshot['structure']; }; $category_filter = function () use ( $snapshot ) { return $snapshot['category_base']; }; $tag_filter = function () use ( $snapshot ) { return $snapshot['tag_base']; }; add_filter( 'pre_option_permalink_structure', $structure_filter ); add_filter( 'pre_option_category_base', $category_filter ); add_filter( 'pre_option_tag_base', $tag_filter ); // Re-initialize $wp_rewrite so it rebuilds permastructs from the // (now filtered) snapshot structure. This is the critical step that // makes get_permalink() produce URLs under the old structure. $rewrite->init(); try { return call_user_func( $callback ); } finally { // Remove the filters. remove_filter( 'pre_option_permalink_structure', $structure_filter ); remove_filter( 'pre_option_category_base', $category_filter ); remove_filter( 'pre_option_tag_base', $tag_filter ); // Re-initialize $wp_rewrite with the live values so the rest of // the request sees the correct structure. $rewrite->init(); } } /** * Get a human-readable description of the snapshot for the UI. * * @return string e.g. "/%category%/%postname%/ (captured 2026-08-04 12:00)". */ public static function describe() { $snapshot = self::get(); if ( ! $snapshot ) { return __( 'No snapshot yet.', 'ailinkmaster-internal-auto-manager' ); } $structure = $snapshot['structure'] ? $snapshot['structure'] : __( 'Default (plain)', 'ailinkmaster-internal-auto-manager' ); return sprintf( /* translators: 1: permalink structure, 2: capture date */ __( '%1$s (captured %2$s)', 'ailinkmaster-internal-auto-manager' ), $structure, $snapshot['captured_at'] ); } /** * Delete the snapshot (used on uninstall). * * @return bool True on success. */ public static function delete() { return delete_option( self::OPTION_KEY ); } }