Making WordPress.org

Changeset 14541


Ignore:
Timestamp:
09/26/2025 06:51:30 PM (10 hours ago)
Author:
coffee2code
Message:

Photo Directory, Tags: Fix and improve tag merging.

  • Split merging and rename handling
  • Add helper function for explicit rename/merge
  • Add support for rename/merge via Quick Edit
  • Improve admin notice handling
  • Hide new tag form since tags shouldn't be directly created
  • Prevent direct new tag creation from tags listing
File:
1 edited

Legend:

Unmodified
Added
Removed
  • sites/trunk/wordpress.org/public_html/wp-content/plugins/photo-directory/inc/tags.php

    r14540 r14541  
    1616    private static $processing_merge = false;
    1717
    18     /** @var array The old terms. */
    19     protected static $old_terms = [];
    20 
    2118    /** @var string[] Memoized array of taxonomies that have merge functionality enabled, as determined by `get_mergeable_taxonomies()`. */
    2219    protected static $mergeable_taxonomies = [];
     
    3835        add_filter( 'pre_insert_term', [ __CLASS__, 'prevent_remapped_term_creation' ], 10, 3 );
    3936
     37        // Handle quick edit merge.
     38        add_action( 'wp_ajax_inline-save-tax',      [ __CLASS__, 'maybe_handle_quick_edit_merge' ], 1 );
     39
    4040        if ( is_admin() ) {
    4141            self::init_admin();
     
    4949        foreach ( self::get_mergeable_taxonomies() as $taxonomy ) {
    5050            add_action( "{$taxonomy}_edit_form_fields", [ __CLASS__, 'inject_custom_slug_description' ], 20, 2 );
    51             add_action( "edit_{$taxonomy}",             [ __CLASS__, 'capture_old_slug' ], 1, 2 );
    52             add_action( "edited_{$taxonomy}",           [ __CLASS__, 'process_merge_rename' ], 10, 2 );
     51            add_action( "edited_{$taxonomy}",           [ __CLASS__, 'process_rename' ], 10, 2 );
    5352        }
    5453        add_action( 'admin_notices',              [ __CLASS__, 'admin_notices' ] );
     54        add_action( 'load-edit-tags.php',         [ __CLASS__, 'maybe_handle_term_merge_submit' ] );
    5555        add_filter( 'redirect_term_location',     [ __CLASS__, 'maybe_redirect_merged_term' ], 10, 2 );
     56
     57        // Hide the new tag form and prevent direct tag creation.
     58        add_action( "{$taxonomy}_add_form",       [ __CLASS__, 'hide_add_tag_form' ], 1 );
     59        add_filter( 'pre_insert_term',            [ __CLASS__, 'prevent_direct_tag_creation' ], 10, 3 );
    5660    }
    5761
     
    109113
    110114    /**
    111      * Stores the name and slug of term being edited before changes are saved.
     115     * Hides the new tag form for mergeable taxonomies.
     116     *
     117     * @param string $taxonomy The taxonomy slug.
     118     */
     119    public static function hide_add_tag_form( $taxonomy ) {
     120        if (
     121            self::is_mergeable_taxonomy( $taxonomy )
     122            && isset( $_GET['post_type'] )
     123            && Registrations::get_post_type() === $_GET['post_type']
     124        ) {
     125            echo '<style>#col-left { display: none !important; } #col-right { float: none !important; width: 100% !important; }</style>';
     126        }
     127    }
     128
     129    /**
     130     * Prevents direct tag creation for mergeable taxonomies.
     131     *
     132     * @param mixed  $term     The term data.
     133     * @param string $taxonomy The taxonomy.
     134     * @param array  $args     Additional arguments.
     135     * @return mixed|WP_Error The term data or WP_Error to prevent creation.
     136     */
     137    public static function prevent_direct_tag_creation( $term, $taxonomy, $args ) {
     138        if ( self::is_mergeable_taxonomy( $taxonomy ) ) {
     139            $post_type = $_POST['post_type'] ?? '';
     140            $photo_post_type = Registrations::get_post_type();
     141            // Check if this is coming from the add tag form (and not programmatic).
     142            if (
     143                wp_doing_ajax()
     144                && isset( $_POST['action'] )
     145                && 'add-tag' === $_POST['action']
     146                && $photo_post_type === $post_type
     147            ) {
     148                return new \WP_Error( 'tag_creation_disabled',
     149                    __( 'Direct tag creation is disabled. Please use the merge functionality instead.', 'wporg-photos' )
     150                );
     151            }
     152        }
     153
     154        return $term;
     155    }
     156
     157    /**
     158     * Handles tag rename.
    112159     *
    113160     * @param int $term_id The ID of the term just edited.
    114161     * @param int $tt_id   The term taxonomy ID.
    115162     */
    116     public static function capture_old_slug( $term_id, $tt_id ) {
     163    public static function process_rename( $term_id, $tt_id ) {
    117164        // Get taxonomy from the term.
    118165        $term = get_term( $term_id );
     
    122169        $taxonomy = $term->taxonomy;
    123170
    124         if ( ! self::is_mergeable_taxonomy( $taxonomy ) ) {
    125             return;
    126         }
    127 
    128         self::$old_terms[ $taxonomy ][ $term_id ] = [
    129             'name' => $term->name,
    130             'slug' => $term->slug,
    131         ];
    132     }
    133 
    134     /**
    135      * Handles tag merge/rename.
    136      *
    137      * @param int $term_id The ID of the term just edited.
    138      * @param int $tt_id   The term taxonomy ID.
    139      */
    140     public static function process_merge_rename( $term_id, $tt_id ) {
    141         // Bail if function has already been invoked (prevents recursion).
    142         if ( self::$processing_merge ) {
    143             return;
    144         }
    145 
    146         // Get taxonomy from the term.
    147         $term = get_term( $term_id );
    148         if ( ! $term instanceof \WP_Term ) {
    149             return;
    150         }
    151         $taxonomy = $term->taxonomy;
    152 
    153171        // Bail if not a mergeable taxonomy.
    154172        if ( ! self::is_mergeable_taxonomy( $taxonomy ) ) {
     
    156174        }
    157175
     176        // Get the old slug and count from the transient.
     177        $key  = self::pre_rename_key( $taxonomy, $term_id );
     178        $data = get_transient( $key );
     179
     180        // Bail if there was no pre-rename note.
     181        if ( false === $data || ! is_array( $data ) ) {
     182            return;
     183        }
     184
     185        // Remove the transient.
     186        delete_transient( $key );
     187
    158188        // Old term data was captured earlier.
    159         $old_data = self::$old_terms[ $taxonomy ][ $term_id ] ?? [ 'name' => '', 'slug' => '' ];
    160         $old_name = $old_data['name'];
    161         $old_slug = $old_data['slug'];
    162 
    163         // Get new term data.
    164         $new_slug = ( $term instanceof \WP_Term ) ? $term->slug : '';
    165         $new_name = ( $term instanceof \WP_Term ) ? $term->name : '';
    166 
    167         // Mimic core and auto-generate slug from name if no slug was provided.
     189        $old_slug  = isset( $data['old_slug'] ) ? sanitize_title( $data['old_slug'] ) : '';
     190        $old_count = isset( $data['count'] ) ? (int) $data['count'] : 0;
     191        $new_slug  = $term->slug;
     192
     193        // Only act when slug actually changed and the old term had posts.
     194        if ( $old_slug && $new_slug && $old_slug !== $new_slug && $old_count > 0 ) {
     195            self::add_slug_redirect( $old_slug, $new_slug, $taxonomy );
     196        }
     197    }
     198
     199    /**
     200     * Handles merging tags and setting a redirect when the user attempts to rename
     201     * a term to a slug that already exists.
     202     */
     203    public static function maybe_handle_term_merge_submit() {
     204        // Bail if merge is already in progress.
     205        if ( self::$processing_merge ) {
     206            return;
     207        }
     208
     209        // Bail if not a POST request.
     210        if ( 'POST' !== $_SERVER['REQUEST_METHOD'] ) {
     211            return;
     212        }
     213
     214        // Bail if not an edited tag request.
     215        if ( empty( $_POST['action'] ) || 'editedtag' !== $_POST['action'] ) {
     216            return;
     217        }
     218
     219        // Bail if not a mergeable taxonomy.
     220        $taxonomy = isset( $_POST['taxonomy'] ) ? sanitize_key( $_POST['taxonomy'] ) : '';
     221        if ( ! self::is_mergeable_taxonomy( $taxonomy ) ) {
     222            return;
     223        }
     224
     225        // Bail if no from term ID.
     226        $from_term_id = isset( $_POST['tag_ID'] ) ? (int) $_POST['tag_ID'] : 0;
     227        if ( $from_term_id <= 0 ) {
     228            return;
     229        }
     230
     231        // Bail if user doesn't have the capability.
     232        if ( ! current_user_can( 'edit_term', $from_term_id ) ) {
     233            return;
     234        }
     235        check_admin_referer( 'update-tag_' . $from_term_id );
     236
     237        $new_slug_raw = isset( $_POST['slug'] ) ? wp_unslash( $_POST['slug'] ) : '';
     238        $new_slug     = sanitize_title( $new_slug_raw );
     239
     240        // Bail if the slug field was not changed or is empty.
    168241        if ( '' === $new_slug ) {
    169             $new_slug = sanitize_title( $new_name );
    170         }
    171 
    172         // Bail if no change in slug.
    173         if ( ! $new_slug || $old_slug === $new_slug ) {
    174             return;
    175         }
    176 
    177         // Check if target tag exists.
     242            return;
     243        }
     244
     245        // Bail if from term doesn't exist.
     246        $from = get_term( $from_term_id, $taxonomy );
     247        if ( ! $from || is_wp_error( $from ) ) {
     248            return;
     249        }
     250
     251        // Bail if user didn't actually change the slug.
     252        if ( $new_slug === $from->slug ) {
     253            return;
     254        }
     255
     256        // Bail if the term doesn't exist.
    178257        $exists = term_exists( $new_slug, $taxonomy );
     258        if ( ! $exists || is_wp_error( $exists ) ) {
     259            // Queue up a transient to store the old slug and count for later use by `process_rename()`.
     260            set_transient(
     261                self::pre_rename_key( $taxonomy, $from_term_id ),
     262                [
     263                    'old_slug' => $from->slug,
     264                    'count'    => (int) $from->count,
     265                ],
     266                5 * MINUTE_IN_SECONDS
     267            );
     268
     269            // Let core handle the new, unique slug.
     270            return;
     271        }
     272
     273        // Bail if the term ID is invalid.
    179274        $to_term_id = is_array( $exists ) ? (int) $exists['term_id'] : (int) $exists;
    180 
     275        if ( $to_term_id <= 0 || $to_term_id === $from_term_id ) {
     276            return;
     277        }
     278
     279        // Keep the old before we mutate/delete it.
     280        $old_slug   = $from->slug;
     281        $old_count  = (int) $from->count;
     282        $to         = get_term( $to_term_id, $taxonomy );
     283        $to_name    = $to && ! is_wp_error( $to ) ? $to->name : '';
     284
     285        // Merge terms.
    181286        self::$processing_merge = true;
    182 
    183         // Merge terms if target term exists.
    184         if ( $to_term_id && $to_term_id !== $term_id ) {
    185             self::merge_terms( $term_id, $to_term_id, $taxonomy );
    186             $new_term = get_term( $to_term_id, $taxonomy );
    187 
    188             self::add_slug_redirect( $old_slug, $new_term->slug, $taxonomy );
    189 
    190             set_transient( "photo_directory_tag_merge_redirect_{$term_id}", $to_term_id, 60 );
    191 
    192             $message = sprintf(
    193                 /* translators: 1: Old term, 2: Existing term */
    194                 __( 'Merged term <strong>%1$s</strong> into <strong>%2$s</strong>.', 'wporg-photos' ),
    195                 esc_html( $old_name ),
    196                 esc_html( $new_term->name )
     287        self::merge_terms( $from_term_id, $to_term_id, $taxonomy );
     288
     289        // Add redirect only if the old term actually had posts assigned.
     290        if ( $old_count > 0 ) {
     291            self::add_slug_redirect( $old_slug, $to->slug, $taxonomy );
     292        }
     293
     294        self::queue_admin_notice( [
     295            'type' => 'success',
     296            'message' => sprintf(
     297                /* translators: 1: Old term name, 2: New/existing term name */
     298                __( 'Merged term "%1$s" into "%2$s".', 'wporg-photos' ),
     299                $from->name,
     300                $to_name
     301            )
     302        ] );
     303
     304        // Redirect to surviving term’s edit screen, skipping core's updater.
     305        $location = add_query_arg(
     306            [
     307                'action'   => 'edit',
     308                'post_type' => Registrations::get_post_type(),
     309                'taxonomy' => $taxonomy,
     310                'tag_ID'   => $to_term_id,
     311            ],
     312            admin_url( 'term.php' )
     313        );
     314        wp_safe_redirect( $location );
     315        exit;
     316    }
     317
     318    /**
     319     * Handles quick edit merge.
     320     */
     321    public static function maybe_handle_quick_edit_merge() {
     322        // Security and context checks mirror core.
     323        check_ajax_referer( 'taxinlineeditnonce', '_inline_edit' );
     324
     325        // Bail if merge is already in progress.
     326        if ( self::$processing_merge ) {
     327            return;
     328        }
     329
     330        // Bail if not a mergeable taxonomy.
     331        $taxonomy = isset( $_POST['taxonomy'] ) ? sanitize_key( $_POST['taxonomy'] ) : '';
     332        if ( ! self::is_mergeable_taxonomy( $taxonomy ) ) {
     333            return; // Let core proceed.
     334        }
     335
     336        // Bail if no from term ID.
     337        $from_id = isset( $_POST['tax_ID'] ) ? (int) $_POST['tax_ID'] : 0;
     338        if ( $from_id <= 0 || ! current_user_can( 'edit_term', $from_id ) ) {
     339            return;
     340        }
     341
     342        // Bail if from term doesn't exist.
     343        $from = get_term( $from_id, $taxonomy );
     344        if ( ! $from || is_wp_error( $from ) ) {
     345            return;
     346        }
     347
     348        $new_slug_raw = isset( $_POST['slug'] ) ? wp_unslash( $_POST['slug'] ) : '';
     349        $new_slug     = sanitize_title( $new_slug_raw );
     350
     351        // Bail if the slug field was not changed or is empty.
     352        if ( '' === $new_slug || $new_slug === $from->slug ) {
     353            return;
     354        }
     355
     356        $exists = term_exists( $new_slug, $taxonomy );
     357
     358        // Bail if no existing term, but stash old slug/count so process_rename() can add a redirect.
     359        if ( ! $exists || is_wp_error( $exists ) ) {
     360            set_transient(
     361                self::pre_rename_key( $taxonomy, $from_id ),
     362                [
     363                    'old_slug' => $from->slug,
     364                    'count'    => (int) $from->count,
     365                ],
     366                5 * MINUTE_IN_SECONDS
    197367            );
    198 
    199         // Else just rename existing term.
    200         } else {
    201             $updated = wp_update_term( $term_id, $taxonomy, [
    202                 'name' => $new_name,
    203                 'slug' => $new_slug,
    204             ] );
    205 
    206             if ( is_wp_error( $updated ) ) {
    207                 $message = $updated->get_error_message();
    208             } else {
    209                 // Only create redirect if the tag has posts.
    210                 if ( $term->count > 0 ) {
    211                     self::add_slug_redirect( $old_slug, $new_slug, $taxonomy );
    212                 }
    213 
    214                 $message = sprintf(
    215                     /* translators: %s: New term name */
    216                     __( 'Changed term slug to <strong>%s</strong>.', 'wporg-photos' ),
    217                     esc_html( $new_name )
    218                 );
     368            // Core's wp_ajax_inline_save_tax() will perform wp_update_term().
     369            return;
     370        }
     371
     372        // Bail if the to term ID is invalid or the same as the from term ID.
     373        $to_id = is_array( $exists ) ? (int) $exists['term_id'] : (int) $exists;
     374        if ( $to_id <= 0 || $to_id === $from_id ) {
     375            return;
     376        }
     377
     378        // Perform merge and produce the same kind of response core would.
     379        $old_count = (int) $from->count;
     380        self::$processing_merge = true;
     381        self::merge_terms( $from_id, $to_id, $taxonomy );
     382
     383        if ( $old_count > 0 ) {
     384            $to_slug = get_term_field( 'slug', $to_id, $taxonomy );
     385            if ( ! is_wp_error( $to_slug ) && $to_slug ) {
     386                self::add_slug_redirect( $from->slug, $to_slug, $taxonomy );
    219387            }
    220388        }
    221389
    222         // Queue up an admin notice.
    223         set_transient( 'photo_directory_tag_merge_admin_notice', $message, 30 );
    224 
    225         self::$processing_merge = false;
    226 
    227         return;
     390        // Render the updated row HTML for the surviving term, just like core does.
     391        $tag           = get_term( $to_id, $taxonomy );
     392        $wp_list_table = _get_list_table( 'WP_Terms_List_Table', [ 'screen' => 'edit-' . $taxonomy ] );
     393
     394        // Compute level for hierarchical taxonomies (tags will just be level 0).
     395        $level  = 0;
     396        $parent = $tag && ! is_wp_error( $tag ) ? (int) $tag->parent : 0;
     397        while ( $parent > 0 ) {
     398            $parent_tag = get_term( $parent, $taxonomy );
     399            $parent     = $parent_tag ? (int) $parent_tag->parent : 0;
     400            ++$level;
     401        }
     402
     403        $wp_list_table->single_row( $tag, $level );
     404        // Stop core's default AJAX handler from running.
     405        wp_die();
     406    }
     407
     408    /**
     409     * Generates a transient key for pre-rename data.
     410     *
     411     * @param string $taxonomy The taxonomy slug.
     412     * @param int    $term_id  The term ID.
     413     * @return string The transient key.
     414     */
     415    protected static function pre_rename_key( $taxonomy, $term_id ) {
     416        return "photos_tags_pre_rename_{$taxonomy}_{$term_id}";
    228417    }
    229418
     
    248437
    249438        foreach ( $object_ids as $object_id ) {
    250             wp_remove_object_terms( $object_id, $from_term_id, $taxonomy );
    251             wp_add_object_terms( $object_id, $to_term_id, $taxonomy );
     439            wp_add_object_terms( $object_id, [ $to_term_id ], $taxonomy );
     440            wp_remove_object_terms( $object_id, [ $from_term_id ], $taxonomy );
    252441        }
    253442
     
    257446
    258447    /**
     448     * Queues an admin notice.
     449     *
     450     * @param string $message The message to queue.
     451     */
     452    public static function queue_admin_notice( $message ) {
     453        set_transient( 'photo_directory_tag_merge_admin_notice', $message, 30 );
     454    }
     455
     456    /**
    259457     * Outputs admin notice if one is queued.
    260458     */
    261459    public static function admin_notices() {
    262         if ( $msg = get_transient( 'photo_directory_tag_merge_admin_notice' ) ) {
    263             printf(
    264                 '<div class="notice notice-success is-dismissible"><p>%s</p></div>',
    265                 wp_kses_post( $msg )
    266             );
    267             delete_transient( 'photo_directory_tag_merge_admin_notice' );
    268         }
     460        $data = get_transient( 'photo_directory_tag_merge_admin_notice' );
     461        if ( ! $data ) {
     462            return;
     463        }
     464        delete_transient( 'photo_directory_tag_merge_admin_notice' );
     465
     466        $type    = in_array( $data['type'], [ 'success', 'warning', 'error', 'info' ], true ) ? $data['type'] : 'success';
     467        $message = $data['message'];
     468
     469        printf(
     470            '<div class="notice notice-%1$s is-dismissible"><p>%2$s</p></div>',
     471            esc_attr( $type ),
     472            wp_kses_post( $message )
     473        );
    269474    }
    270475
     
    582787    }
    583788
     789    /**
     790     * API function to rename or merge tags.
     791     *
     792     * Use this instead of `wp_update_term()` to "rename to existing slug = merge".
     793     *
     794     * @return array|WP_Error Result of `wp_update_term()` on simple rename, or ['merged' => true, 'to_term_id' => X] on merge.
     795     */
     796    public static function rename_or_merge( int $term_id, string $taxonomy, string $new_slug ) {
     797        // Bail if not a mergeable taxonomy.
     798        if ( ! self::is_mergeable_taxonomy( $taxonomy ) ) {
     799            return new WP_Error( 'invalid_taxonomy', __( 'Taxonomy is not merge-enabled.', 'your-textdomain' ) );
     800        }
     801
     802        $new_slug = sanitize_title( $new_slug );
     803        $from     = get_term( $term_id, $taxonomy );
     804
     805        // Bail if the term doesn't exist.
     806        if ( ! $from || is_wp_error( $from ) ) {
     807            return new WP_Error( 'invalid_term', __( 'Term not found.', 'wporg-photos' ) );
     808        }
     809
     810        // Bail if the new slug is the same as the old slug.
     811        if ( '' === $new_slug || $new_slug === $from->slug ) {
     812            // Nothing to do.
     813            return [ 'term_id' => $term_id ];
     814        }
     815
     816        // Handle a merge.
     817        $exists = term_exists( $new_slug, $taxonomy );
     818        if ( $exists && ! is_wp_error( $exists ) ) {
     819            $to_term_id = is_array( $exists ) ? (int) $exists['term_id'] : (int) $exists;
     820            if ( $to_term_id && $to_term_id !== $term_id ) {
     821                if ( self::$processing_merge ) {
     822                    return new WP_Error( 'reentrancy', __( 'Merge already in progress.', 'wporg-photos' ) );
     823                }
     824                self::$processing_merge = true;
     825
     826                $old_count = (int) $from->count;
     827                $old_slug  = $from->slug;
     828                self::merge_terms( $term_id, $to_term_id, $taxonomy );
     829
     830                if ( $old_count > 0 ) {
     831                    $to = get_term( $to_term_id, $taxonomy );
     832                    if ( $to && ! is_wp_error( $to ) ) {
     833                        self::add_slug_redirect( $old_slug, $to->slug, $taxonomy );
     834                    }
     835                }
     836
     837                self::$processing_merge = false;
     838                return [ 'merged' => true, 'to_term_id' => $to_term_id ];
     839            }
     840        }
     841
     842        // Handle a simple rename.
     843        $result = wp_update_term( $term_id, $taxonomy, [ 'slug' => $new_slug ] );
     844        if ( is_wp_error( $result ) ) {
     845            return $result;
     846        }
     847
     848        // Optional: add redirect if old term had posts.
     849        if ( (int) $from->count > 0 ) {
     850            self::add_slug_redirect( $from->slug, $new_slug, $taxonomy );
     851        }
     852
     853        return $result;
     854    }
     855
    584856}
    585857
Note: See TracChangeset for help on using the changeset viewer.