post_type ) { return; } /* * Select2 is bundled in assets/vendor/select2 (no third-party CDN). If another plugin (e.g. WooCommerce's * selectWoo) has already registered a Select2 build, reuse it as the dependency instead of loading a second * copy that would clash on jQuery.fn.select2. */ $select2_handle = 'rfsd-select2-js'; if ( wp_script_is( 'select2', 'registered' ) ) { $select2_handle = 'select2'; } elseif ( wp_script_is( 'selectWoo', 'registered' ) ) { $select2_handle = 'selectWoo'; } // The unminified Select2 sources ship alongside the minified build; SCRIPT_DEBUG loads them. $suffix = ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) ? '' : '.min'; if ( 'rfsd-select2-js' === $select2_handle ) { wp_enqueue_style( 'rfsd-select2-css', RFSD_PLUGIN_URL . 'assets/vendor/select2/select2' . $suffix . '.css', array(), '4.1.0' ); wp_enqueue_script( 'rfsd-select2-js', RFSD_PLUGIN_URL . 'assets/vendor/select2/select2' . $suffix . '.js', array( 'jquery' ), '4.1.0', true ); } else { wp_enqueue_script( $select2_handle ); // The style handle can differ from the script's (WooCommerce registers selectWoo JS with select2 CSS). $style_handle = ''; foreach ( array( $select2_handle, 'select2', 'selectWoo' ) as $candidate ) { if ( wp_style_is( $candidate, 'registered' ) ) { $style_handle = $candidate; break; } } if ( $style_handle ) { wp_enqueue_style( $style_handle ); } else { wp_enqueue_style( 'rfsd-select2-css', RFSD_PLUGIN_URL . 'assets/vendor/select2/select2' . $suffix . '.css', array(), '4.1.0' ); // The reused script may be Select2 4.0 (or selectWoo), which marks results with aria-selected instead // of the 4.1 state classes the bundled stylesheet targets. wp_add_inline_style( 'rfsd-select2-css', '.select2-results__option[aria-selected]{cursor:pointer}' . '.select2-container--default .select2-results__option[aria-selected=true]{background-color:#ddd}' . '.select2-container--default .select2-results__option--highlighted[aria-selected]{background-color:#5897fb;color:#fff}' ); } } wp_enqueue_script( 'rfsd-admin-js', RFSD_PLUGIN_URL . 'assets/js/rfsd-admin.js', array( 'jquery', $select2_handle ), RFSD_VERSION, true ); wp_localize_script( 'rfsd-admin-js', 'rfsdAdmin', array( 'ajax_url' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'rfsd_admin_nonce' ), 'post_id' => get_the_ID(), 'i18n' => array( 'urlsHelp' => __( 'Enter one URL per line (e.g. https://example.com/about/). The FAQ appears on whichever post has that permalink.', 'renderbit-faq-structured-data' ), 'postsHelp' => __( 'Search posts/pages and add multiple results.', 'renderbit-faq-structured-data' ), 'postsPlaceholder' => __( 'Search posts by title...', 'renderbit-faq-structured-data' ), 'postTypesHelp' => __( 'Select post type(s) to associate this FAQ with.', 'renderbit-faq-structured-data' ), 'termsHelp' => __( 'Search taxonomy terms and add multiple results.', 'renderbit-faq-structured-data' ), 'termsPlaceholder' => __( 'Search terms by name...', 'renderbit-faq-structured-data' ), 'globalHelp' => __( 'This FAQ will be included site-wide.', 'renderbit-faq-structured-data' ), ), ) ); } add_action( 'admin_enqueue_scripts', 'rfsd_admin_assets' ); /** * Add association meta box */ function rfsd_add_meta_boxes() { add_meta_box( 'rfsd_assoc_rules', __( 'Associations & Output', 'renderbit-faq-structured-data' ), 'rfsd_assoc_rules_meta_box_cb', RFSD_POST_TYPE, 'normal', 'default' ); } add_action( 'add_meta_boxes', 'rfsd_add_meta_boxes' ); /** * Render association meta box. * * @param WP_Post $post The post object. */ function rfsd_assoc_rules_meta_box_cb( $post ) { wp_nonce_field( 'rfsd_save_meta', 'rfsd_meta_nonce' ); // Load stored JSON payload for this FAQ. $data_json = get_post_meta( $post->ID, 'rfsd_assoc_data_json', true ); $data_json = $data_json ? $data_json : '{}'; $data = json_decode( $data_json, true ); if ( ! is_array( $data ) ) { $data = array(); } // Hydrate posts and terms with titles/names for Select2. // Every stored ID is kept (even drafts, private, trashed or deleted items) so a re-save never drops it. if ( isset( $data['posts'] ) && is_array( $data['posts'] ) && ! empty( $data['posts'] ) ) { $hydrated_posts = array(); foreach ( array_map( 'intval', $data['posts'] ) as $pid ) { $p = get_post( $pid ); if ( $p ) { $text = get_the_title( $p ) . ' – ' . get_permalink( $p ); if ( 'publish' !== $p->post_status ) { $text .= ' (' . $p->post_status . ')'; } } else { /* translators: %d: post ID. */ $text = sprintf( __( 'Post #%d (not found)', 'renderbit-faq-structured-data' ), $pid ); } $hydrated_posts[] = array( 'id' => $pid, 'text' => $text, ); } $data['posts'] = $hydrated_posts; } if ( isset( $data['terms'] ) && is_array( $data['terms'] ) && ! empty( $data['terms'] ) ) { $hydrated_terms = array(); foreach ( array_map( 'intval', $data['terms'] ) as $tid ) { $term = get_term( $tid ); if ( $term && ! is_wp_error( $term ) ) { $text = $term->name . ' (' . $term->taxonomy . ')'; } else { /* translators: %d: term ID. */ $text = sprintf( __( 'Term #%d (not found)', 'renderbit-faq-structured-data' ), $tid ); } $hydrated_terms[] = array( 'id' => $tid, 'text' => $text, ); } $data['terms'] = $hydrated_terms; } // Always hand the JS an object, even when the payload is empty. $data_json_hydrated = empty( $data ) ? '{}' : wp_json_encode( $data ); $assoc_type = get_post_meta( $post->ID, 'rfsd_assoc_type', true ); $assoc_type = $assoc_type ? $assoc_type : 'urls'; $assoc_types = array( 'urls' => __( 'By URLs (one per line)', 'renderbit-faq-structured-data' ), 'posts' => __( 'By Posts (search & multi-select)', 'renderbit-faq-structured-data' ), 'post_types' => __( 'By Post Types (apply to all posts of selected types)', 'renderbit-faq-structured-data' ), 'tax_terms' => __( 'By Taxonomy Terms (search & multi-select)', 'renderbit-faq-structured-data' ), 'global' => __( 'Global (site-wide)', 'renderbit-faq-structured-data' ), ); echo '
'; echo ''; echo ''; echo ''; echo '' . esc_html__( 'Enter association details. Use the "By Posts" option to search and multi-select posts. For large sites, prefer Post Types or Taxonomy Terms.', 'renderbit-faq-structured-data' ) . '
'; } /** * Save meta handler (similar to previous implementation but store payload JSON for indexing) * * @param int $post_id The post ID. * @param WP_Post $post The post object. */ function rfsd_save_meta_handler( $post_id, $post ) { if ( ! isset( $_POST['rfsd_meta_nonce'] ) ) { return; } if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['rfsd_meta_nonce'] ) ), 'rfsd_save_meta' ) ) { return; } if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { return; } if ( RFSD_POST_TYPE !== $post->post_type ) { return; } if ( ! current_user_can( 'edit_post', $post_id ) ) { return; } $assoc_type = isset( $_POST['rfsd_assoc_type'] ) ? sanitize_text_field( wp_unslash( $_POST['rfsd_assoc_type'] ) ) : 'urls'; if ( ! in_array( $assoc_type, array( 'urls', 'posts', 'post_types', 'tax_terms', 'global' ), true ) ) { // Unknown type: leave the stored meta and mappings untouched. return; } $payload = array(); // Drop any notices left over from an earlier save. delete_transient( 'rfsd_rejected_urls_' . get_current_user_id() . '_' . $post_id ); delete_transient( 'rfsd_unmatched_urls_' . get_current_user_id() . '_' . $post_id ); if ( 'urls' === $assoc_type ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $raw = isset( $_POST['rfsd_assoc_urls'] ) ? trim( wp_unslash( $_POST['rfsd_assoc_urls'] ) ) : ''; $lines = preg_split( "/\r\n|\n|\r/", $raw ); $urls = array(); $bad = array(); foreach ( $lines as $l ) { $l = trim( $l ); if ( empty( $l ) ) { continue; } $clean = esc_url_raw( $l ); if ( '' === $clean ) { // Rejected (e.g. disallowed protocol); do not store an empty mapping. $bad[] = $l; continue; } $urls[] = $clean; } $payload['urls'] = array_values( array_unique( $urls ) ); if ( $bad ) { set_transient( 'rfsd_rejected_urls_' . get_current_user_id() . '_' . $post_id, $bad, MINUTE_IN_SECONDS ); } // Warn, never reject: a URL that is no post's permalink covers nothing until a post takes it. $unmatched = array(); foreach ( $payload['urls'] as $url ) { if ( rfsd_post_for_url( $url ) ) { continue; } $routed = rfsd_post_routed_from_url( $url ); $unmatched[] = array( 'url' => $url, 'suggestion' => $routed ? (string) get_permalink( $routed ) : '', ); } if ( $unmatched ) { set_transient( 'rfsd_unmatched_urls_' . get_current_user_id() . '_' . $post_id, $unmatched, MINUTE_IN_SECONDS ); } } elseif ( 'posts' === $assoc_type ) { $post_ids = array(); if ( isset( $_POST['rfsd_assoc_posts_select'] ) && is_array( $_POST['rfsd_assoc_posts_select'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized foreach ( wp_unslash( $_POST['rfsd_assoc_posts_select'] ) as $pid ) { $post_ids[] = intval( $pid ); } } $payload['posts'] = array_values( array_unique( $post_ids ) ); } elseif ( 'post_types' === $assoc_type ) { $ptypes = array(); if ( isset( $_POST['rfsd_assoc_post_types'] ) && is_array( $_POST['rfsd_assoc_post_types'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized foreach ( wp_unslash( $_POST['rfsd_assoc_post_types'] ) as $pt ) { $ptypes[] = sanitize_text_field( $pt ); } } $payload['post_types'] = array_values( array_unique( $ptypes ) ); } elseif ( 'tax_terms' === $assoc_type ) { $terms = array(); if ( isset( $_POST['rfsd_assoc_terms_select'] ) && is_array( $_POST['rfsd_assoc_terms_select'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized foreach ( wp_unslash( $_POST['rfsd_assoc_terms_select'] ) as $t ) { $terms[] = intval( $t ); } } $payload['terms'] = array_values( array_unique( $terms ) ); } elseif ( 'global' === $assoc_type ) { $payload['global'] = true; } update_post_meta( $post_id, 'rfsd_assoc_type', $assoc_type ); update_post_meta( $post_id, 'rfsd_assoc_data_json', wp_json_encode( $payload ) ); // Rebuild index (indexer will delete and reinsert mappings, then invalidate transients). rfsd_rebuild_index_for_faq( $post_id ); } add_action( 'save_post', 'rfsd_save_meta_handler', 10, 2 ); /** * Admin notice listing URLs rejected on the last save of this FAQ. */ function rfsd_rejected_urls_notice() { $post_id = isset( $_GET['post'] ) ? absint( $_GET['post'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( ! $post_id ) { return; } $key = 'rfsd_rejected_urls_' . get_current_user_id() . '_' . $post_id; $bad = get_transient( $key ); if ( empty( $bad ) || ! is_array( $bad ) ) { return; } delete_transient( $key ); printf( '%s
' . implode( '', array_map( 'esc_html', $bad ) ) . '' . esc_html( $entry['url'] ) . '';
if ( '' !== $entry['suggestion'] ) {
/* translators: %s: permalink of the post the URL leads to. */
$items .= ' ' . sprintf( esc_html__( '(did you mean %s?)', 'renderbit-faq-structured-data' ), '' . esc_html( $entry['suggestion'] ) . '' );
}
$items .= '%s
' . esc_html( $entry['new'] ) . '';
$line = '' === $entry['old']
/* translators: 1: FAQ title, 2: added URL. */
? sprintf( esc_html__( '%1$s: added %2$s', 'renderbit-faq-structured-data' ), $faq, $new )
/* translators: 1: FAQ title, 2: old URL, 3: new URL. */
: sprintf( esc_html__( '%1$s: %2$s became %3$s', 'renderbit-faq-structured-data' ), $faq, '' . esc_html( $entry['old'] ) . '', $new );
$items .= '