Plugin Directory

Changeset 3384189


Ignore:
Timestamp:
10/24/2025 06:32:18 PM (7 weeks ago)
Author:
carlbensy16
Message:

1.0.6

Location:
just-duplicate/trunk
Files:
2 added
3 deleted
7 edited

Legend:

Unmodified
Added
Removed
  • just-duplicate/trunk/README.txt

    r3297660 r3384189  
    1010Requires at least: 6.0
    1111Tested up to: 6.8
    12 Stable tag: 1.0.4
    13 Version: 1.0.4
     12Stable tag: 1.0.6
     13Version: 1.0.6
    1414Requires PHP: 7.4
    1515License: GNU General Public License v3.0 or later
    1616License URI: http://www.gnu.org/licenses/gpl.html
    17 Tags: duplicate posts, duplicate pages, duplicate WooCommerce products, WordPress duplicator, content duplicator, post duplicator, page duplicator, WooCommerce duplicator, batch duplication, role-based access control
     17Tags: Duplicate Page, Duplicate Post, Page Duplicator, Post Duplicator, Duplicate Custom Posts
    1818
    1919Easily duplicate WordPress pages, posts, custom post types, and WooCommerce products with one click.
     
    54543. Go to **Plugins > Installed Plugins**, find "Just Duplicate", and click **Activate**.
    5555
     56== How to Use ==
     57
     58- In the Posts/Pages list: hover a row to see two links — "Clone" (duplicates and returns to the list) and "New Draft" (opens the editor for the copy).
     59- In the Classic editor: find the buttons in the Publish box.
     60- In the Block editor (Gutenberg): open the sidebar panel to find "Clone" and "New Draft" buttons.
     61- In the Admin Bar (optional): use the Duplicate menu for quick actions.
     62- Bulk actions: Select items in the list table, choose "Duplicate" from Bulk actions, and apply.
     63
     64== Settings ==
     65
     66Go to Settings → Just Duplicate to configure:
     67
     68- Where to show links: Row actions, Submitbox (edit screen), Admin Bar, and Bulk actions.
     69- Who can duplicate: Allow specific roles and limit to selected post types.
     70- What to copy: Author, date, password, excerpt, template, post format, sticky, menu order (with optional increment), taxonomies, and meta.
     71- Blacklists: Exclude specific meta keys or taxonomies using CSV with wildcard support (e.g., _edit_*).
     72- Attachments: Reference original media or duplicate media files and remap references.
     73- Children: Copy one level of child posts to preserve simple hierarchies.
     74- Redirect after duplicate: Go back to list, open editor, or view the original.
     75- Title/slug patterns: Continue using your existing title/slug patterns without breaking changes.
     76
     77Advanced:
     78
     79- Original surfacing: Add an "Original" column in the list table, a "Copy" post state label, and/or an "Original" meta box on the edit screen.
     80- Rewrite & Republish: Create a working copy of a post; when published, its changes replace the original content while preserving the original ID/URL.
     81
    5682== Frequently Asked Questions ==
    5783
     
    85111
    86112== Changelog ==
     113
     114= 1.0.6 =
     115- Granular settings for what to copy (author, date, password, excerpt, template, format, sticky, menu order with increment, children)
     116- Post type and role allowlists; link location toggles (row, submitbox, admin bar, bulk)
     117- Gutenberg sidebar panel with Clone/New Draft actions (nonce-protected)
     118- Admin Bar “Duplicate” menu with Clone and New Draft (toggleable)
     119- Meta and taxonomy blacklists with * wildcard
     120- Attachment handling: reference originals or duplicate media (best-effort remap)
     121- Redirect preferences after duplication: list/editor/original
     122- Original surfacing: optional list column, post state label, and meta box
     123- Bulk duplicate across all enabled post types with success notice
     124- Rewrite & Republish flow: create working copy and apply changes back to original on publish
     125- Nonces and capability checks across all actions
     126- Backward compatibility preserved for existing options and hooks
     127- Report tab now includes a Clear Log button (clears plugin log and transient)
     128
     129= 1.0.5 =
     130- Update: Improve image assets.
     131- Update: README.txt.
     132- Improvement: Code Improvement
    87133
    88134= 1.0.4 =
     
    176222
    177223== Copyright ==
     224Copyright © 2013 - 2025 Just There
    178225Just Duplicate is built with ❤️ by Just There.
  • just-duplicate/trunk/assets/js/admin-script.js

    r3265459 r3384189  
    1414        $('#' + tabId).addClass('active');
    1515    });
     16
     17    // If a tab hint is provided via query (?jd_tab=report) or hash (#report-tab), activate it on load.
     18    (function activateInitialTab() {
     19        function getQueryParam(name) {
     20            var results = new RegExp('[?&]' + name + '=([^&#]*)').exec(window.location.href);
     21            return results ? decodeURIComponent(results[1].replace(/\+/g, ' ')) : null;
     22        }
     23        var hint = getQueryParam('jd_tab');
     24        var hash = window.location.hash ? window.location.hash.replace('#', '') : '';
     25        var target = null;
     26        if (hint === 'report') { target = 'report-tab'; }
     27        else if (hint === 'general') { target = 'general-tab'; }
     28        else if (hint === 'help') { target = 'help-tab'; }
     29        else if (hash) { target = hash; }
     30        if (target && $('#' + target).length) {
     31            $('.jd-tabs .jd-tab').removeClass('active');
     32            $('.jd-tab-content').removeClass('active');
     33            $('.jd-tabs .jd-tab[data-tab="' + target + '"]').addClass('active');
     34            $('#' + target).addClass('active');
     35        }
     36    })();
    1637
    1738    // --- Preview Duplicate Handling ---
  • just-duplicate/trunk/assets/js/gutenberg-duplicate.js

    r3265459 r3384189  
    22    const { registerPlugin } = wp.plugins;
    33    const { PluginPostStatusInfo } = wp.editPost;
    4     const { createElement } = wp.element;
     4    const { createElement, Fragment } = wp.element;
     5    const { Button } = wp.components;
    56
    67    registerPlugin('just-duplicate', {
    78        render: () => {
     9            const onClone = () => { window.location.href = JustDuplicate.cloneUrl; };
     10            const onNewDraft = () => { window.location.href = JustDuplicate.draftUrl; };
     11            const onRewrite = () => { window.location.href = JustDuplicate.rewriteUrl; };
     12
    813            return createElement(
    914                PluginPostStatusInfo,
    1015                null,
    11                 createElement(
    12                     'a',
    13                     {
    14                         href: JustDuplicate.url,
    15                         className: 'button',
    16                     },
    17                     'Duplicate This'
     16                createElement('div', { className: 'jd-gutenberg-actions' },
     17                    createElement(Button, { onClick: onClone, isSecondary: true, style: { marginRight: '6px' } }, 'Clone'),
     18                    createElement(Button, { onClick: onNewDraft, isPrimary: true, style: { marginRight: '6px' } }, 'New Draft'),
     19                    createElement(Button, { onClick: onRewrite, isTertiary: true }, 'Rewrite & Republish')
    1820                )
    1921            );
  • just-duplicate/trunk/includes/admin/class-admin-settings.php

    r3297655 r3384189  
    77    exit; // Exit if accessed directly.
    88}
     9
     10// Lightweight namespaced shims to forward WordPress global functions; this calms static analysis
     11// while keeping runtime behavior identical under WordPress.
     12if ( ! function_exists( __NAMESPACE__ . '\\add_action' ) ) {
     13    function add_action( $tag, $function_to_add, $priority = 10, $accepted_args = 1 ) { return \function_exists('\\add_action') ? \call_user_func_array('\\add_action', \func_get_args()) : null; }
     14}
     15if ( ! function_exists( __NAMESPACE__ . '\\add_filter' ) ) {
     16    function add_filter( $tag, $function_to_add, $priority = 10, $accepted_args = 1 ) { return \function_exists('\\add_filter') ? \call_user_func_array('\\add_filter', \func_get_args()) : null; }
     17}
     18if ( ! function_exists( __NAMESPACE__ . '\\add_menu_page' ) ) {
     19    function add_menu_page( $page_title, $menu_title, $capability, $menu_slug, $callback = '', $icon_url = '', $position = null ) { return \function_exists('\\add_menu_page') ? \call_user_func_array('\\add_menu_page', \func_get_args()) : null; }
     20}
     21if ( ! function_exists( __NAMESPACE__ . '\\add_meta_box' ) ) {
     22    function add_meta_box( $id, $title, $callback, $screen, $context = 'advanced', $priority = 'default', $callback_args = null ) { return \function_exists('\\add_meta_box') ? \call_user_func_array('\\add_meta_box', \func_get_args()) : null; }
     23}
     24if ( ! function_exists( __NAMESPACE__ . '\\add_settings_field' ) ) {
     25    function add_settings_field( $id, $title, $callback, $page, $section = 'default', $args = [] ) { return \function_exists('\\add_settings_field') ? \call_user_func_array('\\add_settings_field', \func_get_args()) : null; }
     26}
     27if ( ! function_exists( __NAMESPACE__ . '\\add_settings_section' ) ) {
     28    function add_settings_section( $id, $title, $callback, $page ) { return \function_exists('\\add_settings_section') ? \call_user_func_array('\\add_settings_section', \func_get_args()) : null; }
     29}
     30if ( ! function_exists( __NAMESPACE__ . '\\register_setting' ) ) {
     31    function register_setting( $option_group, $option_name, $args = [] ) { return \function_exists('\\register_setting') ? \call_user_func_array('\\register_setting', \func_get_args()) : null; }
     32}
     33if ( ! function_exists( __NAMESPACE__ . '\\__' ) ) {
     34    function __( $text, $domain = 'default' ) { return \function_exists('\\__') ? \call_user_func_array('\\__', \func_get_args()) : $text; }
     35}
     36if ( ! function_exists( __NAMESPACE__ . '\\esc_html__' ) ) {
     37    function esc_html__( $text, $domain = 'default' ) { return \function_exists('\\esc_html__') ? \call_user_func_array('\\esc_html__', \func_get_args()) : $text; }
     38}
     39if ( ! function_exists( __NAMESPACE__ . '\\esc_html' ) ) {
     40    function esc_html( $text ) { return \function_exists('\\esc_html') ? \call_user_func_array('\\esc_html', \func_get_args()) : $text; }
     41}
     42if ( ! function_exists( __NAMESPACE__ . '\\esc_attr' ) ) {
     43    function esc_attr( $text ) { return \function_exists('\\esc_attr') ? \call_user_func_array('\\esc_attr', \func_get_args()) : (string) $text; }
     44}
     45if ( ! function_exists( __NAMESPACE__ . '\\esc_url' ) ) {
     46    function esc_url( $url ) { return \function_exists('\\esc_url') ? \call_user_func_array('\\esc_url', \func_get_args()) : (string) $url; }
     47}
     48if ( ! function_exists( __NAMESPACE__ . '\\selected' ) ) {
     49    function selected( $selected, $current = true, $echo = true ) { return \function_exists('\\selected') ? \call_user_func_array('\\selected', \func_get_args()) : ''; }
     50}
     51if ( ! function_exists( __NAMESPACE__ . '\\checked' ) ) {
     52    function checked( $checked, $current = true, $echo = true ) { return \function_exists('\\checked') ? \call_user_func_array('\\checked', \func_get_args()) : ''; }
     53}
     54if ( ! function_exists( __NAMESPACE__ . '\\submit_button' ) ) {
     55    function submit_button( $text = null, $type = 'primary', $name = 'submit', $wrap = true, $other_attributes = null ) { return \function_exists('\\submit_button') ? \call_user_func_array('\\submit_button', \func_get_args()) : null; }
     56}
     57if ( ! function_exists( __NAMESPACE__ . '\\admin_url' ) ) {
     58    function admin_url( $path = '', $scheme = 'admin' ) { return \function_exists('\\admin_url') ? \call_user_func_array('\\admin_url', \func_get_args()) : (string) $path; }
     59}
     60if ( ! function_exists( __NAMESPACE__ . '\\get_option' ) ) {
     61    function get_option( $option, $default = false ) { return \function_exists('\\get_option') ? \call_user_func_array('\\get_option', \func_get_args()) : $default; }
     62}
     63if ( ! function_exists( __NAMESPACE__ . '\\get_post_types' ) ) {
     64    function get_post_types( $args = [], $output = 'names', $operator = 'and' ) { return \function_exists('\\get_post_types') ? \call_user_func_array('\\get_post_types', \func_get_args()) : []; }
     65}
     66if ( ! function_exists( __NAMESPACE__ . '\\get_post_type' ) ) {
     67    function get_post_type( $post = null ) { return \function_exists('\\get_post_type') ? \call_user_func_array('\\get_post_type', \func_get_args()) : null; }
     68}
     69if ( ! function_exists( __NAMESPACE__ . '\\get_post_type_object' ) ) {
     70    function get_post_type_object( $post_type ) { return \function_exists('\\get_post_type_object') ? \call_user_func_array('\\get_post_type_object', \func_get_args()) : null; }
     71}
     72if ( ! function_exists( __NAMESPACE__ . '\\is_post_type_hierarchical' ) ) {
     73    function is_post_type_hierarchical( $post_type ) { return \function_exists('\\is_post_type_hierarchical') ? \call_user_func_array('\\is_post_type_hierarchical', \func_get_args()) : false; }
     74}
     75if ( ! function_exists( __NAMESPACE__ . '\\get_current_screen' ) ) {
     76    function get_current_screen() { return \function_exists('\\get_current_screen') ? \call_user_func_array('\\get_current_screen', \func_get_args()) : null; }
     77}
     78if ( ! function_exists( __NAMESPACE__ . '\\wp_enqueue_script' ) ) {
     79    function wp_enqueue_script( $handle, $src = '', $deps = [], $ver = false, $in_footer = false ) { return \function_exists('\\wp_enqueue_script') ? \call_user_func_array('\\wp_enqueue_script', \func_get_args()) : null; }
     80}
     81if ( ! function_exists( __NAMESPACE__ . '\\wp_enqueue_style' ) ) {
     82    function wp_enqueue_style( $handle, $src = '', $deps = [], $ver = false, $media = 'all' ) { return \function_exists('\\wp_enqueue_style') ? \call_user_func_array('\\wp_enqueue_style', \func_get_args()) : null; }
     83}
     84if ( ! function_exists( __NAMESPACE__ . '\\wp_localize_script' ) ) {
     85    function wp_localize_script( $handle, $object_name, $l10n ) { return \function_exists('\\wp_localize_script') ? \call_user_func_array('\\wp_localize_script', \func_get_args()) : null; }
     86}
     87if ( ! function_exists( __NAMESPACE__ . '\\wp_nonce_field' ) ) {
     88    function wp_nonce_field( $action = -1, $name = '_wpnonce', $referer = true, $echo = true ) { return \function_exists('\\wp_nonce_field') ? \call_user_func_array('\\wp_nonce_field', \func_get_args()) : null; }
     89}
     90if ( ! function_exists( __NAMESPACE__ . '\\wp_nonce_url' ) ) {
     91    function wp_nonce_url( $actionurl, $action = -1, $name = '_wpnonce' ) { return \function_exists('\\wp_nonce_url') ? \call_user_func_array('\\wp_nonce_url', \func_get_args()) : (string) $actionurl; }
     92}
     93if ( ! function_exists( __NAMESPACE__ . '\\wp_unslash' ) ) {
     94    function wp_unslash( $value ) { return \function_exists('\\wp_unslash') ? \call_user_func_array('\\wp_unslash', \func_get_args()) : $value; }
     95}
     96if ( ! function_exists( __NAMESPACE__ . '\\wp_create_nonce' ) ) {
     97    function wp_create_nonce( $action = -1 ) { return \function_exists('\\wp_create_nonce') ? \call_user_func_array('\\wp_create_nonce', \func_get_args()) : ''; }
     98}
     99if ( ! function_exists( __NAMESPACE__ . '\\sanitize_text_field' ) ) {
     100    function sanitize_text_field( $str ) { return \function_exists('\\sanitize_text_field') ? \call_user_func_array('\\sanitize_text_field', \func_get_args()) : (string) $str; }
     101}
     102if ( ! function_exists( __NAMESPACE__ . '\\get_the_title' ) ) {
     103    function get_the_title( $post = 0 ) { return \function_exists('\\get_the_title') ? \call_user_func_array('\\get_the_title', \func_get_args()) : ''; }
     104}
     105
     106 
    9107
    10108/**
     
    28126     */
    29127    private const DEFAULT_SETTINGS = [
     128        // Back-compat legacy fields
    30129        'redirect_after_duplicate' => false,
    31130        'default_prefix'           => '',
     
    43142        'custom_title_pattern'     => '',
    44143        'custom_slug_pattern'      => '',
     144
     145        // New fields
     146        'enabled_post_types'       => [],
     147        'enabled_roles'            => [ 'administrator', 'editor' ],
     148        'show_row_actions'         => true,
     149        'show_submitbox'           => true,
     150        'show_admin_bar'           => false,
     151        'show_bulk'                => true,
     152        'copy_author'              => false,
     153        'copy_date'                => false,
     154        'copy_password'            => true,
     155        'copy_excerpt'             => true,
     156        'copy_template'            => true,
     157        'copy_post_format'         => true,
     158        'copy_sticky'              => false,
     159        'copy_menu_order'          => true,
     160        'increase_menu_order_by'   => 0,
     161        'copy_children'            => false,
     162        'media_attachment_handling'=> 'reference',
     163        'meta_blacklist'           => '_edit_lock,_edit_last',
     164        'tax_blacklist'            => '',
     165        'redirect_after'           => 'list',
     166        'copy_exact_slug'          => false,
     167        'status_same_as_original'  => false,
     168        // Original surfacing
     169        'show_original_column'     => false,
     170        'show_copy_post_state'     => false,
     171        'show_original_metabox'    => false,
    45172    ];
    46173
     
    53180     */
    54181    public static function init(): void {
    55         add_action( 'admin_menu', [ __CLASS__, 'add_settings_page' ] );
    56         add_action( 'admin_init', [ __CLASS__, 'register_settings' ] );
    57         add_filter( 'bulk_actions-edit-post', [ __CLASS__, 'register_bulk_action' ] );
    58         add_filter( 'bulk_actions-edit-page', [ __CLASS__, 'register_bulk_action' ] );
    59         add_action( 'handle_bulk_actions-edit-post', [ __CLASS__, 'handle_bulk_action' ], 10, 3 );
    60         add_action( 'handle_bulk_actions-edit-page', [ __CLASS__, 'handle_bulk_action' ], 10, 3 );
     182    \add_action( 'admin_menu', [ __CLASS__, 'add_settings_page' ] );
     183    \add_action( 'admin_init', [ __CLASS__, 'register_settings' ] );
     184
     185        // Clear logs action
     186    \add_action( 'admin_post_jd_clear_log', [ __CLASS__, 'handle_clear_log' ] );
     187
     188        // Dynamic bulk registration for enabled CPTs
     189    \add_action( 'current_screen', [ __CLASS__, 'register_bulk_for_screen' ] );
     190
     191        // Submitbox buttons
     192    \add_action( 'post_submitbox_misc_actions', [ __CLASS__, 'render_submitbox_buttons' ] );
     193
     194        // Admin Bar menu
     195    \add_action( 'admin_bar_menu', [ __CLASS__, 'admin_bar_menu' ], 100 );
     196
     197        // Gutenberg panel
     198    \add_action( 'enqueue_block_editor_assets', [ __CLASS__, 'enqueue_gutenberg_assets' ] );
     199
     200        // List table column and post state
     201    \add_filter( 'display_post_states', [ __CLASS__, 'maybe_add_post_state' ], 10, 2 );
     202    \add_filter( 'manage_posts_columns', [ __CLASS__, 'add_original_column' ] );
     203    \add_filter( 'manage_pages_columns', [ __CLASS__, 'add_original_column' ] );
     204    \add_action( 'manage_posts_custom_column', [ __CLASS__, 'render_original_column' ], 10, 2 );
     205    \add_action( 'manage_pages_custom_column', [ __CLASS__, 'render_original_column' ], 10, 2 );
     206    \add_action( 'add_meta_boxes', [ __CLASS__, 'register_original_metabox' ] );
    61207    }
    62208
     
    67213     */
    68214    public static function add_settings_page(): void {
    69         add_menu_page(
    70             __( 'Just Duplicate', 'just-duplicate' ),
    71             __( 'Just Duplicate', 'just-duplicate' ),
     215        \add_menu_page(
     216            \__( 'Just Duplicate', 'just-duplicate' ),
     217            \__( 'Just Duplicate', 'just-duplicate' ),
    72218            'manage_options',
    73219            'just-duplicate-settings',
     
    83229     */
    84230    public static function register_settings(): void {
    85         register_setting(
     231    \register_setting(
    86232            self::OPTION_KEY, // Option group name.
    87233            self::OPTION_KEY, // Option name.
    88234            [
    89235                'type'              => 'array',
    90                 'description'       => __( 'Settings for Just Duplicate', 'just-duplicate' ),
     236                'description'       => \__( 'Settings for Just Duplicate', 'just-duplicate' ),
    91237                'sanitize_callback' => [ __CLASS__, 'sanitize_settings' ], // Explicitly defined callback
    92238                'show_in_rest'      => false,
     
    95241        );
    96242
    97         add_settings_section(
    98             'JUST_DUPLICATE_general',
    99             __( 'General Settings', 'just-duplicate' ),
    100             '__return_false',
    101             self::OPTION_KEY
    102         );
    103 
    104         // Redirect After Duplication field.
    105         add_settings_field(
     243    \add_settings_section( 'JUST_DUPLICATE_general', \__( 'General Settings', 'just-duplicate' ), '__return_false', self::OPTION_KEY );
     244    \add_settings_section( 'JUST_DUPLICATE_links', \__( 'Link Locations', 'just-duplicate' ), '__return_false', self::OPTION_KEY );
     245    \add_settings_section( 'JUST_DUPLICATE_access', \__( 'Access Control', 'just-duplicate' ), '__return_false', self::OPTION_KEY );
     246    \add_settings_section( 'JUST_DUPLICATE_copy', \__( 'What to Copy', 'just-duplicate' ), '__return_false', self::OPTION_KEY );
     247    \add_settings_section( 'JUST_DUPLICATE_behavior', \__( 'Behavior', 'just-duplicate' ), '__return_false', self::OPTION_KEY );
     248
     249        // Redirect After Duplication (legacy checkbox kept for BC).
     250    \add_settings_field(
    106251            'redirect_after_duplicate',
    107             __( 'Redirect After Duplication', 'just-duplicate' ),
     252            \__( 'Redirect After Duplication', 'just-duplicate' ),
    108253            [ __CLASS__, 'render_redirect_field' ],
    109254            self::OPTION_KEY,
     
    112257
    113258        // Default Prefix field.
    114         add_settings_field(
     259    \add_settings_field(
    115260            'default_prefix',
    116             __( 'Default Prefix', 'just-duplicate' ),
     261            \__( 'Default Prefix', 'just-duplicate' ),
    117262            [ __CLASS__, 'render_prefix_field' ],
    118263            self::OPTION_KEY,
     
    121266
    122267        // Default Suffix field.
    123         add_settings_field(
     268    \add_settings_field(
    124269            'default_suffix',
    125             __( 'Default Suffix', 'just-duplicate' ),
     270            \__( 'Default Suffix', 'just-duplicate' ),
    126271            [ __CLASS__, 'render_suffix_field' ],
    127272            self::OPTION_KEY,
     
    130275
    131276        // Duplicate Post Meta field.
    132         add_settings_field(
     277    \add_settings_field(
    133278            'duplicate_post_meta',
    134             __( 'Duplicate Post Meta', 'just-duplicate' ),
     279            \__( 'Duplicate Post Meta', 'just-duplicate' ),
    135280            [ __CLASS__, 'render_duplicate_post_meta_field' ],
    136281            self::OPTION_KEY,
     
    139284
    140285        // Duplicate Taxonomies field.
    141         add_settings_field(
     286    \add_settings_field(
    142287            'duplicate_taxonomies',
    143             __( 'Duplicate Taxonomies', 'just-duplicate' ),
     288            \__( 'Duplicate Taxonomies', 'just-duplicate' ),
    144289            [ __CLASS__, 'render_duplicate_taxonomies_field' ],
    145290            self::OPTION_KEY,
     
    148293
    149294        // Duplicate Attachments field.
    150         add_settings_field(
     295    \add_settings_field(
    151296            'duplicate_attachments',
    152             __( 'Duplicate Attachments', 'just-duplicate' ),
     297            \__( 'Duplicate Attachments', 'just-duplicate' ),
    153298            [ __CLASS__, 'render_duplicate_attachments_field' ],
    154299            self::OPTION_KEY,
     
    157302
    158303        // Duplicate Custom Fields field.
    159         add_settings_field(
     304    \add_settings_field(
    160305            'duplicate_custom_fields',
    161             __( 'Duplicate Custom Fields', 'just-duplicate' ),
     306            \__( 'Duplicate Custom Fields', 'just-duplicate' ),
    162307            [ __CLASS__, 'render_duplicate_custom_fields_field' ],
    163308            self::OPTION_KEY,
     
    166311
    167312        // Duplicate Custom Taxonomies field.
    168         add_settings_field(
     313    \add_settings_field(
    169314            'duplicate_custom_taxonomies',
    170             __( 'Duplicate Custom Taxonomies', 'just-duplicate' ),
     315            \__( 'Duplicate Custom Taxonomies', 'just-duplicate' ),
    171316            [ __CLASS__, 'render_duplicate_custom_taxonomies_field' ],
    172317            self::OPTION_KEY,
     
    175320
    176321        // Duplicate Comments field.
    177         add_settings_field(
     322    \add_settings_field(
    178323            'duplicate_comments',
    179             __( 'Duplicate Comments', 'just-duplicate' ),
     324            \__( 'Duplicate Comments', 'just-duplicate' ),
    180325            [ __CLASS__, 'render_duplicate_comments_field' ],
    181326            self::OPTION_KEY,
     
    184329
    185330        // Duplicate Featured Image field.
    186         add_settings_field(
     331    \add_settings_field(
    187332            'duplicate_featured_image',
    188             __( 'Duplicate Featured Image', 'just-duplicate' ),
     333            \__( 'Duplicate Featured Image', 'just-duplicate' ),
    189334            [ __CLASS__, 'render_duplicate_featured_image_field' ],
    190335            self::OPTION_KEY,
     
    193338
    194339        // Schedule Duplication field.
    195         add_settings_field(
     340    \add_settings_field(
    196341            'schedule_duplication',
    197             __( 'Schedule Duplication', 'just-duplicate' ),
     342            \__( 'Schedule Duplication', 'just-duplicate' ),
    198343            [ __CLASS__, 'render_schedule_duplication_field' ],
    199344            self::OPTION_KEY,
     
    202347
    203348        // Custom Title field.
    204         add_settings_field(
     349    \add_settings_field(
    205350            'custom_title',
    206             __( 'Custom Title', 'just-duplicate' ),
     351            \__( 'Custom Title', 'just-duplicate' ),
    207352            [ __CLASS__, 'render_custom_title_field' ],
    208353            self::OPTION_KEY,
     
    211356
    212357        // Custom Slug field.
    213         add_settings_field(
     358    \add_settings_field(
    214359            'custom_slug',
    215             __( 'Custom Slug', 'just-duplicate' ),
     360            \__( 'Custom Slug', 'just-duplicate' ),
    216361            [ __CLASS__, 'render_custom_slug_field' ],
    217362            self::OPTION_KEY,
     
    220365
    221366        // Custom Post Status field.
    222         add_settings_field(
     367    \add_settings_field(
    223368            'custom_post_status',
    224             __( 'Duplicated Post Status', 'just-duplicate' ),
     369            \__( 'Duplicated Post Status', 'just-duplicate' ),
    225370            [ __CLASS__, 'render_custom_post_status_field' ],
    226371            self::OPTION_KEY,
     
    229374
    230375        // Duplicate ACF Meta field.
    231         add_settings_field(
     376    \add_settings_field(
    232377            'duplicate_acf_meta',
    233             __( 'Duplicate ACF Meta', 'just-duplicate' ),
     378            \__( 'Duplicate ACF Meta', 'just-duplicate' ),
    234379            [ __CLASS__, 'render_duplicate_acf_meta_field' ],
    235380            self::OPTION_KEY,
     
    238383
    239384        // Duplicate SEO Meta field.
    240         add_settings_field(
     385    \add_settings_field(
    241386            'duplicate_seo_meta',
    242             __( 'Duplicate SEO Meta', 'just-duplicate' ),
     387            \__( 'Duplicate SEO Meta', 'just-duplicate' ),
    243388            [ __CLASS__, 'render_duplicate_seo_meta_field' ],
    244389            self::OPTION_KEY,
     
    247392
    248393        // Add fields for custom title and slug patterns.
    249         add_settings_field(
     394    \add_settings_field(
    250395            'custom_title_pattern',
    251             __( 'Custom Title Pattern', 'just-duplicate' ),
     396            \__( 'Custom Title Pattern', 'just-duplicate' ),
    252397            [ __CLASS__, 'render_custom_title_pattern_field' ],
    253398            self::OPTION_KEY,
     
    255400        );
    256401
    257         add_settings_field(
     402    \add_settings_field(
    258403            'custom_slug_pattern',
    259             __( 'Custom Slug Pattern', 'just-duplicate' ),
     404            \__( 'Custom Slug Pattern', 'just-duplicate' ),
    260405            [ __CLASS__, 'render_custom_slug_pattern_field' ],
    261406            self::OPTION_KEY,
     
    264409
    265410        // Add a setting for media attachment handling.
    266         add_settings_field(
     411    \add_settings_field(
    267412            'media_attachment_handling',
    268             __( 'Media Attachment Handling', 'just-duplicate' ),
     413            \__( 'Media Attachment Handling', 'just-duplicate' ),
    269414            [ __CLASS__, 'render_media_attachment_handling_field' ],
    270415            self::OPTION_KEY,
    271416            'JUST_DUPLICATE_general'
    272417        );
     418
     419        // Link locations
     420    \add_settings_field( 'show_row_actions', \__( 'Show in Row Actions', 'just-duplicate' ), [ __CLASS__, 'render_show_row_actions_field' ], self::OPTION_KEY, 'JUST_DUPLICATE_links' );
     421    \add_settings_field( 'show_submitbox', \__( 'Show in Edit Screen', 'just-duplicate' ), [ __CLASS__, 'render_show_submitbox_field' ], self::OPTION_KEY, 'JUST_DUPLICATE_links' );
     422    \add_settings_field( 'show_admin_bar', \__( 'Show in Admin Bar', 'just-duplicate' ), [ __CLASS__, 'render_show_admin_bar_field' ], self::OPTION_KEY, 'JUST_DUPLICATE_links' );
     423    \add_settings_field( 'show_bulk', \__( 'Enable Bulk Action', 'just-duplicate' ), [ __CLASS__, 'render_show_bulk_field' ], self::OPTION_KEY, 'JUST_DUPLICATE_links' );
     424
     425        // Access control
     426    \add_settings_field( 'enabled_post_types', \__( 'Enabled Post Types', 'just-duplicate' ), [ __CLASS__, 'render_enabled_post_types_field' ], self::OPTION_KEY, 'JUST_DUPLICATE_access' );
     427    \add_settings_field( 'enabled_roles', \__( 'Enabled Roles', 'just-duplicate' ), [ __CLASS__, 'render_enabled_roles_field' ], self::OPTION_KEY, 'JUST_DUPLICATE_access' );
     428
     429        // What to copy
     430    \add_settings_field( 'copy_author', \__( 'Copy Author', 'just-duplicate' ), [ __CLASS__, 'render_checkbox_field' ], self::OPTION_KEY, 'JUST_DUPLICATE_copy', [ 'key' => 'copy_author' ] );
     431    \add_settings_field( 'copy_date', \__( 'Copy Date', 'just-duplicate' ), [ __CLASS__, 'render_checkbox_field' ], self::OPTION_KEY, 'JUST_DUPLICATE_copy', [ 'key' => 'copy_date' ] );
     432    \add_settings_field( 'copy_password', \__( 'Copy Password', 'just-duplicate' ), [ __CLASS__, 'render_checkbox_field' ], self::OPTION_KEY, 'JUST_DUPLICATE_copy', [ 'key' => 'copy_password' ] );
     433    \add_settings_field( 'copy_excerpt', \__( 'Copy Excerpt', 'just-duplicate' ), [ __CLASS__, 'render_checkbox_field' ], self::OPTION_KEY, 'JUST_DUPLICATE_copy', [ 'key' => 'copy_excerpt' ] );
     434    \add_settings_field( 'copy_template', \__( 'Copy Page Template', 'just-duplicate' ), [ __CLASS__, 'render_checkbox_field' ], self::OPTION_KEY, 'JUST_DUPLICATE_copy', [ 'key' => 'copy_template' ] );
     435    \add_settings_field( 'copy_post_format', \__( 'Copy Post Format', 'just-duplicate' ), [ __CLASS__, 'render_checkbox_field' ], self::OPTION_KEY, 'JUST_DUPLICATE_copy', [ 'key' => 'copy_post_format' ] );
     436    \add_settings_field( 'copy_sticky', \__( 'Copy Sticky', 'just-duplicate' ), [ __CLASS__, 'render_checkbox_field' ], self::OPTION_KEY, 'JUST_DUPLICATE_copy', [ 'key' => 'copy_sticky' ] );
     437    \add_settings_field( 'copy_menu_order', \__( 'Copy Menu Order', 'just-duplicate' ), [ __CLASS__, 'render_checkbox_field' ], self::OPTION_KEY, 'JUST_DUPLICATE_copy', [ 'key' => 'copy_menu_order' ] );
     438    \add_settings_field( 'increase_menu_order_by', \__( 'Increase Menu Order by', 'just-duplicate' ), [ __CLASS__, 'render_number_field' ], self::OPTION_KEY, 'JUST_DUPLICATE_copy', [ 'key' => 'increase_menu_order_by' ] );
     439    \add_settings_field( 'copy_children', \__( 'Copy Children (one level)', 'just-duplicate' ), [ __CLASS__, 'render_checkbox_field' ], self::OPTION_KEY, 'JUST_DUPLICATE_copy', [ 'key' => 'copy_children' ] );
     440    \add_settings_field( 'meta_blacklist', \__( 'Meta Blacklist (CSV, * supported)', 'just-duplicate' ), [ __CLASS__, 'render_text_field' ], self::OPTION_KEY, 'JUST_DUPLICATE_copy', [ 'key' => 'meta_blacklist' ] );
     441    \add_settings_field( 'tax_blacklist', \__( 'Taxonomy Blacklist (CSV, * supported)', 'just-duplicate' ), [ __CLASS__, 'render_text_field' ], self::OPTION_KEY, 'JUST_DUPLICATE_copy', [ 'key' => 'tax_blacklist' ] );
     442
     443        // Behavior
     444    \add_settings_field( 'redirect_after', \__( 'Redirect After Action', 'just-duplicate' ), [ __CLASS__, 'render_redirect_after_field' ], self::OPTION_KEY, 'JUST_DUPLICATE_behavior' );
     445        \add_settings_field( 'copy_exact_slug', \__( 'Copy Exact Slug', 'just-duplicate' ), [ __CLASS__, 'render_checkbox_field' ], self::OPTION_KEY, 'JUST_DUPLICATE_behavior', [ 'key' => 'copy_exact_slug' ] );
     446    \add_settings_field( 'status_same_as_original', \__( 'Status: Same as original', 'just-duplicate' ), [ __CLASS__, 'render_checkbox_field' ], self::OPTION_KEY, 'JUST_DUPLICATE_behavior', [ 'key' => 'status_same_as_original' ] );
    273447    }
    274448
     
    280454     */
    281455    public static function sanitize_settings( array $settings ): array {
     456        // Populate dynamic post types default if empty
     457    $show_ui_types = array_keys( \get_post_types( [ 'show_ui' => true ], 'names' ) );
     458    $roles = \wp_roles();
     459        $all_roles = is_object( $roles ) ? array_keys( $roles->roles ) : [ 'administrator', 'editor' ];
     460
    282461        return [
    283462            'redirect_after_duplicate' => isset( $settings['redirect_after_duplicate'] ) ? (bool) $settings['redirect_after_duplicate'] : false,
    284             'default_prefix'           => sanitize_text_field( $settings['default_prefix'] ?? '' ),
     463            'default_prefix'           => \sanitize_text_field( $settings['default_prefix'] ?? '' ),
    285464            'default_suffix'           => sanitize_text_field( $settings['default_suffix'] ?? '' ),
    286465            'duplicate_post_meta'      => isset( $settings['duplicate_post_meta'] ) ? (bool) $settings['duplicate_post_meta'] : true,
     
    299478            'custom_title_pattern'     => sanitize_text_field( $settings['custom_title_pattern'] ?? '' ),
    300479            'custom_slug_pattern'      => sanitize_text_field( $settings['custom_slug_pattern'] ?? '' ),
    301             'media_attachment_handling' => $settings['media_attachment_handling'] ?? 'duplicate',
     480            'media_attachment_handling' => in_array( ( $settings['media_attachment_handling'] ?? 'reference' ), [ 'reference','duplicate' ], true ) ? $settings['media_attachment_handling'] : 'reference',
     481
     482            // New settings
     483            'enabled_post_types'       => array_values( array_intersect( (array) ( $settings['enabled_post_types'] ?? $show_ui_types ), $show_ui_types ) ),
     484            'enabled_roles'            => array_values( array_intersect( (array) ( $settings['enabled_roles'] ?? [ 'administrator','editor' ] ), $all_roles ) ),
     485            'show_row_actions'         => isset( $settings['show_row_actions'] ) ? (bool) $settings['show_row_actions'] : true,
     486            'show_submitbox'           => isset( $settings['show_submitbox'] ) ? (bool) $settings['show_submitbox'] : true,
     487            'show_admin_bar'           => isset( $settings['show_admin_bar'] ) ? (bool) $settings['show_admin_bar'] : false,
     488            'show_bulk'                => isset( $settings['show_bulk'] ) ? (bool) $settings['show_bulk'] : true,
     489            'copy_author'              => isset( $settings['copy_author'] ) ? (bool) $settings['copy_author'] : false,
     490            'copy_date'                => isset( $settings['copy_date'] ) ? (bool) $settings['copy_date'] : false,
     491            'copy_password'            => isset( $settings['copy_password'] ) ? (bool) $settings['copy_password'] : true,
     492            'copy_excerpt'             => isset( $settings['copy_excerpt'] ) ? (bool) $settings['copy_excerpt'] : true,
     493            'copy_template'            => isset( $settings['copy_template'] ) ? (bool) $settings['copy_template'] : true,
     494            'copy_post_format'         => isset( $settings['copy_post_format'] ) ? (bool) $settings['copy_post_format'] : true,
     495            'copy_sticky'              => isset( $settings['copy_sticky'] ) ? (bool) $settings['copy_sticky'] : false,
     496            'copy_menu_order'          => isset( $settings['copy_menu_order'] ) ? (bool) $settings['copy_menu_order'] : true,
     497            'increase_menu_order_by'   => isset( $settings['increase_menu_order_by'] ) ? (int) $settings['increase_menu_order_by'] : 0,
     498            'copy_children'            => isset( $settings['copy_children'] ) ? (bool) $settings['copy_children'] : false,
     499            'meta_blacklist'           => sanitize_text_field( $settings['meta_blacklist'] ?? '_edit_lock,_edit_last' ),
     500            'tax_blacklist'            => sanitize_text_field( $settings['tax_blacklist'] ?? '' ),
     501            'redirect_after'           => in_array( ( $settings['redirect_after'] ?? 'list' ), [ 'list','editor','original' ], true ) ? $settings['redirect_after'] : 'list',
     502            'copy_exact_slug'          => isset( $settings['copy_exact_slug'] ) ? (bool) $settings['copy_exact_slug'] : false,
     503            'status_same_as_original'  => isset( $settings['status_same_as_original'] ) ? (bool) $settings['status_same_as_original'] : false,
     504            'show_original_column'     => isset( $settings['show_original_column'] ) ? (bool) $settings['show_original_column'] : false,
     505            'show_copy_post_state'     => isset( $settings['show_copy_post_state'] ) ? (bool) $settings['show_copy_post_state'] : false,
     506            'show_original_metabox'    => isset( $settings['show_original_metabox'] ) ? (bool) $settings['show_original_metabox'] : false,
    302507        ];
    303508    }
     
    314519            <input type="checkbox" name="<?php echo esc_attr( self::OPTION_KEY ); ?>[redirect_after_duplicate]" value="1" <?php checked( $settings['redirect_after_duplicate'] ?? false, true ); ?> />
    315520            <label><?php esc_html_e( 'Redirect to the edit screen of the duplicated item.', 'just-duplicate' ); ?></label>
     521        </p>
     522        <?php
     523    }
     524
     525    public static function render_redirect_after_field(): void {
     526        $settings = get_option( self::OPTION_KEY, [] );
     527        $val = $settings['redirect_after'] ?? 'list';
     528        ?>
     529        <p>
     530            <label><input type="radio" name="<?php echo esc_attr( self::OPTION_KEY ); ?>[redirect_after]" value="list" <?php checked( $val, 'list' ); ?> /> <?php esc_html_e( 'List (default)', 'just-duplicate' ); ?></label><br />
     531            <label><input type="radio" name="<?php echo esc_attr( self::OPTION_KEY ); ?>[redirect_after]" value="editor" <?php checked( $val, 'editor' ); ?> /> <?php esc_html_e( 'Editor', 'just-duplicate' ); ?></label><br />
     532            <label><input type="radio" name="<?php echo esc_attr( self::OPTION_KEY ); ?>[redirect_after]" value="original" <?php checked( $val, 'original' ); ?> /> <?php esc_html_e( 'Original', 'just-duplicate' ); ?></label>
    316533        </p>
    317534        <?php
     
    586803        <p>
    587804            <select name="<?php echo esc_attr( self::OPTION_KEY ); ?>[media_attachment_handling]">
    588                 <option value="duplicate" <?php selected( $settings['media_attachment_handling'] ?? 'duplicate', 'duplicate' ); ?>><?php esc_html_e( 'Duplicate Media', 'just-duplicate' ); ?></option>
    589                 <option value="reference" <?php selected( $settings['media_attachment_handling'] ?? 'duplicate', 'reference' ); ?>><?php esc_html_e( 'Reference Originals', 'just-duplicate' ); ?></option>
     805                <option value="reference" <?php selected( $settings['media_attachment_handling'] ?? 'reference', 'reference' ); ?>><?php esc_html_e( 'Reference Originals', 'just-duplicate' ); ?></option>
     806                <option value="duplicate" <?php selected( $settings['media_attachment_handling'] ?? 'reference', 'duplicate' ); ?>><?php esc_html_e( 'Duplicate Media', 'just-duplicate' ); ?></option>
    590807            </select>
    591808            <span class="description"><?php esc_html_e( 'Choose whether to duplicate attached media or reference the originals.', 'just-duplicate' ); ?></span>
    592809        </p>
    593810        <?php
     811    }
     812
     813    // Link location fields
     814    public static function render_show_row_actions_field(): void { self::render_checkbox_field_common( 'show_row_actions', __( 'Show Clone/New Draft in list rows', 'just-duplicate' ) ); }
     815    public static function render_show_submitbox_field(): void { self::render_checkbox_field_common( 'show_submitbox', __( 'Show buttons in edit screen', 'just-duplicate' ) ); }
     816    public static function render_show_admin_bar_field(): void { self::render_checkbox_field_common( 'show_admin_bar', __( 'Show in admin bar', 'just-duplicate' ) ); }
     817    public static function render_show_bulk_field(): void { self::render_checkbox_field_common( 'show_bulk', __( 'Enable bulk action', 'just-duplicate' ) ); }
     818
     819    private static function render_checkbox_field_common( string $key, string $label ): void {
     820        $settings = get_option( self::OPTION_KEY, [] );
     821        ?>
     822        <p>
     823            <input type="checkbox" name="<?php echo esc_attr( self::OPTION_KEY ); ?>[<?php echo esc_attr( $key ); ?>]" value="1" <?php checked( $settings[ $key ] ?? false, true ); ?> />
     824            <label><?php echo esc_html( $label ); ?></label>
     825        </p>
     826        <?php
     827    }
     828
     829    // Generic checkbox/number/text
     830    public static function render_checkbox_field( array $args ): void {
     831        $key = $args['key'] ?? '';
     832        $settings = get_option( self::OPTION_KEY, [] );
     833        ?>
     834        <p><input type="checkbox" name="<?php echo esc_attr( self::OPTION_KEY ); ?>[<?php echo esc_attr( $key ); ?>]" value="1" <?php checked( $settings[ $key ] ?? false, true ); ?> /></p>
     835        <?php
     836    }
     837    public static function render_number_field( array $args ): void {
     838        $key = $args['key'] ?? '';
     839        $settings = get_option( self::OPTION_KEY, [] );
     840        ?>
     841        <p><input type="number" name="<?php echo esc_attr( self::OPTION_KEY ); ?>[<?php echo esc_attr( $key ); ?>]" value="<?php echo esc_attr( (string) ( $settings[ $key ] ?? '0' ) ); ?>" /></p>
     842        <?php
     843    }
     844    public static function render_text_field( array $args ): void {
     845        $key = $args['key'] ?? '';
     846        $settings = get_option( self::OPTION_KEY, [] );
     847        ?>
     848        <p><input type="text" class="regular-text" name="<?php echo esc_attr( self::OPTION_KEY ); ?>[<?php echo esc_attr( $key ); ?>]" value="<?php echo esc_attr( (string) ( $settings[ $key ] ?? '' ) ); ?>" /></p>
     849        <?php
     850    }
     851
     852    // Enabled post types and roles
     853    public static function render_enabled_post_types_field(): void {
     854        $settings = get_option( self::OPTION_KEY, [] );
     855        $selected = (array) ( $settings['enabled_post_types'] ?? [] );
     856        $types = get_post_types( [ 'show_ui' => true ], 'objects' );
     857        echo '<select multiple name="' . esc_attr( self::OPTION_KEY ) . '[enabled_post_types][]" size="8" style="min-width: 280px;">';
     858        foreach ( $types as $type => $obj ) {
     859            printf( '<option value="%1$s" %2$s>%3$s</option>', esc_attr( $type ), selected( in_array( $type, $selected, true ), true, false ), esc_html( $obj->labels->name ) );
     860        }
     861        echo '</select>';
     862    }
     863    public static function render_enabled_roles_field(): void {
     864        $settings = get_option( self::OPTION_KEY, [] );
     865        $selected = (array) ( $settings['enabled_roles'] ?? [ 'administrator','editor' ] );
     866        $roles = wp_roles();
     867        $all = is_object( $roles ) ? $roles->roles : [];
     868        echo '<select multiple name="' . esc_attr( self::OPTION_KEY ) . '[enabled_roles][]" size="8" style="min-width: 280px;">';
     869        foreach ( $all as $role_key => $role_obj ) {
     870            printf( '<option value="%1$s" %2$s>%3$s</option>', esc_attr( $role_key ), selected( in_array( $role_key, $selected, true ), true, false ), esc_html( $role_obj['name'] ) );
     871        }
     872        echo '</select>';
     873    }
     874
     875    // Dynamic bulk registration per CPT
     876    public static function register_bulk_for_screen( \WP_Screen $screen ): void {
     877        $settings = get_option( self::OPTION_KEY, [] );
     878        if ( empty( $settings['show_bulk'] ) ) { return; }
     879        if ( strpos( $screen->id, 'edit-' ) === 0 ) {
     880            $post_type = substr( $screen->id, 5 );
     881            if ( ! self::is_post_type_enabled( $post_type ) ) { return; }
     882            add_filter( 'bulk_actions-edit-' . $post_type, [ __CLASS__, 'register_bulk_action' ] );
     883            add_filter( 'handle_bulk_actions-edit-' . $post_type, [ \Just_Duplicate\Duplicate_Handler::class, 'handle_bulk_action' ], 10, 3 );
     884        }
    594885    }
    595886
     
    620911        }
    621912        foreach ( $post_ids as $post_id ) {
    622             self::duplicate_post( (int) $post_id );
     913            \Just_Duplicate\Duplicate_Handler::duplicate_post( (int) $post_id, [] );
    623914        }
    624915        return add_query_arg( 'bulk_duplicated', count( $post_ids ), $redirect_url );
     
    685976    }
    686977
     978    // Submitbox UI
     979    public static function render_submitbox_buttons(): void {
     980        $settings = get_option( self::OPTION_KEY, [] );
     981        if ( empty( $settings['show_submitbox'] ) ) { return; }
     982        global $post;
     983        if ( ! $post ) { return; }
     984        if ( ! self::is_post_type_enabled( $post->post_type ) || ! current_user_can( 'edit_post', $post->ID ) || ! self::current_user_role_allowed() ) { return; }
     985        $nonce = wp_create_nonce( 'just_dupe_' . $post->ID );
     986        $clone = admin_url( 'admin.php?action=jd_clone&post=' . $post->ID . '&_wpnonce=' . $nonce );
     987        $draft = admin_url( 'admin.php?action=jd_new_draft&post=' . $post->ID . '&_wpnonce=' . $nonce );
     988        $rewrite = admin_url( 'admin.php?action=jd_rewrite&post=' . $post->ID . '&_wpnonce=' . $nonce );
     989        echo '<div class="misc-pub-section">';
     990        echo '<a class="button" href="' . esc_url( $clone ) . '">' . esc_html__( 'Clone', 'just-duplicate' ) . '</a> ';
     991        echo '<a class="button button-primary" href="' . esc_url( $draft ) . '">' . esc_html__( 'New Draft', 'just-duplicate' ) . '</a> ';
     992        echo '<a class="button" href="' . esc_url( $rewrite ) . '">' . esc_html__( 'Rewrite & Republish', 'just-duplicate' ) . '</a>';
     993        echo '</div>';
     994    }
     995
     996    // Admin Bar
     997    public static function admin_bar_menu( \WP_Admin_Bar $admin_bar ): void {
     998        $settings = get_option( self::OPTION_KEY, [] );
     999        if ( empty( $settings['show_admin_bar'] ) ) { return; }
     1000        global $pagenow, $post;
     1001        if ( ! in_array( $pagenow, [ 'post.php', 'post-new.php' ], true ) || ! $post ) { return; }
     1002        if ( ! self::is_post_type_enabled( $post->post_type ) || ! current_user_can( 'edit_post', $post->ID ) || ! self::current_user_role_allowed() ) { return; }
     1003        $nonce = wp_create_nonce( 'just_dupe_' . $post->ID );
     1004        $parent_id = 'just-duplicate';
     1005        $admin_bar->add_node( [ 'id' => $parent_id, 'title' => __( 'Duplicate', 'just-duplicate' ), 'href' => false ] );
     1006        $admin_bar->add_node( [ 'id' => 'jd-clone', 'parent' => $parent_id, 'title' => __( 'Clone', 'just-duplicate' ), 'href' => admin_url( 'admin.php?action=jd_clone&post=' . $post->ID . '&_wpnonce=' . $nonce ) ] );
     1007        $admin_bar->add_node( [ 'id' => 'jd-new-draft', 'parent' => $parent_id, 'title' => __( 'New Draft', 'just-duplicate' ), 'href' => admin_url( 'admin.php?action=jd_new_draft&post=' . $post->ID . '&_wpnonce=' . $nonce ) ] );
     1008        $admin_bar->add_node( [ 'id' => 'jd-rewrite', 'parent' => $parent_id, 'title' => __( 'Rewrite & Republish', 'just-duplicate' ), 'href' => admin_url( 'admin.php?action=jd_rewrite&post=' . $post->ID . '&_wpnonce=' . $nonce ) ] );
     1009    }
     1010
     1011    // Gutenberg assets
     1012    public static function enqueue_gutenberg_assets(): void {
     1013        $settings = get_option( self::OPTION_KEY, [] );
     1014        if ( empty( $settings['show_submitbox'] ) ) { return; }
     1015        global $post;
     1016        if ( ! $post ) { return; }
     1017        if ( ! self::is_post_type_enabled( $post->post_type ) || ! current_user_can( 'edit_post', $post->ID ) || ! self::current_user_role_allowed() ) { return; }
     1018        $nonce = wp_create_nonce( 'just_dupe_' . $post->ID );
     1019        wp_enqueue_script( 'just-duplicate-gutenberg', JUST_DUPLICATE_URL . 'assets/js/gutenberg-duplicate.js', [ 'wp-edit-post', 'wp-plugins', 'wp-element', 'wp-components' ], JUST_DUPLICATE_VERSION, true );
     1020        wp_localize_script( 'just-duplicate-gutenberg', 'JustDuplicate', [
     1021            'cloneUrl' => admin_url( 'admin.php?action=jd_clone&post=' . $post->ID . '&_wpnonce=' . $nonce ),
     1022            'draftUrl' => admin_url( 'admin.php?action=jd_new_draft&post=' . $post->ID . '&_wpnonce=' . $nonce ),
     1023            'rewriteUrl' => admin_url( 'admin.php?action=jd_rewrite&post=' . $post->ID . '&_wpnonce=' . $nonce ),
     1024        ] );
     1025    }
     1026
     1027    // List table columns and post state
     1028    public static function add_original_column( array $columns ): array {
     1029        $settings = get_option( self::OPTION_KEY, [] );
     1030        if ( empty( $settings['show_original_column'] ) ) { return $columns; }
     1031        $columns['jd_original'] = __( 'Original', 'just-duplicate' );
     1032        return $columns;
     1033    }
     1034    public static function render_original_column( string $column, int $post_id ): void {
     1035        if ( 'jd_original' !== $column ) { return; }
     1036        $orig = (int) get_post_meta( $post_id, '_jd_original_post', true );
     1037        if ( $orig ) {
     1038            $link = get_edit_post_link( $orig, '' );
     1039            echo $link ? '<a href="' . esc_url( $link ) . '">#' . esc_html( (string) $orig ) . '</a>' : '#' . esc_html( (string) $orig );
     1040        }
     1041    }
     1042    public static function maybe_add_post_state( array $states, \WP_Post $post ): array {
     1043        $settings = get_option( self::OPTION_KEY, [] );
     1044        if ( empty( $settings['show_copy_post_state'] ) ) { return $states; }
     1045        $orig = (int) get_post_meta( $post->ID, '_jd_original_post', true );
     1046        if ( $orig ) {
     1047            $states['jd_copy'] = __( 'Copy', 'just-duplicate' );
     1048        }
     1049        return $states;
     1050    }
     1051    public static function register_original_metabox(): void {
     1052        $settings = get_option( self::OPTION_KEY, [] );
     1053        if ( empty( $settings['show_original_metabox'] ) ) { return; }
     1054        add_meta_box( 'jd_original_meta', __( 'Original', 'just-duplicate' ), [ __CLASS__, 'render_original_metabox' ], null, 'side', 'default' );
     1055    }
     1056    public static function render_original_metabox( \WP_Post $post ): void {
     1057        $orig = (int) get_post_meta( $post->ID, '_jd_original_post', true );
     1058        if ( $orig ) {
     1059            $link = get_edit_post_link( $orig, '' );
     1060            echo '<p>' . esc_html__( 'Original:', 'just-duplicate' ) . ' ' . ( $link ? '<a href="' . esc_url( $link ) . '">#' . esc_html( (string) $orig ) . '</a>' : '#' . esc_html( (string) $orig ) ) . '</p>';
     1061        } else {
     1062            echo '<p>' . esc_html__( 'No original reference found.', 'just-duplicate' ) . '</p>';
     1063        }
     1064    }
     1065
     1066    private static function is_post_type_enabled( string $post_type ): bool {
     1067        $settings = get_option( self::OPTION_KEY, [] );
     1068        $enabled = (array) ( $settings['enabled_post_types'] ?? [] );
     1069        return empty( $enabled ) || in_array( $post_type, $enabled, true );
     1070    }
     1071    private static function current_user_role_allowed(): bool {
     1072        $settings = get_option( self::OPTION_KEY, [] );
     1073        $allowed = (array) ( $settings['enabled_roles'] ?? [ 'administrator','editor' ] );
     1074        $user = wp_get_current_user();
     1075        if ( empty( $user->roles ) ) { return false; }
     1076        foreach ( $user->roles as $role ) {
     1077            if ( in_array( $role, $allowed, true ) ) { return true; }
     1078        }
     1079        return false;
     1080    }
     1081
    6871082    /**
    6881083     * Copy metadata from the original post to the duplicated post.
     
    7651160        <div class="wrap just-duplicate-wrap">
    7661161            <h1><?php esc_html_e( 'Just Duplicate Settings', 'just-duplicate' ); ?></h1>
     1162            <?php if ( isset( $_GET['jd_log_cleared'] ) && '1' === $_GET['jd_log_cleared'] ) : ?>
     1163                <div class="notice notice-success is-dismissible"><p><?php echo esc_html__( 'Duplication log cleared.', 'just-duplicate' ); ?></p></div>
     1164            <?php endif; ?>
    7671165            <!-- Tabs Navigation -->
    7681166            <ul class="jd-tabs">
     
    8521250        ?>
    8531251        <h2><?php esc_html_e( 'Duplication Report', 'just-duplicate' ); ?></h2>
     1252        <form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" style="margin-bottom:12px;">
     1253            <input type="hidden" name="action" value="jd_clear_log" />
     1254            <?php wp_nonce_field( 'jd_clear_log_action', '_jdnonce' ); ?>
     1255            <button type="submit" class="button button-secondary" onclick="return confirm('<?php echo esc_js( __( 'This will permanently clear the duplication log and related transients. Are you sure?', 'just-duplicate' ) ); ?>');">
     1256                <?php esc_html_e( 'Clear Log', 'just-duplicate' ); ?>
     1257            </button>
     1258        </form>
    8541259        <table class="wp-list-table widefat fixed striped">
    8551260            <thead>
     
    8781283
    8791284    /**
     1285     * Handle the Clear Log admin action.
     1286     *
     1287     * @return void
     1288     */
     1289    public static function handle_clear_log(): void {
     1290        if ( ! current_user_can( 'manage_options' ) ) {
     1291            wp_die( esc_html__( 'You do not have permission to perform this action.', 'just-duplicate' ) );
     1292        }
     1293        $nonce = isset( $_POST['_jdnonce'] ) ? sanitize_text_field( wp_unslash( $_POST['_jdnonce'] ) ) : '';
     1294        if ( ! wp_verify_nonce( $nonce, 'jd_clear_log_action' ) ) {
     1295            wp_die( esc_html__( 'Nonce verification failed.', 'just-duplicate' ) );
     1296        }
     1297
     1298        // Clear option-based log.
     1299        \Just_Duplicate\Duplicate_Logger::clear();
     1300        // Clear transient tracking the last duplicated post id.
     1301        \delete_transient( 'just_duplicate_last_post_id' );
     1302
     1303        /**
     1304         * Fires after Just Duplicate logs are cleared.
     1305         * Allows extensions to purge their own options/transients.
     1306         */
     1307        \do_action( 'just_duplicate_logs_cleared' );
     1308
     1309        // Redirect back to settings with a success flag; try to re-open the Report tab.
     1310        $url = \add_query_arg( [
     1311            'page' => 'just-duplicate-settings',
     1312            'jd_log_cleared' => '1',
     1313            'jd_tab' => 'report',
     1314        ], admin_url( 'admin.php' ) );
     1315        \wp_safe_redirect( $url );
     1316        exit;
     1317    }
     1318
     1319    /**
    8801320     * Handle the preview duplication AJAX request.
    8811321     *
  • just-duplicate/trunk/includes/class-duplicate-handler.php

    r3297655 r3384189  
    77    exit; // Exit if accessed directly.
    88}
     9
     10// Import WordPress global functions/constants for static analysis and clarity.
     11use const \HOUR_IN_SECONDS;
     12use function \absint;
     13use function \add_action;
     14use function \add_filter;
     15use function \add_query_arg;
     16use function \admin_url;
     17use function \apply_filters;
     18use function \check_ajax_referer;
     19use function \current_user_can;
     20use function \date_i18n;
     21use function \delete_post_meta;
     22use function \delete_transient;
     23use function \do_action;
     24use function \esc_html;
     25use function \esc_html__;
     26use function \esc_url;
     27use function \get_attached_file;
     28use function \get_post_types;
     29use function \get_children;
     30use function \get_comments;
     31use function \get_current_user_id;
     32use function \get_edit_post_link;
     33use function \get_object_taxonomies;
     34use function \get_post;
     35use function \get_post_field;
     36use function \get_post_format;
     37use function \get_post_meta;
     38use function \get_post_thumbnail_id;
     39use function \get_post_type;
     40use function \get_the_author_meta;
     41use function \get_transient;
     42use function \is_wp_error;
     43use function \maybe_unserialize;
     44use function \sanitize_text_field;
     45use function \sanitize_title;
     46use function \set_post_format;
     47use function \set_post_thumbnail;
     48use function \set_transient;
     49use function \stick_post;
     50use function \update_post_meta;
     51use function \wp_check_filetype;
     52use function \wp_create_nonce;
     53use function \wp_die;
     54use function \wp_generate_attachment_metadata;
     55use function \wp_get_current_user;
     56use function \wp_get_object_terms;
     57use function \wp_insert_attachment;
     58use function \wp_insert_comment;
     59use function \wp_insert_post;
     60use function \wp_kses_post;
     61use function \wp_nonce_url;
     62use function \wp_redirect;
     63use function \wp_safe_redirect;
     64use function \wp_send_json_error;
     65use function \wp_send_json_success;
     66use function \wp_set_object_terms;
     67use function \wp_trash_post;
     68use function \wp_unslash;
     69use function \wp_update_attachment_metadata;
     70use function \wp_update_post;
     71use function \wp_upload_dir;
     72use function \wp_verify_nonce;
    973
    1074/**
     
    1680
    1781    /**
     82     * Cached settings for this request.
     83     *
     84     * @var array|null
     85     */
     86    private static ?array $settings = null;
     87
     88    /**
     89     * Allowed action slugs.
     90     */
     91    private const ACTION_CLONE = 'jd_clone';
     92    private const ACTION_NEW_DRAFT = 'jd_new_draft';
     93    private const ACTION_REWRITE = 'jd_rewrite';
     94    private const NONCE_ACTION_PREFIX = 'just_dupe_';
     95
     96    /**
    1897     * Initialize the duplication handler.
    1998     *
     
    23102     */
    24103    public static function init(): void {
    25         // Add duplicate action links for posts and pages.
    26         add_filter( 'post_row_actions', [ __CLASS__, 'add_duplicate_link' ], 10, 2 );
    27         add_filter( 'page_row_actions', [ __CLASS__, 'add_duplicate_link' ], 10, 2 );
    28 
    29         // Handle the duplication action.
    30         add_action( 'admin_action_duplicate_post', [ __CLASS__, 'process_duplication' ] );
    31 
    32         // Hook to display the rollback notice.
    33         add_action( 'admin_notices', [ __CLASS__, 'add_rollback_notice' ] );
    34 
    35         // Hook to handle the rollback action.
    36         add_action( 'admin_action_rollback_duplicate', [ __CLASS__, 'handle_rollback_action' ] );
     104        // Add row action links for all admin-visible post types (filtered later by allowlist).
     105        $post_types = \get_post_types( [ 'show_ui' => true ], 'names' );
     106        if ( \is_array( $post_types ) ) {
     107            foreach ( $post_types as $post_type ) {
     108                \add_filter( $post_type . '_row_actions', [ __CLASS__, 'add_row_action_links' ], 10, 2 );
     109                // Bulk actions per CPT handled elsewhere in Admin Settings for registration; keep handler here as fallback.
     110                \add_filter( 'handle_bulk_actions-edit-' . $post_type, [ __CLASS__, 'handle_bulk_action' ], 10, 3 );
     111            }
     112        }
     113
     114        // Handle the duplicate actions.
     115        \add_action( 'admin_action_' . self::ACTION_CLONE, [ __CLASS__, 'process_clone' ] );
     116        \add_action( 'admin_action_' . self::ACTION_NEW_DRAFT, [ __CLASS__, 'process_new_draft' ] );
     117        \add_action( 'admin_action_duplicate_post', [ __CLASS__, 'process_duplication_legacy' ] );
     118
     119        // Rewrite & Republish entry and finalize on publish.
     120        \add_action( 'admin_action_' . self::ACTION_REWRITE, [ __CLASS__, 'process_rewrite' ] );
     121        \add_action( 'transition_post_status', [ __CLASS__, 'maybe_finalize_rewrite' ], 10, 3 );
     122
     123        // Hook to display notices.
     124        \add_action( 'admin_notices', [ __CLASS__, 'render_admin_notices' ] );
     125        \add_action( 'admin_notices', [ __CLASS__, 'add_rollback_notice' ] );
     126
     127        // Rollback action.
     128        \add_action( 'admin_action_rollback_duplicate', [ __CLASS__, 'handle_rollback_action' ] );
    37129    }
    38130
     
    44136     * @return array Modified array of row action links.
    45137     */
    46     public static function add_duplicate_link( array $actions, \WP_Post $post ): array {
    47         if ( current_user_can( 'edit_posts', $post->ID ) ) {
    48             // Build duplicate URL.
    49             $duplicate_url = wp_nonce_url(
    50                 add_query_arg(
    51                     [
    52                         'action' => 'duplicate_post',
    53                         'post'   => $post->ID,
    54                     ],
    55                     admin_url( 'admin.php' )
    56                 ),
    57                 'duplicate_post_' . $post->ID
    58             );
    59             // Build preview URL for AJAX preview.
    60             $preview_url = wp_nonce_url(
    61                 add_query_arg(
    62                     [
    63                         'action' => 'preview_duplicate_post',
    64                         'post'   => $post->ID,
    65                     ],
    66                     admin_url( 'admin-ajax.php' )
    67                 ),
    68                 'preview_duplicate_post_' . $post->ID
    69             );
    70             $actions['duplicate'] = sprintf(
    71                 '<a href="%s">%s</a>',
    72                 esc_url( $duplicate_url ),
    73                 esc_html__( 'Duplicate', 'just-duplicate' )
    74             );
    75             $actions['preview_duplicate'] = sprintf(
    76                 '<a href="#" class="preview-duplicate" data-preview-url="%s">%s</a>',
    77                 esc_url( $preview_url ),
    78                 esc_html__( 'Preview Duplicate', 'just-duplicate' )
    79             );
    80         }
    81 
     138    public static function add_row_action_links( array $actions, $post ): array {
     139        $settings = self::get_settings();
     140        if ( empty( $settings['show_row_actions'] ) ) {
     141            return $actions;
     142        }
     143        if ( ! self::is_post_type_enabled( $post->post_type ) ) {
     144            return $actions;
     145        }
     146        if ( ! \current_user_can( 'edit_post', $post->ID ) || ! self::current_user_role_allowed() ) {
     147            return $actions;
     148        }
     149
     150        $nonce = \wp_create_nonce( self::NONCE_ACTION_PREFIX . $post->ID );
     151        $clone_url = \add_query_arg( [ 'action' => self::ACTION_CLONE, 'post' => $post->ID, '_wpnonce' => $nonce ], \admin_url( 'admin.php' ) );
     152        $draft_url = \add_query_arg( [ 'action' => self::ACTION_NEW_DRAFT, 'post' => $post->ID, '_wpnonce' => $nonce ], \admin_url( 'admin.php' ) );
     153        $rewrite_url = \add_query_arg( [ 'action' => self::ACTION_REWRITE, 'post' => $post->ID, '_wpnonce' => $nonce ], \admin_url( 'admin.php' ) );
     154
     155        $actions['jd_clone'] = '<a href="' . \esc_url( $clone_url ) . '">' . \esc_html__( 'Clone', 'just-duplicate' ) . '</a>';
     156        $actions['jd_new_draft'] = '<a href="' . \esc_url( $draft_url ) . '">' . \esc_html__( 'New Draft', 'just-duplicate' ) . '</a>';
     157        $actions['jd_rewrite'] = '<a href="' . \esc_url( $rewrite_url ) . '">' . \esc_html__( 'Rewrite & Republish', 'just-duplicate' ) . '</a>';
    82158        return $actions;
    83159    }
     
    86162     * Process the duplication of a post or page with role-based access control.
    87163     */
    88     public static function process_duplication(): void {
     164    public static function process_duplication_legacy(): void {
    89165        // Verify nonce.
    90166        if ( ! isset( $_GET['_wpnonce'], $_GET['post'] ) ) {
    91             wp_die( esc_html__( 'Missing required parameters.', 'just-duplicate' ) );
    92         }
    93 
    94         $post_id = absint( $_GET['post'] );
    95         $nonce   = isset( $_GET['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ) : '';
    96 
    97         if ( ! wp_verify_nonce( $nonce, 'duplicate_post_' . $post_id ) ) {
    98             wp_die( esc_html__( 'Nonce verification failed.', 'just-duplicate' ) );
    99         }
    100 
    101         $post = get_post( $post_id );
     167            \wp_die( \esc_html__( 'Missing required parameters.', 'just-duplicate' ) );
     168        }
     169
     170        $post_id = \absint( $_GET['post'] );
     171        $nonce   = isset( $_GET['_wpnonce'] ) ? \sanitize_text_field( \wp_unslash( $_GET['_wpnonce'] ) ) : '';
     172
     173        if ( ! \wp_verify_nonce( $nonce, 'duplicate_post_' . $post_id ) ) {
     174            \wp_die( \esc_html__( 'Nonce verification failed.', 'just-duplicate' ) );
     175        }
     176
     177        $post = \get_post( $post_id );
    102178        if ( ! $post ) {
    103             wp_die( esc_html__( 'The post you are trying to duplicate does not exist.', 'just-duplicate' ) );
     179            \wp_die( \esc_html__( 'The post you are trying to duplicate does not exist.', 'just-duplicate' ) );
    104180        }
    105181
    106182        // Role-based access control.
    107         $current_user_id = get_current_user_id();
    108         if ( ! ( current_user_can( 'manage_options' ) || current_user_can( 'edit_others_posts' ) ||
    109                  ( current_user_can( 'edit_posts' ) && $current_user_id === $post->post_author ) ) ) {
    110             wp_die( esc_html__( 'You do not have permission to duplicate this post.', 'just-duplicate' ) );
     183        $current_user_id = \get_current_user_id();
     184        if ( ! ( \current_user_can( 'manage_options' ) || \current_user_can( 'edit_others_posts' ) ||
     185                 ( \current_user_can( 'edit_posts' ) && $current_user_id === $post->post_author ) ) ) {
     186            \wp_die( \esc_html__( 'You do not have permission to duplicate this post.', 'just-duplicate' ) );
    111187        }
    112188
    113189        // Duplicate the post.
    114         $new_post_id = self::duplicate_post( $post_id );
     190        $new_post_id = self::duplicate_post( $post_id, [] );
    115191
    116192        if ( $new_post_id ) {
    117             // Redirect back to the referring page.
    118             $referer = wp_get_referer();
    119             $redirect_url = $referer ? $referer : admin_url( 'edit.php' );
    120             wp_redirect( $redirect_url );
     193            // Legacy behavior: redirect to editor.
     194            $edit_url = \add_query_arg( [ 'post' => $new_post_id, 'action' => 'edit' ], \admin_url( 'post.php' ) );
     195            \wp_redirect( $edit_url );
    121196            exit;
    122197        } else {
     
    126201
    127202    /**
     203     * Process the Clone action: duplicate and return to list (per settings).
     204     */
     205    public static function process_clone(): void {
     206        self::process_action_request( self::ACTION_CLONE );
     207    }
     208
     209    /**
     210     * Process the New Draft action: duplicate and open editor.
     211     */
     212    public static function process_new_draft(): void {
     213        self::process_action_request( self::ACTION_NEW_DRAFT );
     214    }
     215
     216    /**
     217     * Common action request processor.
     218     */
     219    private static function process_action_request( string $action ): void {
     220        if ( ! isset( $_GET['_wpnonce'], $_GET['post'] ) ) {
     221            \wp_die( \esc_html__( 'Missing required parameters.', 'just-duplicate' ) );
     222        }
     223        $post_id = \absint( $_GET['post'] );
     224        $nonce   = \sanitize_text_field( \wp_unslash( $_GET['_wpnonce'] ) );
     225        if ( ! \wp_verify_nonce( $nonce, self::NONCE_ACTION_PREFIX . $post_id ) ) {
     226            \wp_die( \esc_html__( 'Nonce verification failed.', 'just-duplicate' ) );
     227        }
     228        $post = \get_post( $post_id );
     229        if ( ! $post ) {
     230            \wp_die( \esc_html__( 'The post you are trying to duplicate does not exist.', 'just-duplicate' ) );
     231        }
     232        if ( ! \current_user_can( 'edit_post', $post_id ) || ! self::current_user_role_allowed() || ! self::is_post_type_enabled( $post->post_type ) ) {
     233            \wp_die( \esc_html__( 'You do not have permission to duplicate this post.', 'just-duplicate' ) );
     234        }
     235
     236        $args = [ 'open_editor' => ( $action === self::ACTION_NEW_DRAFT ) ];
     237        $new_post_id = self::duplicate_post( $post_id, $args );
     238        if ( is_wp_error( $new_post_id ) || ! $new_post_id ) {
     239            \wp_die( esc_html__( 'Failed to duplicate the post.', 'just-duplicate' ) );
     240        }
     241
     242        // Build redirect per settings and action.
     243        $settings = self::get_settings();
     244        $redirect_pref = isset( $settings['redirect_after'] ) ? $settings['redirect_after'] : 'list';
     245
     246        $redirect = \admin_url( 'edit.php?post_type=' . urlencode( \get_post_type( $post_id ) ) );
     247        if ( $redirect_pref === 'original' ) {
     248            $redirect = \get_edit_post_link( $post_id, '' );
     249        }
     250        if ( $action === self::ACTION_NEW_DRAFT || $redirect_pref === 'editor' ) {
     251            $redirect = \add_query_arg( [ 'post' => $new_post_id, 'action' => 'edit' ], \admin_url( 'post.php' ) );
     252        }
     253        // Add notice flag.
     254        $redirect = \add_query_arg( [ 'jd_duplicated' => 1, 'jd_new' => $new_post_id, 'jd_from' => $post_id ], $redirect );
     255        \wp_safe_redirect( $redirect );
     256        exit;
     257    }
     258
     259    /**
    128260     * Duplicate a post or page.
    129261     *
     
    131263     * @return int|null The ID of the duplicated post, or null on failure.
    132264     */
    133     public static function duplicate_post( int $post_id ): ?int {
    134         $post = get_post( $post_id );
     265    public static function duplicate_post( int $post_id, array $args = [] ) {
     266        $post = \get_post( $post_id );
    135267        if ( ! $post ) {
    136268            return null;
     
    138270
    139271        // Trigger the before duplicate hook.
    140         do_action( 'just_duplicate_before_duplicate', $post_id );
    141 
    142         $settings = get_option( 'JUST_DUPLICATE_settings', [] );
    143         $title_pattern = $settings['custom_title_pattern'] ?? '%original% (Copy)';
    144         $slug_pattern = $settings['custom_slug_pattern'] ?? '%original%-copy';
    145         $post_status = $settings['custom_post_status'] ?? 'draft';
     272    \do_action( 'just_duplicate_before_duplicate', $post_id );
     273
     274    $settings = self::get_settings();
     275    $title_pattern = $settings['custom_title_pattern'] ?? '%original% (Copy)';
     276    $slug_pattern = $settings['custom_slug_pattern'] ?? '%original%-copy';
     277    $status_setting = $settings['custom_post_status'] ?? 'draft'; // legacy field
     278    $copy_status_same = isset( $settings['status_same_as_original'] ) ? (bool) $settings['status_same_as_original'] : false;
     279    $post_status = $copy_status_same ? $post->post_status : $status_setting;
     280
     281    $copy_author   = ! empty( $settings['copy_author'] );
     282    $copy_date     = ! empty( $settings['copy_date'] );
     283    $copy_password = ! array_key_exists( 'copy_password', $settings ) || ! empty( $settings['copy_password'] ); // default true
     284    $copy_excerpt  = isset( $settings['copy_excerpt'] ) ? (bool) $settings['copy_excerpt'] : true;
     285    $copy_template = ! empty( $settings['copy_template'] );
     286    $copy_format   = ! empty( $settings['copy_post_format'] );
     287    $copy_sticky   = ! empty( $settings['copy_sticky'] );
     288    $copy_menu     = isset( $settings['copy_menu_order'] ) ? (bool) $settings['copy_menu_order'] : true;
     289    $menu_inc      = isset( $settings['increase_menu_order_by'] ) ? (int) $settings['increase_menu_order_by'] : 0;
     290    $copy_children = ! empty( $settings['copy_children'] );
     291    $copy_tax      = isset( $settings['duplicate_taxonomies'] ) ? (bool) $settings['duplicate_taxonomies'] : true;
     292    $copy_meta     = isset( $settings['duplicate_post_meta'] ) ? (bool) $settings['duplicate_post_meta'] : true;
     293    $media_handling = $settings['media_attachment_handling'] ?? 'reference';
     294    $tax_blacklist = self::parse_csv_patterns( $settings['tax_blacklist'] ?? '' );
     295    $meta_blacklist = self::parse_csv_patterns( $settings['meta_blacklist'] ?? '_edit_lock,_edit_last' );
     296    $copy_exact_slug = ! empty( $settings['copy_exact_slug'] );
    146297
    147298        // Prepare the new post data.
     299    $new_title = \str_replace( ['%original%', '%date%', '%timestamp%'], [$post->post_title, \date_i18n( 'Y-m-d' ), \time()], $title_pattern );
     300    $new_slug  = $copy_exact_slug ? $post->post_name : \str_replace( ['%original%', '%date%', '%timestamp%'], [\sanitize_title( $post->post_title ), \date_i18n( 'Y-m-d' ), \time()], $slug_pattern );
     301
    148302        $new_post = [
    149             'post_title'   => str_replace(
    150                 ['%original%', '%date%', '%timestamp%'],
    151                 [$post->post_title, date_i18n( 'Y-m-d' ), time()],
    152                 $title_pattern
    153             ),
    154             'post_name'    => str_replace(
    155                 ['%original%', '%date%', '%timestamp%'],
    156                 [sanitize_title( $post->post_title ), date_i18n( 'Y-m-d' ), time()],
    157                 $slug_pattern
    158             ),
     303            'post_title'   => $new_title,
     304            'post_name'    => $new_slug,
    159305            'post_content' => $post->post_content,
    160306            'post_status'  => $post_status,
    161307            'post_type'    => $post->post_type,
    162             'post_author'  => get_current_user_id(),
    163             'post_excerpt' => $post->post_excerpt,
     308            'post_author'  => $copy_author ? (int) $post->post_author : (int) \get_current_user_id(),
     309            'post_excerpt' => $copy_excerpt ? $post->post_excerpt : '',
    164310            'post_parent'  => $post->post_parent,
    165311        ];
    166312
     313        if ( $copy_password ) {
     314            $new_post['post_password'] = $post->post_password;
     315        }
     316        if ( $copy_date ) {
     317            $new_post['post_date'] = $post->post_date;
     318            $new_post['post_date_gmt'] = $post->post_date_gmt;
     319        }
     320        if ( $copy_menu ) {
     321            $new_post['menu_order'] = (int) $post->menu_order + $menu_inc;
     322        }
     323
    167324        // Insert the new post.
    168         $new_post_id = wp_insert_post( $new_post );
    169         if ( is_wp_error( $new_post_id ) || ! $new_post_id ) {
     325    $new_post_id = \wp_insert_post( \wp_slash( $new_post ) );
     326        if ( \is_wp_error( $new_post_id ) || ! $new_post_id ) {
    170327            return null;
    171328        }
    172329
    173         // Copy metadata, taxonomies, and other related data.
    174         self::copy_post_meta( $post_id, $new_post_id );
    175         self::copy_post_taxonomies( $post_id, $new_post_id );
    176 
    177         // If the original post has a featured image, duplicate it.
    178         $settings = get_option( 'JUST_DUPLICATE_settings', [] );
    179         $media_handling = $settings['media_attachment_handling'] ?? 'duplicate';
    180 
    181         if ( 'duplicate' === $media_handling ) {
    182             $thumb_id = get_post_thumbnail_id( $post_id );
    183             if ( $thumb_id ) {
     330        // Save original reference.
     331        \update_post_meta( $new_post_id, '_jd_original_post', $post_id );
     332
     333        // Copy taxonomies unless blacklisted per taxonomy.
     334        if ( $copy_tax ) {
     335            self::copy_post_taxonomies_filtered( $post_id, $new_post_id, $tax_blacklist );
     336        }
     337
     338        // Copy meta with blacklist and preserve known exclusions.
     339        if ( $copy_meta ) {
     340            self::copy_post_meta_filtered( $post_id, $new_post_id, $meta_blacklist );
     341        }
     342
     343        // Page template and format.
     344        if ( $copy_template ) {
     345            $template = \get_post_meta( $post_id, '_wp_page_template', true );
     346            if ( $template ) { \update_post_meta( $new_post_id, '_wp_page_template', $template ); }
     347        }
     348        if ( $copy_format && \function_exists( 'set_post_format' ) ) {
     349            $format = \get_post_format( $post_id );
     350            if ( $format ) { \set_post_format( $new_post_id, $format ); }
     351        }
     352        if ( $copy_sticky && 'post' === $post->post_type ) {
     353            \stick_post( $new_post_id );
     354        }
     355
     356        // Featured image and attachments handling.
     357    $thumb_id = \get_post_thumbnail_id( $post_id );
     358        $attachment_map = [];
     359        if ( $thumb_id ) {
     360            if ( 'duplicate' === $media_handling ) {
    184361                $new_thumb_id = self::duplicate_attachment( $thumb_id, $new_post_id );
    185362                if ( $new_thumb_id ) {
    186                     set_post_thumbnail( $new_post_id, $new_thumb_id );
     363                    $attachment_map[ (int) $thumb_id ] = (int) $new_thumb_id;
     364                    \set_post_thumbnail( $new_post_id, $new_thumb_id );
    187365                }
    188             }
    189         } elseif ( 'reference' === $media_handling ) {
    190             $thumb_id = get_post_thumbnail_id( $post_id );
    191             if ( $thumb_id ) {
    192                 set_post_thumbnail( $new_post_id, $thumb_id );
     366            } else {
     367                \set_post_thumbnail( $new_post_id, $thumb_id );
     368            }
     369        }
     370
     371        // Best-effort remap of attachment IDs in content/meta if we duplicated any.
     372        if ( ! empty( $attachment_map ) ) {
     373            $updated_content = self::remap_ids_in_content( \get_post_field( 'post_content', $new_post_id ), $attachment_map );
     374            if ( $updated_content !== null ) {
     375                \wp_update_post( [ 'ID' => $new_post_id, 'post_content' => $updated_content ] );
     376            }
     377            // WooCommerce gallery meta
     378            $wc_gallery = \get_post_meta( $new_post_id, '_product_image_gallery', true );
     379            if ( is_string( $wc_gallery ) && $wc_gallery !== '' ) {
     380                $ids = array_map( 'intval', array_map( 'trim', explode( ',', $wc_gallery ) ) );
     381                $ids = array_map( function( $i ) use ( $attachment_map ) { return $attachment_map[ $i ] ?? $i; }, $ids );
     382                \update_post_meta( $new_post_id, '_product_image_gallery', implode( ',', $ids ) );
    193383            }
    194384        }
     
    199389        // Store the last duplicated post ID in a transient.
    200390        if ( $new_post_id ) {
    201             set_transient( 'just_duplicate_last_post_id', $new_post_id, HOUR_IN_SECONDS );
     391            \set_transient( 'just_duplicate_last_post_id', $new_post_id, \HOUR_IN_SECONDS );
    202392        }
    203393
    204394        // Trigger the after duplicate hook.
    205         do_action( 'just_duplicate_after_duplicate', $post_id, $new_post_id );
     395    \do_action( 'just_duplicate_after_duplicate', $post_id, $new_post_id );
     396
     397        // Children copy (one level deep).
     398        if ( $copy_children ) {
     399            $children = \get_children( [ 'post_parent' => $post_id, 'post_type' => $post->post_type, 'post_status' => 'any' ] );
     400            foreach ( $children as $child ) {
     401                self::duplicate_post( (int) $child->ID, $args );
     402                // maintain hierarchy
     403                $created = self::$last_duplicated_post_id;
     404                if ( $created ) {
     405                    \wp_update_post( [ 'ID' => $created, 'post_parent' => $new_post_id ] );
     406                }
     407            }
     408        }
    206409
    207410        return $new_post_id;
     
    223426     */
    224427    public static function rollback_last_duplicate(): void {
    225         $last_post_id = get_transient( 'just_duplicate_last_post_id' );
     428        $last_post_id = \get_transient( 'just_duplicate_last_post_id' );
    226429
    227430        if ( $last_post_id ) {
    228             wp_delete_post( $last_post_id, true );
    229             delete_transient( 'just_duplicate_last_post_id' );
    230             add_action( 'admin_notices', function () {
    231                 echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__( 'The last duplicated post has been rolled back.', 'just-duplicate' ) . '</p></div>';
     431            \wp_delete_post( $last_post_id, true );
     432            \delete_transient( 'just_duplicate_last_post_id' );
     433            \add_action( 'admin_notices', function () {
     434                echo '<div class="notice notice-success is-dismissible"><p>' . \esc_html__( 'The last duplicated post has been rolled back.', 'just-duplicate' ) . '</p></div>';
    232435            } );
    233436        } else {
    234             add_action( 'admin_notices', function () {
    235                 echo '<div class="notice notice-error is-dismissible"><p>' . esc_html__( 'No duplicated post found to rollback.', 'just-duplicate' ) . '</p></div>';
     437            \add_action( 'admin_notices', function () {
     438                echo '<div class="notice notice-error is-dismissible"><p>' . \esc_html__( 'No duplicated post found to rollback.', 'just-duplicate' ) . '</p></div>';
    236439            } );
    237440        }
     
    242445     */
    243446    public static function add_rollback_notice(): void {
    244         $last_post_id = get_transient( 'just_duplicate_last_post_id' );
     447        $last_post_id = \get_transient( 'just_duplicate_last_post_id' );
    245448
    246449        if ( $last_post_id ) {
    247             $rollback_url = add_query_arg(
     450            $rollback_url = \add_query_arg(
    248451                [
    249452                    'action' => 'rollback_duplicate',
    250                     '_wpnonce' => wp_create_nonce( 'rollback_duplicate' ),
     453                    '_wpnonce' => \wp_create_nonce( 'rollback_duplicate' ),
    251454                ],
    252                 admin_url( 'admin.php' )
     455                \admin_url( 'admin.php' )
    253456            );
    254457
    255458            echo '<div class="notice notice-info is-dismissible"><p>' .
    256                 esc_html__( 'A post has been duplicated. ', 'just-duplicate' ) .
    257                 '<a href="' . esc_url( $rollback_url ) . '">' . esc_html__( 'Undo this action.', 'just-duplicate' ) . '</a>' .
     459                \esc_html__( 'A post has been duplicated. ', 'just-duplicate' ) .
     460                '<a href="' . \esc_url( $rollback_url ) . '">' . \esc_html__( 'Undo this action.', 'just-duplicate' ) . '</a>' .
    258461                '</p></div>';
    259462        }
     
    361564     * @return void
    362565     */
    363     private static function copy_post_taxonomies( int $old_post_id, int $new_post_id ): void {
    364         $post_type  = get_post_type( $old_post_id );
    365         $taxonomies = get_object_taxonomies( $post_type );
    366 
     566    private static function copy_post_taxonomies_filtered( int $old_post_id, int $new_post_id, array $tax_blacklist ): void {
     567        $post_type  = \get_post_type( $old_post_id );
     568        $taxonomies = \get_object_taxonomies( $post_type );
    367569        foreach ( $taxonomies as $taxonomy ) {
    368             $terms = wp_get_object_terms( $old_post_id, $taxonomy, [ 'fields' => 'ids' ] );
    369             if ( ! is_wp_error( $terms ) && ! empty( $terms ) ) {
    370                 wp_set_object_terms( $new_post_id, $terms, $taxonomy );
     570            if ( self::is_any_pattern_match( $taxonomy, $tax_blacklist ) ) {
     571                continue;
     572            }
     573            $terms = \wp_get_object_terms( $old_post_id, $taxonomy, [ 'fields' => 'ids' ] );
     574            if ( ! \is_wp_error( $terms ) && ! empty( $terms ) ) {
     575                \wp_set_object_terms( $new_post_id, $terms, $taxonomy );
    371576            }
    372577        }
     
    380585     * @return void
    381586     */
    382     private static function copy_custom_fields( int $old_post_id, int $new_post_id ): void {
    383         $meta_data = get_post_meta( $old_post_id );
    384 
     587    private static function copy_post_meta_filtered( int $old_post_id, int $new_post_id, array $meta_blacklist ): void {
     588        $meta_data = \get_post_meta( $old_post_id );
     589        $settings = self::get_settings();
    385590        foreach ( $meta_data as $key => $values ) {
     591            // preserve blacklist and core exclusions
     592            if ( in_array( $key, [ '_edit_lock', '_edit_last' ], true ) ) { continue; }
     593            if ( self::is_any_pattern_match( (string) $key, $meta_blacklist ) ) { continue; }
     594            // Respect ACF and SEO toggles if present
     595            if ( strpos( (string) $key, 'acf_' ) === 0 && empty( $settings['duplicate_acf_meta'] ) ) { continue; }
     596            if ( strpos( (string) $key, '_yoast_' ) === 0 && empty( $settings['duplicate_seo_meta'] ) ) { continue; }
     597
    386598            foreach ( $values as $value ) {
    387                 add_post_meta( $new_post_id, $key, maybe_unserialize( $value ) );
     599                \add_post_meta( $new_post_id, $key, \maybe_unserialize( $value ) );
    388600            }
    389601        }
     
    398610     */
    399611    private static function copy_custom_taxonomies( int $old_post_id, int $new_post_id ): void {
    400         $taxonomies = get_object_taxonomies( get_post_type( $old_post_id ) );
     612        $taxonomies = \get_object_taxonomies( \get_post_type( $old_post_id ) );
    401613
    402614        foreach ( $taxonomies as $taxonomy ) {
    403             $terms = wp_get_object_terms( $old_post_id, $taxonomy, [ 'fields' => 'slugs' ] );
    404             if ( ! is_wp_error( $terms ) && ! empty( $terms ) ) {
    405                 wp_set_object_terms( $new_post_id, $terms, $taxonomy );
     615            $terms = \wp_get_object_terms( $old_post_id, $taxonomy, [ 'fields' => 'slugs' ] );
     616            if ( ! \is_wp_error( $terms ) && ! empty( $terms ) ) {
     617                \wp_set_object_terms( $new_post_id, $terms, $taxonomy );
    406618            }
    407619        }
     
    418630    public static function preview_duplicate(): void {
    419631        // Verify the AJAX nonce.
    420         check_ajax_referer( 'preview_duplicate_post', '_wpnonce' );
     632    \check_ajax_referer( 'preview_duplicate_post', '_wpnonce' );
    421633
    422634        // Check permissions.
    423         if ( ! current_user_can( 'edit_posts' ) ) {
    424             wp_send_json_error( esc_html__( 'Permission denied.', 'just-duplicate' ) );
     635        if ( ! \current_user_can( 'edit_posts' ) ) {
     636            \wp_send_json_error( \esc_html__( 'Permission denied.', 'just-duplicate' ) );
    425637        }
    426638       
    427         $post_id = isset( $_GET['post'] ) ? absint( $_GET['post'] ) : 0;
    428 
    429         $post = get_post( $post_id );
     639        $post_id = isset( $_GET['post'] ) ? \absint( $_GET['post'] ) : 0;
     640
     641        $post = \get_post( $post_id );
    430642        if ( ! $post ) {
    431             wp_send_json_error( esc_html__( 'Post not found.', 'just-duplicate' ) );
     643            \wp_send_json_error( \esc_html__( 'Post not found.', 'just-duplicate' ) );
    432644        }
    433645
    434646        // Retrieve plugin settings.
    435         $settings = get_option( 'JUST_DUPLICATE_settings', [] );
     647    $settings = \get_option( 'JUST_DUPLICATE_settings', [] );
    436648        $default_prefix = isset( $settings['default_prefix'] ) ? (string) $settings['default_prefix'] : '';
    437649        $default_suffix = ( isset( $settings['default_suffix'] ) && '' !== $settings['default_suffix'] )
     
    442654        $preview_data = [
    443655            'post_id'      => $post->ID,
    444             'title'        => esc_html( $default_prefix . $post->post_title . $default_suffix ),
    445             'content'      => wp_kses_post( apply_filters( 'the_content', $post->post_content ) ),
    446             'excerpt'      => esc_html( $post->post_excerpt ),
    447             'author'       => esc_html( get_the_author_meta( 'display_name', $post->post_author ) ),
    448             'date'         => esc_html( $post->post_date ),
    449             'duplicate_url'=> esc_url( wp_nonce_url(
    450                 add_query_arg(
     656            'title'        => \esc_html( $default_prefix . $post->post_title . $default_suffix ),
     657            'content'      => \wp_kses_post( \apply_filters( 'the_content', $post->post_content ) ),
     658            'excerpt'      => \esc_html( $post->post_excerpt ),
     659            'author'       => \esc_html( \get_the_author_meta( 'display_name', $post->post_author ) ),
     660            'date'         => \esc_html( $post->post_date ),
     661            'duplicate_url'=> \esc_url( \wp_nonce_url(
     662                \add_query_arg(
    451663                    [
    452664                        'action' => 'duplicate_post',
    453665                        'post'   => $post->ID,
    454666                    ],
    455                     admin_url( 'admin.php' )
     667                    \admin_url( 'admin.php' )
    456668                ),
    457669                'duplicate_post_' . $post->ID
     
    459671        ];
    460672
    461         wp_send_json_success( $preview_data );
     673        \wp_send_json_success( $preview_data );
    462674    }
    463675
     
    468680     */
    469681    private static function get_selective_duplication_options(): array {
    470         $settings = get_option( 'JUST_DUPLICATE_settings', [] );
     682    $settings = \get_option( 'JUST_DUPLICATE_settings', [] );
    471683        return [
    472684            'duplicate_post_meta'      => ! empty( $settings['duplicate_post_meta'] ),
     
    488700     */
    489701    private static function copy_comments( int $old_post_id, int $new_post_id ): void {
    490         $comments = get_comments( [ 'post_id' => $old_post_id ] );
     702    $comments = \get_comments( [ 'post_id' => $old_post_id ] );
    491703        foreach ( $comments as $comment ) {
    492704            $new_comment = [
     
    500712                'user_id'              => $comment->user_id,
    501713            ];
    502             wp_insert_comment( $new_comment );
    503         }
     714            \wp_insert_comment( $new_comment );
     715        }
     716    }
     717
     718    /**
     719     * Handle bulk action fallback; other registration handled in Admin Settings.
     720     */
     721    public static function handle_bulk_action( string $redirect_url, string $action, array $post_ids ): string {
     722        if ( 'duplicate' !== $action && self::ACTION_CLONE !== $action ) {
     723            return $redirect_url;
     724        }
     725        $count = 0;
     726        foreach ( $post_ids as $pid ) {
     727            if ( \current_user_can( 'edit_post', (int) $pid ) && self::is_post_type_enabled( \get_post_type( (int) $pid ) ) && self::current_user_role_allowed() ) {
     728                $res = self::duplicate_post( (int) $pid, [] );
     729                if ( $res ) { $count++; }
     730            }
     731        }
     732        return \add_query_arg( 'bulk_duplicated', $count, $redirect_url );
     733    }
     734
     735    /**
     736     * Process Rewrite & Republish creation.
     737     */
     738    public static function process_rewrite(): void {
     739    if ( ! isset( $_GET['_wpnonce'], $_GET['post'] ) ) { \wp_die( \esc_html__( 'Missing required parameters.', 'just-duplicate' ) ); }
     740    $post_id = \absint( $_GET['post'] );
     741    $nonce = \sanitize_text_field( \wp_unslash( $_GET['_wpnonce'] ) );
     742    if ( ! \wp_verify_nonce( $nonce, self::NONCE_ACTION_PREFIX . $post_id ) ) { \wp_die( \esc_html__( 'Nonce verification failed.', 'just-duplicate' ) ); }
     743    $post = \get_post( $post_id );
     744    if ( ! $post || ! \current_user_can( 'edit_post', $post_id ) ) { \wp_die( \esc_html__( 'Permission denied.', 'just-duplicate' ) ); }
     745
     746        $working_id = self::duplicate_post( $post_id, [ 'open_editor' => true ] );
     747        if ( ! $working_id ) { wp_die( esc_html__( 'Failed to create working copy.', 'just-duplicate' ) ); }
     748        \update_post_meta( $working_id, '_jd_rewrite_of', $post_id );
     749        // Force draft
     750        \wp_update_post( [ 'ID' => $working_id, 'post_status' => 'draft' ] );
     751
     752        \wp_safe_redirect( \add_query_arg( [ 'post' => $working_id, 'action' => 'edit' ], \admin_url( 'post.php' ) ) );
     753        exit;
     754    }
     755
     756    /**
     757     * On publish, if this is a working copy, copy content/meta/terms back to original and trash the working copy.
     758     */
     759    public static function maybe_finalize_rewrite( $new_status, $old_status, $post ): void {
     760        if ( 'publish' !== $new_status ) { return; }
     761    $original_id = (int) \get_post_meta( $post->ID, '_jd_rewrite_of', true );
     762        if ( ! $original_id ) { return; }
     763
     764        // Permissions
     765    if ( ! \current_user_can( 'edit_post', $original_id ) ) { return; }
     766
     767        // Copy content/meta/terms into original while preserving ID/URL and status/date.
     768    $orig = \get_post( $original_id );
     769        if ( ! $orig ) { return; }
     770
     771        // Content and excerpt
     772        \wp_update_post( [
     773            'ID' => $original_id,
     774            'post_content' => $post->post_content,
     775            'post_excerpt' => $post->post_excerpt,
     776            'post_title'   => $post->post_title, // keep edited title
     777        ] );
     778
     779        // Taxonomies
     780        $taxes = \get_object_taxonomies( $orig->post_type );
     781        foreach ( $taxes as $tax ) {
     782            $terms = \wp_get_object_terms( $post->ID, $tax, [ 'fields' => 'ids' ] );
     783            if ( ! \is_wp_error( $terms ) ) {
     784                \wp_set_object_terms( $original_id, $terms, $tax );
     785            }
     786        }
     787
     788        // Meta: copy all except internals and our own markers
     789        $meta = \get_post_meta( $post->ID );
     790        foreach ( $meta as $k => $vals ) {
     791            if ( in_array( $k, [ '_edit_lock', '_edit_last', '_jd_rewrite_of', '_jd_original_post' ], true ) ) { continue; }
     792            \delete_post_meta( $original_id, $k );
     793            foreach ( $vals as $v ) { \add_post_meta( $original_id, $k, \maybe_unserialize( $v ) ); }
     794        }
     795
     796        // Store a revision of original (WP does automatically on update if revisions enabled)
     797
     798        // Trash working copy
     799    \wp_trash_post( $post->ID );
     800
     801        // Add notice
     802        \add_filter( 'redirect_post_location', function( $location ) use ( $original_id ) {
     803            return \add_query_arg( [ 'jd_rewrite_applied' => 1, 'post' => $original_id, 'action' => 'edit' ], $location );
     804        } );
     805    }
     806
     807    /**
     808     * Admin notices renderer for duplication events and bulk counts.
     809     */
     810    public static function render_admin_notices(): void {
     811        if ( isset( $_GET['jd_duplicated'] ) && isset( $_GET['jd_new'] ) ) {
     812            $new_id = absint( $_GET['jd_new'] );
     813            echo '<div class="notice notice-success is-dismissible"><p>' . \esc_html__( 'Post duplicated.', 'just-duplicate' ) . ' ' . sprintf( '<a href="%s">%s</a>', \esc_url( \get_edit_post_link( $new_id, '' ) ), \esc_html__( 'Edit new copy', 'just-duplicate' ) ) . '</p></div>';
     814        }
     815        if ( isset( $_GET['bulk_duplicated'] ) ) {
     816            $count = absint( $_GET['bulk_duplicated'] );
     817            echo '<div class="notice notice-success is-dismissible"><p>' . sprintf( \esc_html__( 'Duplicated %d items.', 'just-duplicate' ), $count ) . '</p></div>';
     818        }
     819        if ( isset( $_GET['jd_rewrite_applied'] ) ) {
     820            echo '<div class="notice notice-success is-dismissible"><p>' . \esc_html__( 'Rewrite changes applied to original.', 'just-duplicate' ) . '</p></div>';
     821        }
     822    }
     823
     824    /* ========================= Helper methods ========================= */
     825
     826    private static function get_settings(): array {
     827        if ( self::$settings !== null ) { return self::$settings; }
     828        $defaults = [
     829            'enabled_post_types'     => array_keys( \get_post_types( [ 'show_ui' => true ], 'names' ) ),
     830            'enabled_roles'          => [ 'administrator', 'editor' ],
     831            'show_row_actions'       => true,
     832            'show_submitbox'         => true,
     833            'show_admin_bar'         => false,
     834            'show_bulk'              => true,
     835            'copy_author'            => false,
     836            'copy_date'              => false,
     837            'copy_password'          => true,
     838            'copy_excerpt'           => true,
     839            'copy_template'          => true,
     840            'copy_post_format'       => true,
     841            'copy_sticky'            => false,
     842            'copy_menu_order'        => true,
     843            'increase_menu_order_by' => 0,
     844            'copy_children'          => false,
     845            'media_attachment_handling' => 'reference',
     846            'meta_blacklist'         => '_edit_lock,_edit_last',
     847            'tax_blacklist'          => '',
     848            'redirect_after'         => 'list',
     849        ];
     850        $settings = \get_option( 'JUST_DUPLICATE_settings', [] );
     851        self::$settings = \wp_parse_args( $settings, $defaults );
     852        return self::$settings;
     853    }
     854
     855    private static function is_post_type_enabled( string $post_type ): bool {
     856        $settings = self::get_settings();
     857        $enabled = is_array( $settings['enabled_post_types'] ?? null ) ? $settings['enabled_post_types'] : [];
     858        return in_array( $post_type, $enabled, true );
     859    }
     860
     861    private static function current_user_role_allowed(): bool {
     862        $settings = self::get_settings();
     863        $allowed = is_array( $settings['enabled_roles'] ?? null ) ? $settings['enabled_roles'] : [ 'administrator', 'editor' ];
     864    $user = \wp_get_current_user();
     865        if ( empty( $user->roles ) ) { return false; }
     866        foreach ( $user->roles as $role ) {
     867            if ( in_array( $role, $allowed, true ) ) { return true; }
     868        }
     869        return false;
     870    }
     871
     872    private static function parse_csv_patterns( string $csv ): array {
     873        $csv = trim( $csv );
     874        if ( $csv === '' ) { return []; }
     875        $parts = array_map( 'trim', explode( ',', $csv ) );
     876        return array_filter( $parts, static function( $v ) { return $v !== ''; } );
     877    }
     878
     879    private static function is_any_pattern_match( string $value, array $patterns ): bool {
     880        foreach ( $patterns as $pattern ) {
     881            if ( self::wildcard_match( $pattern, $value ) ) { return true; }
     882        }
     883        return false;
     884    }
     885
     886    private static function wildcard_match( string $pattern, string $string ): bool {
     887        // Convert simple * wildcard to regex.
     888        $regex = '/^' . str_replace( ['\\*','\\?'], ['.*','.' ], preg_quote( $pattern, '/' ) ) . '$/i';
     889        return (bool) preg_match( $regex, $string );
     890    }
     891
     892    /**
     893     * Best-effort remap of attachment IDs in content (shortcodes and common block attributes).
     894     */
     895    private static function remap_ids_in_content( string $content, array $map ): ?string {
     896        if ( empty( $map ) || $content === '' ) { return $content; }
     897        // Gallery shortcode ids="1,2"
     898        $content = preg_replace_callback( '/(\bid\s*=\s*")(\d+)(")/i', function( $m ) use ( $map ) {
     899            $id = (int) $m[2];
     900            return $m[1] . ( $map[ $id ] ?? $id ) . $m[3];
     901        }, $content );
     902        $content = preg_replace_callback( '/(\bids\s*=\s*")([0-9,\s]+)(")/i', function( $m ) use ( $map ) {
     903            $ids = array_map( 'intval', array_map( 'trim', explode( ',', $m[2] ) ) );
     904            $ids = array_map( function( $i ) use ( $map ) { return $map[ $i ] ?? $i; }, $ids );
     905            return $m[1] . implode( ',', $ids ) . $m[3];
     906        }, $content );
     907        // JSON-like "id": 123
     908        $content = preg_replace_callback( '/("id"\s*:\s*)(\d+)/i', function( $m ) use ( $map ) {
     909            $id = (int) $m[2];
     910            return $m[1] . ( $map[ $id ] ?? $id );
     911        }, $content );
     912        return $content;
    504913    }
    505914}
  • just-duplicate/trunk/includes/class-duplicate-logger.php

    r3252339 r3384189  
    33
    44namespace Just_Duplicate;
     5
     6// Lightweight shims for WordPress global functions to satisfy static analysis in namespaced code.
     7function get_option( $option, $default = false ) { return \function_exists('\\get_option') ? \call_user_func_array('\\get_option', \func_get_args()) : $default; }
     8function update_option( $option, $value, $autoload = null ) { return \function_exists('\\update_option') ? \call_user_func_array('\\update_option', \func_get_args()) : false; }
     9function delete_option( $option ) { return \function_exists('\\delete_option') ? \call_user_func_array('\\delete_option', \func_get_args()) : false; }
     10function get_current_user_id() { return \function_exists('\\get_current_user_id') ? \call_user_func_array('\\get_current_user_id', \func_get_args()) : 0; }
     11function current_time( $type, $gmt = 0 ) { return \function_exists('\\current_time') ? \call_user_func_array('\\current_time', \func_get_args()) : date('Y-m-d H:i:s'); }
    512
    613if ( ! defined( 'ABSPATH' ) ) {
     
    4148        return get_option( 'JUST_DUPLICATE_log', [] );
    4249    }
     50
     51    /**
     52     * Clear the duplication log stored in wp_options.
     53     *
     54     * @return bool True on success, false on failure.
     55     */
     56    public static function clear(): bool {
     57        return (bool) delete_option( 'JUST_DUPLICATE_log' );
     58    }
    4359}
  • just-duplicate/trunk/just-duplicate.php

    r3297655 r3384189  
    44 * Plugin URI: https://wordpress.org/plugins/just-duplicate
    55 * Description: A powerful plugin to duplicate pages, posts, custom post types, WooCommerce products, menus, and more. Supports batch duplication, customizable options, and compatibility with major plugins and themes.
    6  * Version: 1.0.4
     6 * Version: 1.0.6
    77 * Requires at least: 5.0
    8  * Tested up to: 6.7
     8 * Tested up to: 6.8
    99 * Requires PHP: 7.0
    1010 * Author: Just There
     
    2121
    2222// Define plugin constants.
    23 define( 'JUST_DUPLICATE_VERSION', '1.0.4' );
     23define( 'JUST_DUPLICATE_VERSION', '1.0.6' );
    2424define( 'JUST_DUPLICATE_PATH', plugin_dir_path( __FILE__ ) );
    2525define( 'JUST_DUPLICATE_URL', plugin_dir_url( __FILE__ ) );
Note: See TracChangeset for help on using the changeset viewer.