Plugin Directory

Changeset 3803816


Ignore:
Timestamp:
11/15/2026 08:18:28 PM (less than one hour ago)
Author:
netmagic
Message:

Release 1.5.14

Location:
netmagic-category-manager-for-woocommerce/trunk
Files:
3 added
6 deleted
5 edited

Legend:

Unmodified
Added
Removed
  • netmagic-category-manager-for-woocommerce/trunk/admin/class-catpro-admin.php

    r3769691 r3803816  
    1818        add_action( 'admin_notices', [ $this, 'show_notices' ] );
    1919        add_action( 'admin_init', [ $this, 'register_settings' ] );
     20        add_filter( 'plugin_action_links_' . CATPRO_BASENAME, [ $this, 'plugin_action_links' ] );
    2021    }
    2122
    … …  
    4748            [ $this, 'render_settings_page' ]
    4849        );
    49 
    50         // C5 Pro: Ghost Category Detector
    51         add_submenu_page(
    52             'edit.php?post_type=product',
    53             __( 'Ghost Category Detector', 'netmagic-category-manager-for-woocommerce' ),
    54             '👻 ' . __( 'Ghost Detector', 'netmagic-category-manager-for-woocommerce' ),
    55             'manage_woocommerce',
    56             'nmcm-ghost',
    57             [ NMCM_Ghost_Detector::get_instance(), 'render_page' ]
    58         );
    59 
    60         // C9 Pro: Bulk Name Mapper
    61         add_submenu_page(
    62             'edit.php?post_type=product',
    63             __( 'Bulk Name Mapper', 'netmagic-category-manager-for-woocommerce' ),
    64             '🔤 ' . __( 'Name Mapper', 'netmagic-category-manager-for-woocommerce' ),
    65             'manage_woocommerce',
    66             'nmcm-name-mapper',
    67             [ NMCM_Name_Mapper::get_instance(), 'render_page' ]
    68         );
    69 
    70         // C11 Pro: Audit Log
    71         add_submenu_page(
    72             'edit.php?post_type=product',
    73             __( 'Category Manager Audit Log', 'netmagic-category-manager-for-woocommerce' ),
    74             '📋 ' . __( 'Audit Log', 'netmagic-category-manager-for-woocommerce' ),
    75             'manage_woocommerce',
    76             'nmcm-audit-log',
    77             [ NMCM_Audit_Log::get_instance(), 'render_page' ]
    78         );
    7950    }
    8051
    … …  
    8455            'product_page_nmcm-import',
    8556            'product_page_nmcm-settings',
    86             'product_page_nmcm-ghost',
    87             'product_page_nmcm-name-mapper',
    88             'product_page_nmcm-audit-log',
    89             'post.php',
    90             'post-new.php',
    9157        ];
    9258
    93         if ( ! in_array( $hook, $allowed_hooks ) ) return;
    94 
    95         if ( in_array( $hook, [ 'post.php', 'post-new.php' ] ) ) {
    96             global $post;
    97             if ( ! $post || $post->post_type !== 'product' ) return;
    98         }
     59        if ( ! in_array( $hook, $allowed_hooks, true ) ) return;
    9960
    10061        wp_enqueue_style(
    … …  
    11374        );
    11475
    115         // SheetJS: load on import and name-mapper pages; the manager page loads it via enqueue_manager_scripts()
    116         if ( in_array( $hook, [ 'product_page_nmcm-import', 'product_page_nmcm-name-mapper' ] ) ) {
     76        // SheetJS: only the import page reads Excel files.
     77        if ( 'product_page_nmcm-import' === $hook ) {
    11778            wp_enqueue_script(
    11879                'nmcm-xlsx',
    … …  
    12384            );
    12485        }
    125 
    126         // C2 Pro: Media Library for bulk image assignment
    127         if ( $hook === 'product_page_nmcm-manager' ) {
    128             wp_enqueue_media();
    129         }
    130 
    131         wp_localize_script( 'nmcm-admin', 'catpro', [
    132             'ajax_url'     => admin_url( 'admin-ajax.php' ),
    133             'nonce'        => wp_create_nonce( 'nmcm_nonce' ),
    134             'max_depth'    => NMCM_Helpers::get_max_import_depth(),
    135             'feed_sources' => NMCM_Feed_Source::get_configured_sources(),  // C7 Pro: [ slug => label ]
    136             'strings'      => [
    137                 'confirm_delete'          => __( 'Are you sure you want to delete this category?', 'netmagic-category-manager-for-woocommerce' ),
    138                 'confirm_hide'            => __( 'Hide this category from customers?', 'netmagic-category-manager-for-woocommerce' ),
    139                 'products_affected'       => __( 'products will be affected.', 'netmagic-category-manager-for-woocommerce' ),
    140                 'move_to'                 => __( 'Move products to:', 'netmagic-category-manager-for-woocommerce' ),
    141                 'processing'              => __( 'Processing...', 'netmagic-category-manager-for-woocommerce' ),
    142                 'done'                    => __( 'Done!', 'netmagic-category-manager-for-woocommerce' ),
    143                 'error'                   => __( 'An error occurred. Please try again.', 'netmagic-category-manager-for-woocommerce' ),
    144                 // Audit log page strings
    145                 'audit_clear_confirm'     => __( 'Clear ALL audit log entries? This cannot be undone.', 'netmagic-category-manager-for-woocommerce' ),
    146                 'audit_error'             => __( 'Error.', 'netmagic-category-manager-for-woocommerce' ),
    147                 // Ghost detector page strings
    148                 'ghost_confirm_delete'    => __( 'Permanently delete this ghost category? This cannot be undone.', 'netmagic-category-manager-for-woocommerce' ),
    149                 'ghost_bulk_action_req'   => __( 'Please select a bulk action first.', 'netmagic-category-manager-for-woocommerce' ),
    150                 'ghost_select_cat'        => __( 'Please select at least one category.', 'netmagic-category-manager-for-woocommerce' ),
    151                 'ghost_confirm_bulk_del'  => __( 'Permanently delete selected ghost categories? This cannot be undone.', 'netmagic-category-manager-for-woocommerce' ),
    152                 'ghost_processing'        => __( 'Processing…', 'netmagic-category-manager-for-woocommerce' ),
    153                 'ghost_hidden_label'      => __( 'Hidden', 'netmagic-category-manager-for-woocommerce' ),
    154                 'ghost_done'              => __( 'Done!', 'netmagic-category-manager-for-woocommerce' ),
    155                 'ghost_error'             => __( 'An error occurred.', 'netmagic-category-manager-for-woocommerce' ),
    156                 'ghost_req_failed'        => __( 'Request failed. Please try again.', 'netmagic-category-manager-for-woocommerce' ),
    157                 // Name mapper page strings
    158                 'nm_prefix_placeholder'   => __( 'e.g. HAFFNER - ', 'netmagic-category-manager-for-woocommerce' ),
    159                 'nm_saving'               => __( 'Saving…', 'netmagic-category-manager-for-woocommerce' ),
    160                 'nm_saved'                => __( 'Saved!', 'netmagic-category-manager-for-woocommerce' ),
    161                 'nm_save_error'           => __( 'Error saving.', 'netmagic-category-manager-for-woocommerce' ),
    162                 'nm_error'                => __( 'Error.', 'netmagic-category-manager-for-woocommerce' ),
    163                 'nm_choose_file'          => __( 'Please choose a file first.', 'netmagic-category-manager-for-woocommerce' ),
    164                 'nm_reading'              => __( 'Reading…', 'netmagic-category-manager-for-woocommerce' ),
    165                 'nm_sheetjs_missing'      => __( 'SheetJS not loaded. Try CSV instead.', 'netmagic-category-manager-for-woocommerce' ),
    166                 'nm_parse_error'          => __( 'Parse error: ', 'netmagic-category-manager-for-woocommerce' ),
    167                 'nm_rows_loaded'          => __( 'rows loaded. Save the map to keep them.', 'netmagic-category-manager-for-woocommerce' ),
    168                 'nm_loading_preview'      => __( 'Loading preview…', 'netmagic-category-manager-for-woocommerce' ),
    169                 'nm_no_changes'           => __( 'No changes would be made — no category names match the configured rules.', 'netmagic-category-manager-for-woocommerce' ),
    170                 'nm_cats_renamed'         => __( 'categories would be renamed.', 'netmagic-category-manager-for-woocommerce' ),
    171                 'nm_no_cats_selected'     => __( 'No categories selected.', 'netmagic-category-manager-for-woocommerce' ),
    172                 'nm_confirm_apply'        => __( 'Apply renames to selected categories? This cannot be undone (unless you have the Undo Log feature).', 'netmagic-category-manager-for-woocommerce' ),
    173                 'nm_applying'             => __( 'Applying…', 'netmagic-category-manager-for-woocommerce' ),
    174                 'nm_all_done'             => __( 'All done — no more changes pending.', 'netmagic-category-manager-for-woocommerce' ),
    175                 // Feed source settings strings
    176                 'fs_placeholder'          => __( 'Display name (e.g. SellGate)', 'netmagic-category-manager-for-woocommerce' ),
    177                 // Bulk import ZIP strings
    178                 'zip_uploading'           => __( 'Uploading…', 'netmagic-category-manager-for-woocommerce' ),
    179                 'zip_uploading_label'     => __( 'Uploading ZIP…', 'netmagic-category-manager-for-woocommerce' ),
    180                 'zip_assigned'            => __( 'assigned', 'netmagic-category-manager-for-woocommerce' ),
    181                 'zip_skipped'             => __( 'skipped', 'netmagic-category-manager-for-woocommerce' ),
    182                 'zip_errors'              => __( 'errors', 'netmagic-category-manager-for-woocommerce' ),
    183                 'zip_unknown_error'       => __( 'Unknown error', 'netmagic-category-manager-for-woocommerce' ),
    184                 'zip_done'                => __( 'Done!', 'netmagic-category-manager-for-woocommerce' ),
    185                 'zip_server_error'        => __( 'Server error. Please try again.', 'netmagic-category-manager-for-woocommerce' ),
    186                 'zip_start_import'        => __( 'Start image import', 'netmagic-category-manager-for-woocommerce' ),
    187             ],
    188         ] );
    189 
    190         // Feed Sources settings page: inline UI script (no PHP string interpolation needed)
    191         if ( $hook === 'product_page_nmcm-settings' ) {
    192             wp_add_inline_script( 'nmcm-admin', '
    193 (function($){
    194     function toSlug(str) {
    195         return str.toLowerCase().replace(/[^a-z0-9]+/g,"_").replace(/^_|_$/g,"").substring(0,32);
    196     }
    197     $("#catpro-feed-source-add").on("click",function(){
    198         var ph = (typeof catpro !== "undefined" && catpro.strings) ? catpro.strings.fs_placeholder : "";
    199         var row=$("<div class=\"catpro-feed-source-row\" style=\"display:flex;gap:8px;align-items:center;margin-bottom:6px;\">"+
    200             "<input type=\"text\" class=\"catpro-feed-source-label\" placeholder=\""+ph+"\" style=\"width:200px;\">"+
    201             "<code class=\"catpro-feed-source-slug\" style=\"color:#666;font-size:12px;\"></code>"+
    202             "<button type=\"button\" class=\"button button-small catpro-feed-source-remove\" style=\"color:#d63638;border-color:#d63638;\">✕</button>"+
    203             "</div>");
    204         $("#catpro-feed-sources-list").append(row);
    205         row.find(".catpro-feed-source-label").focus();
    206     });
    207     $(document).on("input",".catpro-feed-source-label",function(){
    208         var slug=toSlug($(this).val());
    209         $(this).closest(".catpro-feed-source-row").find(".catpro-feed-source-slug").text(slug);
    210     });
    211     $(document).on("click",".catpro-feed-source-remove",function(){
    212         $(this).closest(".catpro-feed-source-row").remove();
    213     });
    214     $("#catpro-feed-sources-form").on("submit",function(){
    215         var sources=[];
    216         $(".catpro-feed-source-row").each(function(){
    217             var label=$(this).find(".catpro-feed-source-label").val().trim();
    218             var slug=toSlug(label);
    219             if(label&&slug){sources.push({slug:slug,label:label});}
    220         });
    221         $("#catpro-feed-sources-json").val(JSON.stringify(sources));
    222     });
    223 })(jQuery);
    224             ' );
    225         }
    226 
    227         // Audit Log page: clear button script
    228         if ( $hook === 'product_page_nmcm-audit-log' ) {
    229             wp_add_inline_script( 'nmcm-admin', '
    230 (function($){
    231     $("#catpro-audit-clear").on("click",function(){
    232         var s = (typeof catpro!=="undefined"&&catpro.strings)?catpro.strings:{};
    233         if(!confirm(s.audit_clear_confirm||"Clear ALL audit log entries? This cannot be undone."))return;
    234         var btn=$(this).prop("disabled",true);
    235         $.post(catpro.ajax_url,{action:"nmcm_audit_log_clear",nonce:catpro.nonce},function(r){
    236             if(r.success){location.reload();}
    237             else{btn.prop("disabled",false);alert(r.data.message||(s.audit_error||"Error."));}
    238         });
    239     });
    240 })(jQuery);
    241             ' );
    242         }
    243 
    244         // Ghost Detector page: bulk action script
    245         if ( $hook === 'product_page_nmcm-ghost' ) {
    246             wp_add_inline_script( 'nmcm-admin', '
    247 (function($){
    248     var s=(typeof catpro!=="undefined"&&catpro.strings)?catpro.strings:{};
    249     $("#catpro-ghost-check-all").on("change",function(){
    250         $(".catpro-ghost-cb").prop("checked",$(this).is(":checked"));
    251     });
    252     $(document).on("click",".catpro-ghost-hide-one",function(){
    253         var btn=$(this),id=btn.data("id");
    254         runAction("hide",[id],btn);
    255     });
    256     $(document).on("click",".catpro-ghost-delete-one",function(){
    257         if(!confirm(s.ghost_confirm_delete||"Permanently delete this ghost category? This cannot be undone."))return;
    258         var btn=$(this),id=btn.data("id");
    259         runAction("delete",[id],btn);
    260     });
    261     $("#catpro-ghost-bulk-apply").on("click",function(){
    262         var action=$("#catpro-ghost-bulk-select").val();
    263         if(!action){alert(s.ghost_bulk_action_req||"Please select a bulk action first.");return;}
    264         var ids=[];
    265         $(".catpro-ghost-cb:checked").each(function(){ids.push($(this).val());});
    266         if(!ids.length){alert(s.ghost_select_cat||"Please select at least one category.");return;}
    267         if(action==="delete"&&!confirm(s.ghost_confirm_bulk_del||"Permanently delete selected ghost categories? This cannot be undone."))return;
    268         runAction(action,ids,$("#catpro-ghost-bulk-apply"));
    269     });
    270     function runAction(action,ids,$btn){
    271         $btn.prop("disabled",true);
    272         $("#catpro-ghost-result").text(s.ghost_processing||"Processing…");
    273         $.post(catpro.ajax_url,{action:"nmcm_ghost_bulk_action",nonce:catpro.nonce,ghost_action:action,ids:ids},function(r){
    274             if(r.success){
    275                 ids.forEach(function(id){
    276                     if(action==="delete"){
    277                         $("tr[data-id=\""+id+"\"]").fadeOut(300,function(){$(this).remove();});
    278                     }else if(action==="hide"){
    279                         var row=$("tr[data-id=\""+id+"\"]");
    280                         row.find("td:eq(4)").html("<span class=\"catpro-status catpro-status-hidden\">🙈 "+(s.ghost_hidden_label||"Hidden")+"</span>");
    281                         row.find(".catpro-ghost-hide-one").remove();
    282                     }
    283                 });
    284                 $("#catpro-ghost-result").css("color","#00a32a").text(r.data.message||(s.ghost_done||"Done!"));
    285                 if($("#catpro-ghost-table tbody tr:visible").length===0){location.reload();}
    286             }else{
    287                 $("#catpro-ghost-result").css("color","#d63638").text(r.data.message||(s.ghost_error||"An error occurred."));
    288             }
    289             $btn.prop("disabled",false);
    290         }).fail(function(){
    291             $("#catpro-ghost-result").css("color","#d63638").text(s.ghost_req_failed||"Request failed. Please try again.");
    292             $btn.prop("disabled",false);
    293         });
    294     }
    295 })(jQuery);
    296             ' );
    297         }
    298 
    299         // Name Mapper page: all UI scripts
    300         if ( $hook === 'product_page_nmcm-name-mapper' ) {
    301             wp_add_inline_script( 'nmcm-admin', '
    302 (function($){
    303     var s=(typeof catpro!=="undefined"&&catpro.strings)?catpro.strings:{};
    304     // Prefix section
    305     $("#catpro-prefix-add").on("click",function(){
    306         var ph=s.nm_prefix_placeholder||"e.g. HAFFNER - ";
    307         var row=$("<div class=\"catpro-prefix-row\" style=\"display:flex;gap:6px;align-items:center;margin-bottom:6px;\">"+
    308             "<input type=\"text\" class=\"catpro-prefix-value regular-text\" placeholder=\""+ph+"\" style=\"font-family:monospace;font-size:13px;\">"+
    309             "<button type=\"button\" class=\"button button-small catpro-prefix-remove\" style=\"color:#d63638;border-color:#d63638;\">✕</button>"+
    310             "</div>");
    311         $("#catpro-prefix-list").append(row);
    312         row.find("input").focus();
    313     });
    314     $(document).on("click",".catpro-prefix-remove",function(){
    315         $(this).closest(".catpro-prefix-row").remove();
    316     });
    317     $("#catpro-prefix-save").on("click",function(){
    318         var btn=$(this),prefixes=[];
    319         $(".catpro-prefix-value").each(function(){var v=$(this).val();if(v!=="")prefixes.push(v);});
    320         btn.prop("disabled",true);
    321         $("#catpro-prefix-save-msg").text(s.nm_saving||"Saving…");
    322         $.post(catpro.ajax_url,{action:"nmcm_name_mapper_upload",nonce:catpro.nonce,save_type:"prefixes",data:JSON.stringify(prefixes)},function(r){
    323             btn.prop("disabled",false);
    324             if(r.success){$("#catpro-prefix-save-msg").css("color","#00a32a").text("✔ "+(s.nm_saved||"Saved!"));}
    325             else{$("#catpro-prefix-save-msg").css("color","#d63638").text(s.nm_save_error||"Error saving.");}
    326             setTimeout(function(){$("#catpro-prefix-save-msg").text("");},3000);
    327         });
    328     });
    329     // Map table section
    330     $("#catpro-map-row-add").on("click",function(){
    331         var row=$("<tr class=\"catpro-map-row\">"+
    332             "<td style=\"padding:2px 4px;\"><input type=\"text\" class=\"catpro-map-from\" style=\"width:100%;font-size:12px;\"></td>"+
    333             "<td style=\"padding:2px 4px;\"><input type=\"text\" class=\"catpro-map-to\" style=\"width:100%;font-size:12px;\"></td>"+
    334             "<td><button type=\"button\" class=\"button button-small catpro-map-row-remove\" style=\"color:#d63638;border-color:#d63638;padding:0 6px;\">✕</button></td>"+
    335             "</tr>");
    336         $("#catpro-map-tbody").append(row);
    337         row.find(".catpro-map-from").focus();
    338     });
    339     $(document).on("click",".catpro-map-row-remove",function(){
    340         $(this).closest(".catpro-map-row").remove();
    341     });
    342     function collectMap(){
    343         var map=[];
    344         $(".catpro-map-row").each(function(){
    345             var from=$(this).find(".catpro-map-from").val();
    346             var to=$(this).find(".catpro-map-to").val();
    347             if(from!==""&&to!=="")map.push({from:from,to:to});
    348         });
    349         return map;
    350     }
    351     $("#catpro-map-save").on("click",function(){
    352         var btn=$(this);
    353         btn.prop("disabled",true);
    354         $("#catpro-map-save-msg").text(s.nm_saving||"Saving…");
    355         $.post(catpro.ajax_url,{action:"nmcm_name_mapper_upload",nonce:catpro.nonce,save_type:"map",data:JSON.stringify(collectMap())},function(r){
    356             btn.prop("disabled",false);
    357             if(r.success){$("#catpro-map-save-msg").css("color","#00a32a").text("✔ "+(s.nm_saved||"Saved!"));}
    358             else{$("#catpro-map-save-msg").css("color","#d63638").text(s.nm_error||"Error.");}
    359             setTimeout(function(){$("#catpro-map-save-msg").text("");},3000);
    360         });
    361     });
    362     // CSV/XLSX upload
    363     function escapeHtml(str){return String(str).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");}
    364     $("#catpro-map-upload-btn").on("click",function(){
    365         var file=$("#catpro-map-upload")[0].files[0];
    366         if(!file){alert(s.nm_choose_file||"Please choose a file first.");return;}
    367         var ext=file.name.split(".").pop().toLowerCase();
    368         $("#catpro-map-upload-msg").text(s.nm_reading||"Reading…");
    369         var reader=new FileReader();
    370         reader.onload=function(e){
    371             var rows=[];
    372             try{
    373                 if(ext==="csv"){
    374                     var lines=e.target.result.split(/\r?\n/);
    375                     for(var i=1;i<lines.length;i++){
    376                         var cols=lines[i].split(",");
    377                         if(cols.length>=2){
    378                             var from=cols[0].replace(/^"|"$/g,"").trim();
    379                             var to=cols[1].replace(/^"|"$/g,"").trim();
    380                             if(from&&to)rows.push({from:from,to:to});
    381                         }
    382                     }
    383                 }else if(ext==="xlsx"){
    384                     if(typeof XLSX==="undefined"){
    385                         $("#catpro-map-upload-msg").css("color","#d63638").text(s.nm_sheetjs_missing||"SheetJS not loaded. Try CSV instead.");
    386                         return;
    387                     }
    388                     var wb=XLSX.read(new Uint8Array(e.target.result),{type:"array"});
    389                     var ws=wb.Sheets[wb.SheetNames[0]];
    390                     var data=XLSX.utils.sheet_to_json(ws,{header:1,defval:""});
    391                     for(var j=1;j<data.length;j++){
    392                         var from2=String(data[j][0]||"").trim();
    393                         var to2=String(data[j][1]||"").trim();
    394                         if(from2&&to2)rows.push({from:from2,to:to2});
    395                     }
    396                 }
    397             }catch(err){
    398                 $("#catpro-map-upload-msg").css("color","#d63638").text((s.nm_parse_error||"Parse error: ")+err.message);
    399                 return;
    400             }
    401             $("#catpro-map-tbody").empty();
    402             rows.forEach(function(r){
    403                 $("#catpro-map-tbody").append(
    404                     "<tr class=\"catpro-map-row\">"+
    405                     "<td style=\"padding:2px 4px;\"><input type=\"text\" class=\"catpro-map-from\" value=\""+escapeHtml(r.from)+"\" style=\"width:100%;font-size:12px;\"></td>"+
    406                     "<td style=\"padding:2px 4px;\"><input type=\"text\" class=\"catpro-map-to\" value=\""+escapeHtml(r.to)+"\" style=\"width:100%;font-size:12px;\"></td>"+
    407                     "<td><button type=\"button\" class=\"button button-small catpro-map-row-remove\" style=\"color:#d63638;border-color:#d63638;padding:0 6px;\">✕</button></td>"+
    408                     "</tr>"
    409                 );
    410             });
    411             $("#catpro-map-upload-msg").css("color","#00a32a").text(rows.length+" "+(s.nm_rows_loaded||"rows loaded. Save the map to keep them."));
    412         };
    413         if(ext==="xlsx"){reader.readAsArrayBuffer(file);}else{reader.readAsText(file);}
    414     });
    415     // Preview
    416     $("#catpro-map-preview").on("click",function(){
    417         var btn=$(this);
    418         btn.prop("disabled",true);
    419         $("#catpro-map-apply-msg").text(s.nm_loading_preview||"Loading preview…");
    420         $.post(catpro.ajax_url,{action:"nmcm_name_mapper_preview",nonce:catpro.nonce},function(r){
    421             btn.prop("disabled",false);
    422             if(!r.success){
    423                 $("#catpro-map-apply-msg").css("color","#d63638").text(r.data.message||(s.nm_error||"Error."));
    424                 return;
    425             }
    426             $("#catpro-map-apply-msg").text("");
    427             var changes=r.data.changes;
    428             if(!changes||changes.length===0){
    429                 $("#catpro-map-preview-area").show();
    430                 $("#catpro-map-preview-summary").html("<em>"+(s.nm_no_changes||"No changes would be made.")+"</em>");
    431                 $("#catpro-map-preview-tbody").empty();
    432                 $("#catpro-map-apply").hide();
    433                 return;
    434             }
    435             $("#catpro-map-preview-summary").html("<strong>"+changes.length+"</strong> "+(s.nm_cats_renamed||"categories would be renamed."));
    436             var tbody=$("#catpro-map-preview-tbody").empty();
    437             changes.forEach(function(ch){
    438                 tbody.append(
    439                     "<tr><td>"+escapeHtml(ch.old_name)+"</td>"+
    440                     "<td><strong>"+escapeHtml(ch.new_name)+"</strong></td>"+
    441                     "<td><span class=\"catpro-badge\" style=\"background:#e5f2ff;color:#0073aa;\">"+escapeHtml(ch.rule)+"</span></td>"+
    442                     "<td style=\"text-align:center;\"><input type=\"checkbox\" class=\"catpro-apply-cb\" value=\""+ch.id+"\" checked></td></tr>"
    443                 );
    444             });
    445             $("#catpro-map-preview-area").show();
    446             $("#catpro-map-apply").show();
    447         });
    448     });
    449     // Apply
    450     $("#catpro-map-apply").on("click",function(){
    451         var btn=$(this),checkedIds=[];
    452         $(".catpro-apply-cb:checked").each(function(){checkedIds.push($(this).val());});
    453         if(checkedIds.length===0){alert(s.nm_no_cats_selected||"No categories selected.");return;}
    454         if(!confirm(s.nm_confirm_apply||"Apply renames to selected categories? This cannot be undone."))return;
    455         btn.prop("disabled",true);
    456         $("#catpro-map-apply-msg").css("color","#333").text(s.nm_applying||"Applying…");
    457         $.post(catpro.ajax_url,{action:"nmcm_name_mapper_apply",nonce:catpro.nonce,ids:checkedIds},function(r){
    458             btn.prop("disabled",false);
    459             if(r.success){
    460                 $("#catpro-map-apply-msg").css("color","#00a32a").text("✔ "+r.data.message);
    461                 checkedIds.forEach(function(id){
    462                     $(".catpro-apply-cb[value=\""+id+"\"]").closest("tr").fadeOut(300,function(){$(this).remove();});
    463                 });
    464                 if($("#catpro-map-preview-tbody tr:visible").length===0){
    465                     $("#catpro-map-apply").hide();
    466                     $("#catpro-map-preview-summary").html("<em>"+(s.nm_all_done||"All done.")+"</em>");
    467                 }
    468             }else{
    469                 $("#catpro-map-apply-msg").css("color","#d63638").text(r.data.message||(s.nm_error||"Error."));
    470             }
    471         });
    472     });
    473 })(jQuery);
    474             ' );
    475         }
    47686    }
    47787
    47888    public function register_settings() {
    479         register_setting( 'nmcm_settings', 'catpro_auto_hide_empty', [
    480             'type'              => 'boolean',
    481             'default'           => false,
    482             'sanitize_callback' => 'rest_sanitize_boolean',
    483         ] );
    484         register_setting( 'nmcm_settings', 'catpro_max_import_depth', [
    485             'type'              => 'integer',
    486             'default'           => 4,
    487             'sanitize_callback' => function( $value ) {
    488                 return max( 2, min( 10, absint( $value ) ) );
    489             },
    490         ] );
    49189        register_setting( 'nmcm_settings', 'catpro_show_product_count', [
    49290            'type'              => 'boolean',
    … …  
    49492            'sanitize_callback' => 'rest_sanitize_boolean',
    49593        ] );
    496 
    497         // C7 Pro: Feed source names (stored as JSON)
    498         register_setting( 'nmcm_settings', NMCM_Feed_Source::OPTION_SOURCES, [
    499             'type'              => 'string',
    500             'default'           => '',
    501             'sanitize_callback' => [ $this, 'sanitize_feed_sources' ],
    502         ] );
    50394    }
    50495
    505     /**
    506      * C7 Pro: Sanitize the feed-sources JSON option.
    507      * Input: JSON array of {slug, label} objects (from a textarea or JS-built hidden field).
    508      * Returns cleaned JSON, or '' on failure.
    509      */
    510     public function sanitize_feed_sources( $value ) {
    511         $decoded = json_decode( stripslashes( (string) $value ), true );
    512         if ( ! is_array( $decoded ) ) return '';
    513         $clean = [];
    514         foreach ( $decoded as $entry ) {
    515             $slug  = isset( $entry['slug'] )  ? sanitize_key( $entry['slug'] ) : '';
    516             $label = isset( $entry['label'] ) ? sanitize_text_field( $entry['label'] ) : '';
    517             if ( $slug !== '' && $label !== '' ) {
    518                 $clean[] = [ 'slug' => $slug, 'label' => $label ];
    519             }
    520         }
    521         return wp_json_encode( $clean );
     96    public function plugin_action_links( $links ) {
     97        $settings = '<a href="' . esc_url( admin_url( 'edit.php?post_type=product&page=nmcm-manager' ) ) . '">'
     98            . esc_html__( 'Category Manager', 'netmagic-category-manager-for-woocommerce' ) . '</a>';
     99        array_unshift( $links, $settings );
     100        return $links;
    522101    }
    523102
    … …  
    563142                                </td>
    564143                            </tr>
    565                             <tr>
    566                                 <th><?php esc_html_e( 'Auto-hide empty categories', 'netmagic-category-manager-for-woocommerce' ); ?></th>
    567                                 <td>
    568                                     <label>
    569                                         <input type="checkbox" name="catpro_auto_hide_empty" value="1"
    570                                             <?php checked( get_option( 'catpro_auto_hide_empty', false ) ); ?>>
    571                                         <?php esc_html_e( 'Automatically hide categories with 0 products from customers', 'netmagic-category-manager-for-woocommerce' ); ?>
    572                                     </label>
    573                                 </td>
    574                             </tr>
    575                             <tr>
    576                                 <th><?php esc_html_e( 'Max import depth', 'netmagic-category-manager-for-woocommerce' ); ?></th>
    577                                 <td>
    578                                     <select name="catpro_max_import_depth">
    579                                         <?php for ( $i = 2; $i <= 10; $i++ ) : ?>
    580                                             <option value="<?php echo esc_attr( (string) $i ); ?>" <?php selected( get_option( 'catpro_max_import_depth', 4 ), $i ); ?>>
    581                                                 <?php echo esc_html( (string) $i ); ?> <?php esc_html_e( 'levels', 'netmagic-category-manager-for-woocommerce' ); ?>
    582                                             </option>
    583                                         <?php endfor; ?>
    584                                     </select>
    585                                     <p class="description"><?php esc_html_e( 'Maximum depth for Excel category import.', 'netmagic-category-manager-for-woocommerce' ); ?></p>
    586                                 </td>
    587                             </tr>
    588144                        </table>
    589145                        <?php submit_button( __( 'Save Changes', 'netmagic-category-manager-for-woocommerce' ) ); ?>
    … …  
    591147                </div>
    592148
    593                 <!-- C7 Pro: Feed Source configuration -->
     149                <!-- Pro edition -->
    594150                <div class="catpro-card">
    595                     <h2>📡 <?php esc_html_e( 'Feed Sources (Pro)', 'netmagic-category-manager-for-woocommerce' ); ?></h2>
    596                     <p class="description" style="margin-bottom:12px;">
    597                         <?php esc_html_e( 'Define the external feed sources that can be assigned to categories. A category tagged with a feed source will be automatically struct-locked (cannot be renamed, deleted or moved).', 'netmagic-category-manager-for-woocommerce' ); ?>
     151                    <h2><?php esc_html_e( 'Pro edition', 'netmagic-category-manager-for-woocommerce' ); ?></h2>
     152                    <p style="max-width:600px;line-height:1.7;">
     153                        <?php esc_html_e( 'The separate Pro edition adds drag & drop sorting, inline editing, Excel export and re-import, import up to 10 levels deep, bulk actions, category duplication, safe delete with product move, auto-hide for empty categories, campaign profiles and more.', 'netmagic-category-manager-for-woocommerce' ); ?>
    598154                    </p>
    599 
    600                     <div id="catpro-feed-sources-list">
    601                         <?php
    602                         $existing_sources = NMCM_Feed_Source::get_configured_sources();
    603                         foreach ( $existing_sources as $slug => $label ) : ?>
    604                         <div class="catpro-feed-source-row" style="display:flex;gap:8px;align-items:center;margin-bottom:6px;">
    605                             <input type="text" class="catpro-feed-source-label"
    606                                    value="<?php echo esc_attr( $label ); ?>"
    607                                    placeholder="<?php esc_attr_e( 'Display name (e.g. SellGate)', 'netmagic-category-manager-for-woocommerce' ); ?>"
    608                                    style="width:200px;">
    609                             <code class="catpro-feed-source-slug" style="color:#666;font-size:12px;"><?php echo esc_html( $slug ); ?></code>
    610                             <button type="button" class="button button-small catpro-feed-source-remove" style="color:#d63638;border-color:#d63638;">✕</button>
    611                         </div>
    612                         <?php endforeach; ?>
    613                     </div>
    614 
    615                     <button type="button" class="button" id="catpro-feed-source-add" style="margin-top:8px;">
    616                         + <?php esc_html_e( 'Add feed source', 'netmagic-category-manager-for-woocommerce' ); ?>
    617                     </button>
    618 
    619                     <form method="post" action="options.php" id="catpro-feed-sources-form" style="margin-top:16px;">
    620                         <?php settings_fields( 'nmcm_settings' ); ?>
    621                         <input type="hidden" name="<?php echo esc_attr( NMCM_Feed_Source::OPTION_SOURCES ); ?>" id="catpro-feed-sources-json" value="<?php echo esc_attr( get_option( NMCM_Feed_Source::OPTION_SOURCES, '' ) ); ?>">
    622                         <?php submit_button( __( 'Save Feed Sources', 'netmagic-category-manager-for-woocommerce' ), 'secondary', 'catpro-feed-sources-submit' ); ?>
    623                     </form>
    624 
     155                    <p>
     156                        <a href="<?php echo esc_url( NMCM_UPGRADE_URL ); ?>" class="button" target="_blank" rel="noopener noreferrer">
     157                            <?php esc_html_e( 'See the Pro edition', 'netmagic-category-manager-for-woocommerce' ); ?>
     158                        </a>
     159                    </p>
    625160                </div>
    626161
  • netmagic-category-manager-for-woocommerce/trunk/admin/class-catpro-bulk-import.php

    r3769691 r3803816  
    1414
    1515    private function __construct() {
    16         add_action( 'wp_ajax_nmcm_preview_import',  [ $this, 'ajax_preview_import' ] );
    17         add_action( 'wp_ajax_nmcm_process_import',  [ $this, 'ajax_process_import' ] );
    18         add_action( 'wp_ajax_nmcm_export_all_categories', [ $this, 'ajax_export_all_categories' ] );
    19         // C2b Pro: ZIP batch image import
    20         add_action( 'wp_ajax_nmcm_zip_image_import', [ $this, 'ajax_zip_image_import' ] );
     16        add_action( 'wp_ajax_nmcm_preview_import', [ $this, 'ajax_preview_import' ] );
     17        add_action( 'wp_ajax_nmcm_process_import', [ $this, 'ajax_process_import' ] );
    2118    }
    2219
    … …  
    2926                <?php
    3027                /* translators: %d: maximum category import depth */
    31                 echo esc_html( sprintf( __( 'Upload an Excel (.xlsx) file to create or update categories. Maximum %d levels deep.', 'netmagic-category-manager-for-woocommerce' ), (int) $max_depth ) );
     28                echo esc_html( sprintf( __( 'Upload an Excel (.xlsx) file to create categories. Maximum %d levels deep.', 'netmagic-category-manager-for-woocommerce' ), (int) $max_depth ) );
    3229                ?>
    3330            </p>
    3431
    35             <div class="catpro-card" style="margin-bottom:16px;padding:12px 20px;">
    36                 <label style="font-weight:600;"><?php esc_html_e( 'Import depth:', 'netmagic-category-manager-for-woocommerce' ); ?></label>
    37                 <select id="catpro-depth-selector" style="margin-left:10px;">
    38                     <?php for ( $i = 2; $i <= $max_depth; $i++ ) : ?>
    39                     <option value="<?php echo (int) $i; ?>" <?php selected( $max_depth, $i ); ?>><?php echo esc_html( (string) (int) $i ); ?> <?php esc_html_e( 'levels', 'netmagic-category-manager-for-woocommerce' ); ?></option>
    40                     <?php endfor; ?>
    41                 </select>
    42                 <span style="color:#666;margin-left:10px;font-size:13px;">
    43                     <?php esc_html_e( 'How many columns to read from the file.', 'netmagic-category-manager-for-woocommerce' ); ?>
    44                 </span>
    45             </div>
    46 
    4732            <!-- FORMAT INFO -->
    4833            <div class="catpro-card" style="margin-bottom:16px;background:#f0f6fc;border-left:4px solid #2271b1;">
    49                 <h3 style="margin-top:0;">&#x1F4CB; <?php esc_html_e( 'Accepted file formats', 'netmagic-category-manager-for-woocommerce' ); ?></h3>
    50 
    51                 <div style="display:grid;grid-template-columns:1fr 1fr;gap:24px;">
    52                     <div>
    53                         <p style="font-weight:700;margin-bottom:6px;"><?php esc_html_e( 'Exported file re-import (update/edit):', 'netmagic-category-manager-for-woocommerce' ); ?></p>
    54                         <table class="widefat striped" style="font-size:12px;">
    55                             <thead><tr><th>ID</th><th><?php esc_html_e( 'Category name', 'netmagic-category-manager-for-woocommerce' ); ?></th><th><?php esc_html_e( 'Parent category', 'netmagic-category-manager-for-woocommerce' ); ?></th><th>Slug</th></tr></thead>
    56                             <tbody>
    57                                 <tr><td>162</td><td>Glass Screen Protector 5D</td><td>SCREEN PROTECTORS</td><td>glass-5d</td></tr>
    58                                 <tr><td style="color:#888;font-style:italic;"><?php esc_html_e( '(empty)', 'netmagic-category-manager-for-woocommerce' ); ?></td><td><?php esc_html_e( 'New category', 'netmagic-category-manager-for-woocommerce' ); ?></td><td>APPLE</td><td></td></tr>
    59                             </tbody>
    60                         </table>
    61                         <p style="font-size:12px;color:#555;margin-top:6px;"><?php esc_html_e( 'If ID exists → update. If no ID → create.', 'netmagic-category-manager-for-woocommerce' ); ?></p>
    62                     </div>
    63 
    64                     <div>
    65                         <p style="font-weight:700;margin-bottom:6px;"><?php esc_html_e( 'Hierarchical (for bulk creation):', 'netmagic-category-manager-for-woocommerce' ); ?></p>
    66                         <table class="widefat striped" style="font-size:12px;">
    67                             <thead><tr><th><?php esc_html_e( 'Level 1', 'netmagic-category-manager-for-woocommerce' ); ?></th><th><?php esc_html_e( 'Level 2', 'netmagic-category-manager-for-woocommerce' ); ?></th><th><?php esc_html_e( 'Level 3', 'netmagic-category-manager-for-woocommerce' ); ?></th></tr></thead>
    68                             <tbody>
    69                                 <tr><td>APPLE</td><td>iPhone 16</td><td>iPhone 16 Pro</td></tr>
    70                                 <tr><td>SAMSUNG</td><td>Galaxy S25</td><td></td></tr>
    71                             </tbody>
    72                         </table>
    73                         <p style="font-size:12px;color:#555;margin-top:6px;"><?php esc_html_e( 'First row contains "Level 1", "Level 2" headers.', 'netmagic-category-manager-for-woocommerce' ); ?></p>
    74                     </div>
    75                 </div>
     34                <h3 style="margin-top:0;">&#x1F4CB; <?php esc_html_e( 'Accepted file format', 'netmagic-category-manager-for-woocommerce' ); ?></h3>
     35                <table class="widefat striped" style="font-size:12px;max-width:480px;">
     36                    <thead><tr><th><?php esc_html_e( 'Level 1', 'netmagic-category-manager-for-woocommerce' ); ?></th><th><?php esc_html_e( 'Level 2', 'netmagic-category-manager-for-woocommerce' ); ?></th></tr></thead>
     37                    <tbody>
     38                        <tr><td>APPLE</td><td>iPhone 16</td></tr>
     39                        <tr><td>SAMSUNG</td><td>Galaxy S25</td></tr>
     40                    </tbody>
     41                </table>
     42                <p style="font-size:12px;color:#555;margin-top:6px;"><?php esc_html_e( 'First row contains "Level 1", "Level 2" headers.', 'netmagic-category-manager-for-woocommerce' ); ?></p>
     43                <p style="font-size:12px;color:#555;margin-top:6px;">
     44                    <?php esc_html_e( 'Deeper category trees, Excel export and re-import of edited categories are part of the Pro edition.', 'netmagic-category-manager-for-woocommerce' ); ?>
     45                    <a href="<?php echo esc_url( NMCM_UPGRADE_URL ); ?>" target="_blank" rel="noopener noreferrer"><?php esc_html_e( 'See the Pro edition', 'netmagic-category-manager-for-woocommerce' ); ?></a>
     46                </p>
    7647            </div>
    7748
    … …  
    7950            <div class="catpro-card">
    8051                <h2><?php esc_html_e( 'Step 1: Download template', 'netmagic-category-manager-for-woocommerce' ); ?></h2>
    81                 <p><?php esc_html_e( 'Choose which template format to download:', 'netmagic-category-manager-for-woocommerce' ); ?></p>
    82                 <div style="display:flex;gap:12px;flex-wrap:wrap;">
    83                     <button type="button" id="catpro-download-flat-template" class="button">
    84                         &#x1F4E5; <?php esc_html_e( 'Flat format template', 'netmagic-category-manager-for-woocommerce' ); ?>
    85                     </button>
    86                     <button type="button" id="catpro-download-hier-template" class="button">
    87                         &#x1F4E5; <?php esc_html_e( 'Hierarchical template', 'netmagic-category-manager-for-woocommerce' ); ?>
    88                     </button>
    89                     <button type="button" id="catpro-download-existing" class="button button-primary">
    90                         &#x1F4E4; <?php esc_html_e( 'Download existing categories for editing', 'netmagic-category-manager-for-woocommerce' ); ?>
    91                     </button>
    92                 </div>
    93                 <p style="font-size:12px;color:#666;margin-top:8px;">
    94                     <?php esc_html_e( '"Download existing categories" exports all categories in an editable format you can re-import.', 'netmagic-category-manager-for-woocommerce' ); ?>
    95                 </p>
     52                <button type="button" id="catpro-download-hier-template" class="button">
     53                    &#x1F4E5; <?php esc_html_e( 'Download template', 'netmagic-category-manager-for-woocommerce' ); ?>
     54                </button>
    9655            </div>
    9756
    … …  
    150109                </div>
    151110            </div>
    152 
    153             <!-- C2b Pro: ZIP batch image import -->
    154             <div class="catpro-card" style="margin-top:24px;border-left:4px solid #2271b1;">
    155                 <h2 style="margin-top:0;">&#x1F5BC;&#xFE0F; <?php esc_html_e( 'Batch image import from ZIP (Pro)', 'netmagic-category-manager-for-woocommerce' ); ?></h2>
    156                 <p class="description">
    157                     <?php esc_html_e( 'Upload a ZIP file containing category images. Each image filename must match the category slug (e.g. iphone-16.jpg → category slug "iphone-16"). Images are added to the Media Library and assigned as category thumbnails.', 'netmagic-category-manager-for-woocommerce' ); ?>
    158                 </p>
    159                 <div style="display:flex;gap:12px;align-items:flex-start;flex-wrap:wrap;margin-top:12px;">
    160                     <div>
    161                         <label for="catpro-zip-file" class="button">
    162                             &#x1F4C2; <?php esc_html_e( 'Choose ZIP file', 'netmagic-category-manager-for-woocommerce' ); ?>
    163                         </label>
    164                         <input type="file" id="catpro-zip-file" accept=".zip" style="display:none;">
    165                     </div>
    166                     <button type="button" id="catpro-zip-upload-btn" class="button button-primary" disabled>
    167                         &#x25B6;&#xFE0F; <?php esc_html_e( 'Start image import', 'netmagic-category-manager-for-woocommerce' ); ?>
    168                     </button>
    169                 </div>
    170                 <div id="catpro-zip-file-info" style="display:none;margin-top:8px;" class="catpro-file-info">
    171                     <span id="catpro-zip-file-name"></span>
    172                     <button type="button" id="catpro-zip-clear" class="button button-small">&#x2715;</button>
    173                 </div>
    174                 <div id="catpro-zip-progress" style="display:none;margin-top:12px;">
    175                     <div class="catpro-progress-bar">
    176                         <div class="catpro-progress-fill" id="catpro-zip-progress-fill"></div>
    177                     </div>
    178                     <p id="catpro-zip-progress-text" style="font-size:13px;color:#50575e;"></p>
    179                 </div>
    180                 <div id="catpro-zip-results" style="display:none;margin-top:12px;padding:12px;border-radius:4px;font-size:13px;"></div>
    181             </div>
    182111        </div>
    183112        <?php
    184113        $import_vars = wp_json_encode( [
    185             'max_depth'   => (int) $max_depth,
    186             'ajax_url'    => admin_url( 'admin-ajax.php' ),
    187             'nonce'       => wp_create_nonce( 'nmcm_nonce' ),
    188             'strings'     => [
     114            'max_depth' => (int) $max_depth,
     115            'ajax_url'  => admin_url( 'admin-ajax.php' ),
     116            'nonce'     => wp_create_nonce( 'nmcm_nonce' ),
     117            'strings'   => [
    189118                'wait_xlsx'            => __( 'Please wait for the library to finish loading.', 'netmagic-category-manager-for-woocommerce' ),
    190119                'invalid_file'         => __( 'Please upload an Excel (.xlsx, .xls) or CSV file.', 'netmagic-category-manager-for-woocommerce' ),
    … …  
    192121                'empty_file'           => __( 'The file is empty or has no data rows.', 'netmagic-category-manager-for-woocommerce' ),
    193122                'no_data_after_header' => __( 'No data found after the header row.', 'netmagic-category-manager-for-woocommerce' ),
     123                'flat_not_supported'   => __( 'This file uses the ID-based re-import format, which this edition cannot read. Please use the Level 1 / Level 2 column format.', 'netmagic-category-manager-for-woocommerce' ),
    194124                'checking_existing'    => __( 'Checking existing categories...', 'netmagic-category-manager-for-woocommerce' ),
     125                'starting'             => __( 'Starting...', 'netmagic-category-manager-for-woocommerce' ),
    195126                'created'              => __( 'Created', 'netmagic-category-manager-for-woocommerce' ),
    196                 'updated'              => __( 'Updated', 'netmagic-category-manager-for-woocommerce' ),
    197127                'skipped'              => __( 'Skipped', 'netmagic-category-manager-for-woocommerce' ),
    198128                'errors_label'         => __( 'Errors', 'netmagic-category-manager-for-woocommerce' ),
    … …  
    200130                'unique_cats'          => __( 'unique categories to create', 'netmagic-category-manager-for-woocommerce' ),
    201131                'already_exist'        => __( 'categories already exist and will be skipped', 'netmagic-category-manager-for-woocommerce' ),
    202                 'to_update'            => __( 'categories pending update', 'netmagic-category-manager-for-woocommerce' ),
    203                 'to_create'            => __( 'new categories to create', 'netmagic-category-manager-for-woocommerce' ),
    204                 'errors_found'         => __( 'invalid rows', 'netmagic-category-manager-for-woocommerce' ),
     132                'import_failed'        => __( 'The import stopped because of a server error. Please try again.', 'netmagic-category-manager-for-woocommerce' ),
    205133                'done'                 => __( 'Done!', 'netmagic-category-manager-for-woocommerce' ),
    206134                'level'                => __( 'Level', 'netmagic-category-manager-for-woocommerce' ),
    … …  
    208136        ] );
    209137        wp_add_inline_script( 'nmcm-admin', 'var nmcmImport = ' . $import_vars . ';' );
    210 
    211         // C2b: ZIP image import JS — no PHP string interpolation, strings from catpro.strings
    212         wp_add_inline_script( 'nmcm-admin', '
    213 (function($){
    214     "use strict";
    215     var zipFileObj=null;
    216     var s=(typeof catpro!=="undefined"&&catpro.strings)?catpro.strings:{};
    217     $("#catpro-zip-file").on("change",function(){
    218         var file=this.files[0];
    219         if(!file)return;
    220         zipFileObj=file;
    221         $("#catpro-zip-file-name").text(file.name+" ("+Math.round(file.size/1024)+" KB)");
    222         $("#catpro-zip-file-info").show();
    223         $("#catpro-zip-upload-btn").prop("disabled",false);
    224         $("#catpro-zip-results").hide();
    225     });
    226     $("label[for=\'catpro-zip-file\']").on("click",function(){$("#catpro-zip-file").click();});
    227     $("#catpro-zip-clear").on("click",function(){
    228         zipFileObj=null;
    229         $("#catpro-zip-file").val("");
    230         $("#catpro-zip-file-info").hide();
    231         $("#catpro-zip-upload-btn").prop("disabled",true);
    232         $("#catpro-zip-results").hide();
    233     });
    234     $("#catpro-zip-upload-btn").on("click",function(){
    235         if(!zipFileObj)return;
    236         var $btn=$(this),$prog=$("#catpro-zip-progress"),$fill=$("#catpro-zip-progress-fill"),$text=$("#catpro-zip-progress-text"),$results=$("#catpro-zip-results");
    237         $btn.prop("disabled",true).text(s.zip_uploading||"Uploading…");
    238         $prog.show();$fill.css("width","30%");
    239         $text.text(s.zip_uploading_label||"Uploading ZIP…");
    240         $results.hide();
    241         var fd=new FormData();
    242         fd.append("action","nmcm_zip_image_import");
    243         fd.append("nonce",nmcmImport.nonce);
    244         fd.append("zip_file",zipFileObj);
    245         $.ajax({url:nmcmImport.ajax_url,type:"POST",data:fd,contentType:false,processData:false,
    246             xhr:function(){var xhr=new window.XMLHttpRequest();xhr.upload.addEventListener("progress",function(e){if(e.lengthComputable){$fill.css("width",Math.round((e.loaded/e.total)*60)+10+"%");}},false);return xhr;},
    247             success:function(r){
    248                 $fill.css("width","100%");
    249                 if(r.success){
    250                     var d=r.data;
    251                     var summary="✅ "+d.matched+" "+(s.zip_assigned||"assigned")+" | ⏭️ "+d.skipped+" "+(s.zip_skipped||"skipped")+" | ❌ "+d.errors+" "+(s.zip_errors||"errors");
    252                     var logHtml="<strong>"+summary+"</strong>";
    253                     if(d.log&&d.log.length){logHtml+="<ul style=\"margin:8px 0 0;padding-left:16px;font-size:12px;\">";d.log.forEach(function(line){logHtml+="<li>"+line+"</li>";});logHtml+="</ul>";}
    254                     $results.css({background:"#edfaef",border:"1px solid #2e7d32"}).html(logHtml).show();
    255                     $text.text(s.zip_done||"Done!");
    256                 }else{
    257                     $results.css({background:"#fce8e8",border:"1px solid #d63638"}).html("❌ "+(r.data||(s.zip_unknown_error||"Unknown error"))).show();
    258                     $text.text("");
    259                 }
    260                 $btn.prop("disabled",false).text("▶️ "+(s.zip_start_import||"Start image import"));
    261             },
    262             error:function(){
    263                 $results.css({background:"#fce8e8",border:"1px solid #d63638"}).html("❌ "+(s.zip_server_error||"Server error. Please try again.")).show();
    264                 $text.text("");
    265                 $btn.prop("disabled",false).text("▶️ "+(s.zip_start_import||"Start image import"));
    266             }
    267         });
    268     });
    269 })(jQuery);
    270         ' );
    271138    }
    272139
    … …  
    276143    public function ajax_preview_import() {
    277144        check_ajax_referer( 'nmcm_nonce', 'nonce' );
    278         if ( ! current_user_can( 'manage_woocommerce' ) ) wp_die();
     145        if ( ! current_user_can( 'manage_woocommerce' ) ) {
     146            wp_send_json_error( __( 'You are not allowed to do this.', 'netmagic-category-manager-for-woocommerce' ), 403 );
     147        }
    279148
    280149        $raw  = isset( $_POST['tree'] ) ? wp_unslash( $_POST['tree'] ) : '[]'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
    … …  
    284153        $existing = [];
    285154        foreach ( $tree as $item ) {
     155            if ( ! is_array( $item ) ) continue;
    286156            $name = NMCM_Helpers::sanitize_term_name( $item['name'] ?? '' );
    287157            if ( ! $name ) continue;
    … …  
    300170
    301171    /**
    302      * AJAX: Export all categories
    303      */
    304     public function ajax_export_all_categories() {
    305         check_ajax_referer( 'nmcm_nonce', 'nonce' );
    306         if ( ! current_user_can( 'manage_woocommerce' ) ) wp_die();
    307 
    308         $terms = NMCM_Helpers::get_all_categories();
    309         $cats  = [];
    310 
    311         foreach ( $terms as $term ) {
    312             $parent_name = '';
    313             if ( $term->parent ) {
    314                 $parent = get_term( $term->parent, 'product_cat' );
    315                 if ( $parent && ! is_wp_error( $parent ) ) {
    316                     $parent_name = $parent->name;
    317                 }
    318             }
    319             $cats[] = [
    320                 'id'          => $term->term_id,
    321                 'name'        => $term->name,
    322                 'parent_name' => $parent_name,
    323                 'slug'        => $term->slug,
    324                 'count'       => $term->count,
    325                 'hidden'      => NMCM_Helpers::is_category_hidden( $term->term_id ),
    326             ];
    327         }
    328 
    329         wp_send_json_success( [ 'categories' => $cats ] );
    330     }
    331 
    332     /**
    333      * AJAX: Process import batch
     172     * AJAX: Process import batch (Level 1 / Level 2 column format).
     173     * Existing categories are skipped, never modified.
    334174     */
    335175    public function ajax_process_import() {
    336176        check_ajax_referer( 'nmcm_nonce', 'nonce' );
    337         if ( ! current_user_can( 'manage_woocommerce' ) ) wp_die();
    338 
    339         $mode      = sanitize_text_field( wp_unslash( $_POST['mode'] ?? 'hierarchical' ) );
     177        if ( ! current_user_can( 'manage_woocommerce' ) ) {
     178            wp_send_json_error( __( 'You are not allowed to do this.', 'netmagic-category-manager-for-woocommerce' ), 403 );
     179        }
     180
    340181        $raw_batch = isset( $_POST['batch'] ) ? wp_unslash( $_POST['batch'] ) : '[]'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
    341182        $batch     = json_decode( sanitize_textarea_field( $raw_batch ), true );
    … …  
    344185        $max_depth = NMCM_Helpers::get_max_import_depth();
    345186        $created   = 0;
    346         $updated   = 0;
    347187        $skipped   = 0;
    348188        $errors    = 0;
    349189
    350         if ( $mode === 'flat' ) {
    351             foreach ( $batch as $item ) {
    352                 $item_mode = sanitize_text_field( $item['mode'] ?? 'create' );
    353                 $data      = $item['data'] ?? [];
    354                 $name      = NMCM_Helpers::sanitize_term_name( $data['name'] ?? '' );
    355                 $parent    = sanitize_text_field( $data['parent'] ?? '' );
    356                 $slug      = sanitize_title( $data['slug'] ?? '' );
    357                 $hidden    = ! empty( $data['hidden'] );
    358 
    359                 if ( ! $name ) continue;
    360 
    361                 $parent_id = 0;
    362                 if ( $parent ) {
    363                     $parent_term = get_term_by( 'name', $parent, 'product_cat' );
    364                     if ( $parent_term ) $parent_id = $parent_term->term_id;
    365                 }
    366 
    367                 if ( $item_mode === 'update' ) {
    368                     $term_id = (int) ( $data['id'] ?? 0 );
    369                     if ( ! $term_id ) { $errors++; continue; }
    370 
    371                     $args = [ 'name' => $name, 'parent' => $parent_id ];
    372                     if ( $slug ) $args['slug'] = $slug;
    373 
    374                     $result = wp_update_term( $term_id, 'product_cat', $args );
    375                     if ( is_wp_error( $result ) ) {
    376                         $errors++;
    377                     } else {
    378                         if ( $hidden ) {
    379                             NMCM_Helpers::hide_category( $term_id );
    380                         } else {
    381                             NMCM_Helpers::show_category( $term_id );
    382                         }
    383                         $updated++;
    384                     }
    385                 } else {
    386                     $exists = term_exists( $name, 'product_cat', $parent_id );
    387                     if ( $exists ) { $skipped++; continue; }
    388 
    389                     $args = [ 'parent' => $parent_id ];
    390                     if ( $slug ) $args['slug'] = $slug;
    391 
    392                     $result = wp_insert_term( $name, 'product_cat', $args );
    393                     if ( is_wp_error( $result ) ) {
    394                         $errors++;
    395                     } else {
    396                         if ( $hidden ) NMCM_Helpers::hide_category( $result['term_id'] );
    397                         $created++;
    398                     }
    399                 }
    400             }
    401         } else {
    402             // Hierarchical format
    403             foreach ( $batch as $item ) {
    404                 $name   = NMCM_Helpers::sanitize_term_name( $item['name'] ?? '' );
    405                 $parent = sanitize_text_field( $item['parent'] ?? '' );
    406                 $depth  = (int) ( $item['depth'] ?? 0 );
    407 
    408                 if ( ! $name || $depth >= $max_depth ) continue;
    409 
    410                 $parent_id = 0;
    411                 if ( $parent ) {
    412                     $parent_term = get_term_by( 'name', $parent, 'product_cat' );
    413                     if ( $parent_term ) $parent_id = $parent_term->term_id;
    414                 }
    415 
    416                 if ( term_exists( $name, 'product_cat', $parent_id ) ) {
    417                     $skipped++; continue;
    418                 }
    419 
    420                 $result = wp_insert_term( $name, 'product_cat', [ 'parent' => $parent_id ] );
    421                 if ( ! is_wp_error( $result ) ) $created++;
    422                 else $errors++;
    423             }
    424         }
    425 
     190        foreach ( $batch as $item ) {
     191            if ( ! is_array( $item ) ) continue;
     192            $name   = NMCM_Helpers::sanitize_term_name( $item['name'] ?? '' );
     193            $parent = sanitize_text_field( $item['parent'] ?? '' );
     194            $depth  = (int) ( $item['depth'] ?? 0 );
     195
     196            if ( ! $name || $depth < 0 || $depth >= $max_depth ) continue;
     197
     198            $parent_id = 0;
     199            if ( $parent ) {
     200                $parent_term = get_term_by( 'name', $parent, 'product_cat' );
     201                if ( $parent_term ) $parent_id = $parent_term->term_id;
     202            }
     203
     204            if ( term_exists( $name, 'product_cat', $parent_id ) ) {
     205                $skipped++;
     206                continue;
     207            }
     208
     209            $result = wp_insert_term( $name, 'product_cat', [ 'parent' => $parent_id ] );
     210            if ( ! is_wp_error( $result ) ) $created++;
     211            else $errors++;
     212        }
     213
     214        if ( $created > 0 && class_exists( 'NMCM_Review_Notice' ) ) {
     215            NMCM_Review_Notice::mark_milestone();
     216        }
    426217        wp_send_json_success( [
    427218            'created' => $created,
    428             'updated' => $updated,
    429219            'skipped' => $skipped,
    430220            'errors'  => $errors,
    431             'message' => "Created: {$created}, Updated: {$updated}, Skipped: {$skipped}",
     221            'message' => sprintf(
     222                /* translators: 1: number of created categories, 2: number of skipped categories */
     223                __( 'Created: %1$d, Skipped: %2$d', 'netmagic-category-manager-for-woocommerce' ),
     224                $created,
     225                $skipped
     226            ),
    432227        ] );
    433228    }
    434 
    435     /**
    436      * C2b Pro: AJAX — ZIP batch image import
    437      * ZIP file contains images; filename (without extension) must match category slug.
    438      * Each matched image is sideloaded into Media Library → thumbnail_id set on term.
    439      */
    440     public function ajax_zip_image_import() {
    441         check_ajax_referer( 'nmcm_nonce', 'nonce' );
    442         if ( ! current_user_can( 'manage_woocommerce' ) ) wp_die();
    443 
    444         if ( empty( $_FILES['zip_file'] ) || $_FILES['zip_file']['error'] !== UPLOAD_ERR_OK ) {
    445             wp_send_json_error( __( 'No file uploaded or upload error.', 'netmagic-category-manager-for-woocommerce' ) );
    446         }
    447 
    448         $file     = $_FILES['zip_file']; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce already checked above
    449         $tmp_path = $file['tmp_name'];
    450         $mime     = mime_content_type( $tmp_path );
    451 
    452         // Validate: must be a ZIP
    453         $allowed_mimes = [ 'application/zip', 'application/x-zip-compressed', 'application/x-zip', 'multipart/x-zip' ];
    454         if ( ! in_array( $mime, $allowed_mimes, true ) ) {
    455             wp_send_json_error( __( 'Invalid file type. Please upload a ZIP file.', 'netmagic-category-manager-for-woocommerce' ) );
    456         }
    457 
    458         // Check ZipArchive is available
    459         if ( ! class_exists( 'ZipArchive' ) ) {
    460             wp_send_json_error( __( 'ZipArchive PHP extension is not available on this server.', 'netmagic-category-manager-for-woocommerce' ) );
    461         }
    462 
    463         $zip = new ZipArchive();
    464         if ( $zip->open( $tmp_path ) !== true ) {
    465             wp_send_json_error( __( 'Could not open ZIP file.', 'netmagic-category-manager-for-woocommerce' ) );
    466         }
    467 
    468         // Extract to a temp directory inside WP uploads
    469         $upload_dir  = wp_upload_dir();
    470         $extract_dir = trailingslashit( $upload_dir['basedir'] ) . 'catpro-zip-import-' . time();
    471         wp_mkdir_p( $extract_dir );
    472         $zip->extractTo( $extract_dir );
    473         $zip->close();
    474 
    475         // Allowed image extensions
    476         $allowed_ext = [ 'jpg', 'jpeg', 'png', 'gif', 'webp', 'avif' ];
    477 
    478         // Get all product_cat slugs for quick lookup: slug → term_id
    479         $all_terms   = get_terms( [ 'taxonomy' => 'product_cat', 'hide_empty' => false, 'fields' => 'all' ] );
    480         $slug_map    = [];
    481         foreach ( $all_terms as $term ) {
    482             $slug_map[ $term->slug ] = $term->term_id;
    483         }
    484 
    485         require_once ABSPATH . 'wp-admin/includes/image.php';
    486         require_once ABSPATH . 'wp-admin/includes/file.php';
    487         require_once ABSPATH . 'wp-admin/includes/media.php';
    488 
    489         $matched   = 0;
    490         $skipped   = 0;
    491         $errors    = 0;
    492         $log       = [];
    493 
    494         // Iterate only first level of extracted files (no subdirectories)
    495         $dir_handle = opendir( $extract_dir );
    496         if ( $dir_handle ) {
    497             while ( ( $filename = readdir( $dir_handle ) ) !== false ) {
    498                 if ( $filename === '.' || $filename === '..' ) continue;
    499                 $file_path = $extract_dir . DIRECTORY_SEPARATOR . $filename;
    500                 if ( is_dir( $file_path ) ) continue;  // skip subdirectories
    501 
    502                 $info      = pathinfo( $filename );
    503                 $ext       = strtolower( $info['extension'] ?? '' );
    504                 $slug      = sanitize_title( $info['filename'] ?? '' );
    505 
    506                 if ( ! in_array( $ext, $allowed_ext, true ) ) {
    507                     $skipped++;
    508                     $log[] = '⏭️ ' . esc_html( $filename ) . ' — ' . __( 'not an image', 'netmagic-category-manager-for-woocommerce' );
    509                     continue;
    510                 }
    511 
    512                 if ( ! isset( $slug_map[ $slug ] ) ) {
    513                     $skipped++;
    514                     $log[] = '⚠️ ' . esc_html( $filename ) . ' — ' . sprintf(
    515                         /* translators: %s: category slug */
    516                         __( 'no category with slug "%s"', 'netmagic-category-manager-for-woocommerce' ),
    517                         esc_html( $slug )
    518                     );
    519                     continue;
    520                 }
    521 
    522                 $term_id = $slug_map[ $slug ];
    523 
    524                 // Copy to a WP-safe temp path before sideloading
    525                 $safe_tmp = get_temp_dir() . 'nmcm_' . sanitize_file_name( $filename );
    526                 if ( ! copy( $file_path, $safe_tmp ) ) {
    527                     $errors++;
    528                     $log[] = '❌ ' . esc_html( $filename ) . ' — ' . __( 'could not copy file', 'netmagic-category-manager-for-woocommerce' );
    529                     continue;
    530                 }
    531 
    532                 // Sideload image: creates attachment and moves file to uploads
    533                 $attachment_id = media_handle_sideload(
    534                     [
    535                         'name'     => sanitize_file_name( $filename ),
    536                         'tmp_name' => $safe_tmp,
    537                         'type'     => mime_content_type( $safe_tmp ),
    538                         'error'    => 0,
    539                         'size'     => filesize( $safe_tmp ),
    540                     ],
    541                     0, // post_id: 0 = not attached to any post
    542                     null
    543                 );
    544 
    545                 if ( is_wp_error( $attachment_id ) ) {
    546                     $errors++;
    547                     $log[] = '❌ ' . esc_html( $filename ) . ' — ' . esc_html( $attachment_id->get_error_message() );
    548                     @unlink( $safe_tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
    549                     continue;
    550                 }
    551 
    552                 // Assign as category thumbnail (WooCommerce term meta)
    553                 update_term_meta( $term_id, 'thumbnail_id', $attachment_id );
    554                 $matched++;
    555                 $log[] = '✅ ' . esc_html( $filename ) . ' → ' . esc_html( get_term( $term_id, 'product_cat' )->name );
    556             }
    557             closedir( $dir_handle );
    558         }
    559 
    560         // Clean up extracted directory
    561         $this->rmdir_recursive( $extract_dir );
    562 
    563         wp_send_json_success( [
    564             'matched' => $matched,
    565             'skipped' => $skipped,
    566             'errors'  => $errors,
    567             'log'     => $log,
    568         ] );
    569     }
    570 
    571     /**
    572      * Recursively delete a directory and its contents.
    573      * Used to clean up ZIP extract temp dir.
    574      */
    575     private function rmdir_recursive( $dir ) {
    576         if ( ! is_dir( $dir ) ) return;
    577         $files = scandir( $dir );
    578         foreach ( $files as $file ) {
    579             if ( $file === '.' || $file === '..' ) continue;
    580             $path = $dir . DIRECTORY_SEPARATOR . $file;
    581             if ( is_dir( $path ) ) {
    582                 $this->rmdir_recursive( $path );
    583             } else {
    584                 unlink( $path );
    585             }
    586         }
    587         rmdir( $dir );
    588     }
    589229}
  • netmagic-category-manager-for-woocommerce/trunk/admin/class-catpro-category-manager.php

    r3769691 r3803816  
    1414
    1515    private function __construct() {
    16         add_action( 'wp_ajax_nmcm_get_categories',      [ $this, 'ajax_get_categories' ] );
    17         add_action( 'wp_ajax_nmcm_delete_category',     [ $this, 'ajax_delete_category' ] );
    18         add_action( 'wp_ajax_nmcm_toggle_hide_category',[ $this, 'ajax_toggle_hide' ] );
    19         add_action( 'wp_ajax_nmcm_move_products',       [ $this, 'ajax_move_products' ] );
    20         add_action( 'wp_ajax_nmcm_rename_category',     [ $this, 'ajax_rename_category' ] );
    21         add_action( 'wp_ajax_nmcm_duplicate_category',  [ $this, 'ajax_duplicate_category' ] );
    22         add_action( 'wp_ajax_nmcm_bulk_action',         [ $this, 'ajax_bulk_action' ] );
    23         add_action( 'wp_ajax_nmcm_create_category',     [ $this, 'ajax_create_category' ] );
    24         add_action( 'wp_ajax_nmcm_export_excel',          [ $this, 'ajax_export_excel' ] );
    25         add_action( 'wp_ajax_nmcm_export_all_categories', [ $this, 'ajax_export_all_categories' ] );
    26         add_action( 'wp_ajax_nmcm_reorder_categories',    [ $this, 'ajax_reorder_categories' ] );
    27         add_action( 'wp_ajax_nmcm_change_parent',         [ $this, 'ajax_change_parent' ] );
    28         add_action( 'wp_ajax_nmcm_toggle_lock',           [ $this, 'ajax_toggle_lock' ] );
    29         add_action( 'wp_ajax_nmcm_toggle_struct_lock',    [ $this, 'ajax_toggle_struct_lock' ] );
    30         add_action( 'wp_ajax_nmcm_set_image',             [ $this, 'ajax_set_image' ] );
    31         add_action( 'wp_ajax_nmcm_remove_image',          [ $this, 'ajax_remove_image' ] );
     16        add_action( 'wp_ajax_nmcm_toggle_hide_category', [ $this, 'ajax_toggle_hide' ] );
    3217
    3318        // Admin JS for the category manager page
    3419        add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_manager_scripts' ] );
    3520
    36         // C3 Pro: Profiles — frontend CSS for shortcode list
    37         add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_frontend_styles' ] );
    38 
    3921        add_filter( 'woocommerce_product_categories_widget_args', [ $this, 'filter_hidden_categories' ] );
    4022        add_filter( 'get_terms_args', [ $this, 'filter_hidden_from_frontend' ], 10, 2 );
    41         add_filter( 'get_terms', [ $this, 'auto_hide_empty_categories' ], 10, 3 );
    4223    }
    4324
    … …  
    4627     */
    4728    public function enqueue_manager_scripts( $hook ) {
    48         // Only load on the Category Manager page
    49         // Hook name: product_page_nmcm-manager (registered under edit.php?post_type=product)
    5029        if ( 'product_page_nmcm-manager' !== $hook ) {
    5130            return;
    5231        }
    5332
    54         // SheetJS vendor library
    55         wp_enqueue_script(
    56             'nmcm-xlsx',
    57             CATPRO_URL . 'assets/js/vendor/xlsx.full.min.js',
    58             [],
    59             '0.20.3',
    60             true
    61         );
    62 
    63         // Main category manager script
    6433        wp_enqueue_script(
    6534            'nmcm-manager',
    6635            CATPRO_URL . 'assets/js/nm-category-manager.js',
    67             [ 'jquery', 'nmcm-xlsx', 'media-upload', 'thickbox' ],
     36            [ 'jquery' ],
    6837            CATPRO_VERSION,
    6938            true
    7039        );
    7140
    72         // Build category data for JS
    73         $categories = NMCM_Helpers::get_all_categories();
     41        $hidden_ids = NMCM_Helpers::get_hidden_category_ids();
     42        $subtree    = NMCM_Helpers::get_subtree_counts();
    7443        $all_cats   = [];
    75         foreach ( $categories as $cat ) {
    76             $is_locked     = NMCM_Helpers::is_category_locked( $cat->term_id );
    77             $thumbnail_id  = (int) get_term_meta( $cat->term_id, 'thumbnail_id', true );
    78             $thumbnail_url = '';
    79             if ( $thumbnail_id ) {
    80                 $img = wp_get_attachment_image_src( $thumbnail_id, 'thumbnail' );
    81                 $thumbnail_url = $img ? $img[0] : '';
    82             }
     44        foreach ( NMCM_Helpers::get_all_categories() as $cat ) {
    8345            $all_cats[] = [
    84                 'id'               => $cat->term_id,
    85                 'name'             => $cat->name,
    86                 'parent'           => $cat->parent,
    87                 'count'            => (int) $cat->count,
    88                 'slug'             => $cat->slug,
    89                 'hidden'           => NMCM_Helpers::is_category_hidden( $cat->term_id ),
    90                 'auto_hidden'      => ( NMCM_Helpers::auto_hide_empty_enabled() && (int) $cat->count === 0 && ! $is_locked ),
    91                 'locked'           => $is_locked,
    92                 'struct_locked'    => NMCM_Helpers::is_struct_locked( $cat->term_id ),
    93                 'feed_source'      => NMCM_Feed_Source::get_source( $cat->term_id ),
    94                 'feed_source_label'=> NMCM_Feed_Source::get_source_label( NMCM_Feed_Source::get_source( $cat->term_id ) ),
    95                 'thumbnail_id'     => $thumbnail_id,
    96                 'thumbnail_url'    => $thumbnail_url,
     46                'id'       => (int) $cat->term_id,
     47                'name'     => $cat->name,
     48                'parent'   => (int) $cat->parent,
     49                'count'    => (int) $cat->count,
     50                'subtree_count' => (int) ( $subtree[ (int) $cat->term_id ] ?? $cat->count ),
     51                'hidden'   => in_array( (int) $cat->term_id, $hidden_ids, true ),
     52                'edit_url' => admin_url( 'term.php?taxonomy=product_cat&post_type=product&tag_ID=' . (int) $cat->term_id ),
    9753            ];
    9854        }
    9955
    10056        wp_localize_script( 'nmcm-manager', 'nmcm', [
    101             'ajax_url'        => admin_url( 'admin-ajax.php' ),
    102             'nonce'           => wp_create_nonce( 'nmcm_nonce' ),
    103             'categories'      => $all_cats,
    104             'feed_sources'    => NMCM_Feed_Source::get_configured_sources(),
    105             'auto_hide_empty' => NMCM_Helpers::auto_hide_empty_enabled(),
    106             'i18n'            => [
    107                 // Drag handle
    108                 'drag_to_reorder'       => __( 'Drag to reorder', 'netmagic-category-manager-for-woocommerce' ),
    109                 'struct_locked_reorder' => __( 'Structurally locked — cannot reorder', 'netmagic-category-manager-for-woocommerce' ),
    110                 // Image buttons
    111                 'click_change_image'    => __( 'Click to change image', 'netmagic-category-manager-for-woocommerce' ),
    112                 'click_set_image'       => __( 'Click to set image', 'netmagic-category-manager-for-woocommerce' ),
    113                 'change_image'          => __( 'Change image', 'netmagic-category-manager-for-woocommerce' ),
    114                 'remove_image'          => __( 'Remove image', 'netmagic-category-manager-for-woocommerce' ),
    115                 'set_image'             => __( 'Set image', 'netmagic-category-manager-for-woocommerce' ),
    116                 // Lock badges
    117                 'locked_title'          => __( 'Locked: auto-hide will not affect this category', 'netmagic-category-manager-for-woocommerce' ),
    118                 'locked'                => __( 'Locked', 'netmagic-category-manager-for-woocommerce' ),
    119                 'struct_locked_title'   => __( 'Structurally locked: cannot rename, delete, move or add children', 'netmagic-category-manager-for-woocommerce' ),
    120                 'struct_lock'           => __( 'Struct Lock', 'netmagic-category-manager-for-woocommerce' ),
    121                 'managed_by_feed'       => __( 'Managed by feed source', 'netmagic-category-manager-for-woocommerce' ),
    122                 // Lock buttons
    123                 'unlock'                => __( 'Unlock', 'netmagic-category-manager-for-woocommerce' ),
    124                 'lock'                  => __( 'Lock', 'netmagic-category-manager-for-woocommerce' ),
    125                 'unlock_title'          => __( 'Unlock: allow auto-hide', 'netmagic-category-manager-for-woocommerce' ),
    126                 'lock_title'            => __( 'Lock: protect from auto-hide', 'netmagic-category-manager-for-woocommerce' ),
    127                 'remove_struct_lock'    => __( 'Remove structural lock', 'netmagic-category-manager-for-woocommerce' ),
    128                 'struct_lock_title'     => __( 'Structural lock: prevent rename, delete, move, add child', 'netmagic-category-manager-for-woocommerce' ),
    129                 'unstruct'              => __( 'Unstruct', 'netmagic-category-manager-for-woocommerce' ),
    130                 'struct_locked_move'    => __( 'Struct-locked: cannot be moved', 'netmagic-category-manager-for-woocommerce' ),
    131                 'structurally_locked'   => __( 'Structural lock: cannot be renamed, deleted, moved or given new children', 'netmagic-category-manager-for-woocommerce' ),
    132                 // Feed source
    133                 'feed_placeholder'      => __( 'Feed…', 'netmagic-category-manager-for-woocommerce' ),
    134                 'clear_source'          => __( 'Clear source', 'netmagic-category-manager-for-woocommerce' ),
    135                 'assign_feed'           => __( 'Assign feed source', 'netmagic-category-manager-for-woocommerce' ),
    136                 // Inline edit modal
    137                 'category_name_label'   => __( 'Category name:', 'netmagic-category-manager-for-woocommerce' ),
    138                 'edit_category'         => __( 'Edit category', 'netmagic-category-manager-for-woocommerce' ),
    139                 'save'                  => __( 'Save', 'netmagic-category-manager-for-woocommerce' ),
    140                 // New category modal
    141                 'parent_category_label' => __( 'Parent category:', 'netmagic-category-manager-for-woocommerce' ),
    142                 'create_new_category'   => __( 'Create new category', 'netmagic-category-manager-for-woocommerce' ),
    143                 'create'                => __( 'Create', 'netmagic-category-manager-for-woocommerce' ),
    144                 // Errors
    145                 'error_occurred'        => __( 'An error occurred.', 'netmagic-category-manager-for-woocommerce' ),
    146                 // Delete modal
    147                 'products'              => __( 'products', 'netmagic-category-manager-for-woocommerce' ),
    148                 'products_in_cat'       => __( 'are in this category. What should happen to them?', 'netmagic-category-manager-for-woocommerce' ),
    149                 'move_to_uncategorized' => __( 'Move to Uncategorized', 'netmagic-category-manager-for-woocommerce' ),
    150                 'move_to_another'       => __( 'Move to another category:', 'netmagic-category-manager-for-woocommerce' ),
    151                 'confirm_delete_empty'  => __( 'This category is empty. Are you sure you want to delete it?', 'netmagic-category-manager-for-woocommerce' ),
    152                 'delete_label'          => __( 'Delete', 'netmagic-category-manager-for-woocommerce' ),
    153                 // Bulk actions
    154                 'cats_remove_image'     => __( 'categories: remove image?', 'netmagic-category-manager-for-woocommerce' ),
    155                 'cats_will_be_deleted'  => __( 'categories will be deleted.', 'netmagic-category-manager-for-woocommerce' ),
    156                 'products_to_uncategorized' => __( 'products will be moved to Uncategorized.', 'netmagic-category-manager-for-woocommerce' ),
    157                 // Export
    158                 'sheetjs_load_error'    => __( 'SheetJS load error. Please refresh and try again.', 'netmagic-category-manager-for-woocommerce' ),
    159                 'col_cat_name'          => __( 'Category name', 'netmagic-category-manager-for-woocommerce' ),
    160                 'col_parent'            => __( 'Parent category', 'netmagic-category-manager-for-woocommerce' ),
    161                 'col_product_count'     => __( 'Product count', 'netmagic-category-manager-for-woocommerce' ),
    162                 'col_hidden'            => __( 'Hidden', 'netmagic-category-manager-for-woocommerce' ),
    163                 'yes'                   => __( 'Yes', 'netmagic-category-manager-for-woocommerce' ),
    164                 'no'                    => __( 'No', 'netmagic-category-manager-for-woocommerce' ),
    165                 'col_categories'        => __( 'Categories', 'netmagic-category-manager-for-woocommerce' ),
    166                 // Profiles (C3)
    167                 'select_profile'        => __( 'Please select a profile.', 'netmagic-category-manager-for-woocommerce' ),
    168                 'profile_loaded'        => __( 'Profile loaded.', 'netmagic-category-manager-for-woocommerce' ),
    169                 'fill_profile_slug_name'=> __( 'Please fill in the profile slug and name.', 'netmagic-category-manager-for-woocommerce' ),
    170                 'hidden_label'          => __( 'hidden', 'netmagic-category-manager-for-woocommerce' ),
    171                 'profile_saved'         => __( 'Profile saved!', 'netmagic-category-manager-for-woocommerce' ),
    172                 'delete_profile_confirm'=> __( 'Delete this profile?', 'netmagic-category-manager-for-woocommerce' ),
    173                 'profile_deleted'       => __( 'Profile deleted.', 'netmagic-category-manager-for-woocommerce' ),
    174                 'reset_visibility'      => __( 'Reset — showing original visibility.', 'netmagic-category-manager-for-woocommerce' ),
    175                 'copied'                => __( 'Copied!', 'netmagic-category-manager-for-woocommerce' ),
    176                 // Parent change (drag & drop)
    177                 'make_top_level'        => __( 'Make this a top-level category (no parent)?', 'netmagic-category-manager-for-woocommerce' ),
    178                 'move_under'            => __( 'Move this category under:', 'netmagic-category-manager-for-woocommerce' ),
    179                 'change_parent'         => __( 'Change parent category', 'netmagic-category-manager-for-woocommerce' ),
    180                 'yes_change_parent'     => __( 'Yes, change parent', 'netmagic-category-manager-for-woocommerce' ),
    181                 // Sort / bulk
    182                 'products_sort'         => __( 'Products', 'netmagic-category-manager-for-woocommerce' ),
    183                 'select_cats_and_action'=> __( 'Please select categories and an action.', 'netmagic-category-manager-for-woocommerce' ),
    184                 // Media library
    185                 'select_cat_image'      => __( 'Select category image', 'netmagic-category-manager-for-woocommerce' ),
    186                 'set_as_cat_image'      => __( 'Set as category image', 'netmagic-category-manager-for-woocommerce' ),
     57            'ajax_url'   => admin_url( 'admin-ajax.php' ),
     58            'nonce'      => wp_create_nonce( 'nmcm_nonce' ),
     59            'categories' => $all_cats,
     60            'i18n'       => [
     61                'products'      => __( 'Products', 'netmagic-category-manager-for-woocommerce' ),
     62                'hidden'        => __( 'Hidden', 'netmagic-category-manager-for-woocommerce' ),
     63                'visible'       => __( 'Visible', 'netmagic-category-manager-for-woocommerce' ),
     64                'empty'         => __( 'Empty', 'netmagic-category-manager-for-woocommerce' ),
     65                'hide'          => __( 'Hide', 'netmagic-category-manager-for-woocommerce' ),
     66                'show'          => __( 'Show', 'netmagic-category-manager-for-woocommerce' ),
     67                'edit'          => __( 'Edit', 'netmagic-category-manager-for-woocommerce' ),
     68                'toggle_status' => __( 'Click to toggle status', 'netmagic-category-manager-for-woocommerce' ),
     69                'error'         => __( 'An error occurred. Please try again.', 'netmagic-category-manager-for-woocommerce' ),
    18770            ],
    18871        ] );
    … …  
    19073
    19174    public function render_page() {
    192         $categories = NMCM_Helpers::get_all_categories();
     75        $total = count( NMCM_Helpers::get_all_categories() );
    19376        ?>
    19477        <div class="wrap catpro-wrap">
    195             <?php
    196             if ( get_transient( 'catpro_order_saved_' . get_current_user_id() ) ) {
    197                 delete_transient( 'catpro_order_saved_' . get_current_user_id() );
    198                 ?>
    199                 <div class="notice notice-success is-dismissible" style="margin:10px 0;">
    200                     <p>&#x2705; <strong><?php esc_html_e( 'Order saved successfully!', 'netmagic-category-manager-for-woocommerce' ); ?></strong></p>
    201                 </div>
    202                 <?php
    203             }
    204             ?>
    20578            <h1>
    20679                <?php esc_html_e( 'Category Manager', 'netmagic-category-manager-for-woocommerce' ); ?>
    … …  
    20881                    &#x1F4E5; <?php esc_html_e( 'Import from Excel', 'netmagic-category-manager-for-woocommerce' ); ?>
    20982                </a>
    210                 <button type="button" id="catpro-export-btn" class="page-title-action">
    211                     &#x1F4E4; <?php esc_html_e( 'Export to Excel', 'netmagic-category-manager-for-woocommerce' ); ?>
    212                 </button>
    213                 <button type="button" id="catpro-new-cat-btn" class="page-title-action">
     83                <a href="<?php echo esc_url( admin_url( 'edit-tags.php?taxonomy=product_cat&post_type=product' ) ); ?>" class="page-title-action">
    21484                    &#x2795; <?php esc_html_e( 'New category', 'netmagic-category-manager-for-woocommerce' ); ?>
    215                 </button>
     85                </a>
    21686            </h1>
    21787
    218             <!-- C3 Pro: Profiles bar -->
    219             <div class="catpro-profiles-bar catpro-card" id="catpro-profiles-bar">
    220                 <div class="catpro-profiles-bar-inner">
    221                     <span class="catpro-profiles-bar-label">
    222                         🎯 <strong><?php esc_html_e( 'Campaign Profiles', 'netmagic-category-manager-for-woocommerce' ); ?></strong>
    223                     </span>
    224 
    225                     <!-- Load existing profile -->
    226                     <select id="catpro-profile-select" style="min-width:160px;">
    227                         <option value=""><?php esc_html_e( '— Load profile —', 'netmagic-category-manager-for-woocommerce' ); ?></option>
    228                         <?php
    229                         $profiles = NMCM_Profiles::get_all_profiles();
    230                         foreach ( $profiles as $p ) {
    231                             $hidden_count = count( $p['hidden_ids'] ?? [] );
    232                             echo '<option value="' . esc_attr( $p['slug'] ) . '">'
    233                                 . esc_html( $p['name'] )
    234                                 . ' (' . (int) $hidden_count . ' ' . esc_html__( 'hidden', 'netmagic-category-manager-for-woocommerce' ) . ')'
    235                                 . '</option>';
    236                         }
    237                         ?>
    238                     </select>
    239                     <button type="button" id="catpro-profile-load" class="button">
    240                         <?php esc_html_e( 'Load', 'netmagic-category-manager-for-woocommerce' ); ?>
    241                     </button>
    242                     <button type="button" id="catpro-profile-delete" class="button" style="color:#d63638;border-color:#d63638;display:none;">
    243                         🗑️ <?php esc_html_e( 'Delete', 'netmagic-category-manager-for-woocommerce' ); ?>
    244                     </button>
    245 
    246                     <span style="color:#dcdcde;margin:0 6px;">|</span>
    247 
    248                     <!-- Save current state as profile -->
    249                     <input type="text" id="catpro-profile-slug"
    250                            placeholder="<?php esc_attr_e( 'profile-slug', 'netmagic-category-manager-for-woocommerce' ); ?>"
    251                            style="width:130px;" maxlength="60">
    252                     <input type="text" id="catpro-profile-name"
    253                            placeholder="<?php esc_attr_e( 'Profile name', 'netmagic-category-manager-for-woocommerce' ); ?>"
    254                            style="width:160px;" maxlength="100">
    255                     <input type="text" id="catpro-profile-desc"
    256                            placeholder="<?php esc_attr_e( 'Description (optional)', 'netmagic-category-manager-for-woocommerce' ); ?>"
    257                            style="width:200px;" maxlength="200">
    258                     <button type="button" id="catpro-profile-save" class="button button-primary">
    259                         💾 <?php esc_html_e( 'Save profile', 'netmagic-category-manager-for-woocommerce' ); ?>
    260                     </button>
    261 
    262                     <span id="catpro-profile-msg" style="margin-left:8px;font-size:13px;display:none;"></span>
    263                 </div>
    264 
    265                 <!-- Shortcode hint — megjelenik, ha profil van betöltve -->
    266                 <div id="catpro-profile-shortcode-hint" style="display:none;margin-top:10px;padding:8px 12px;background:#f0f6ff;border-radius:4px;font-size:13px;">
    267                     📋 <?php esc_html_e( 'Shortcode for campaign pages:', 'netmagic-category-manager-for-woocommerce' ); ?>
    268                     <code id="catpro-profile-shortcode-code" style="background:#e8f0fe;padding:2px 8px;border-radius:3px;user-select:all;cursor:pointer;"
    269                           title="<?php esc_attr_e( 'Click to copy', 'netmagic-category-manager-for-woocommerce' ); ?>"></code>
    270                     <code id="catpro-profile-shortcode-list" style="background:#e8f0fe;padding:2px 8px;border-radius:3px;margin-left:6px;user-select:all;cursor:pointer;"
    271                           title="<?php esc_attr_e( 'Click to copy — also shows a category list', 'netmagic-category-manager-for-woocommerce' ); ?>"></code>
    272                     <button type="button" id="catpro-profile-reset" class="button button-small" style="margin-left:10px;">
    273                         ↺ <?php esc_html_e( 'Reset (show all)', 'netmagic-category-manager-for-woocommerce' ); ?>
    274                     </button>
    275                 </div>
    276             </div>
    277 
    27888            <div class="catpro-stats-bar">
    279                 <span class="catpro-stat-link" data-filter="">
    280                     &#x1F4C1; <strong id="stat-total"><?php echo count( $categories ); ?></strong>
     89                <span class="catpro-stat-link" data-filter="" style="cursor:pointer;">
     90                    &#x1F4C1; <strong id="stat-total"><?php echo (int) $total; ?></strong>
    28191                    <?php esc_html_e( 'total categories', 'netmagic-category-manager-for-woocommerce' ); ?>
    28292                </span>
    … …  
    303113                    </select>
    304114                </div>
    305                 <div class="catpro-toolbar-right">
    306                     <select id="catpro-bulk-action">
    307                         <option value=""><?php esc_html_e( 'Bulk actions', 'netmagic-category-manager-for-woocommerce' ); ?></option>
    308                         <option value="hide"><?php esc_html_e( 'Hide selected', 'netmagic-category-manager-for-woocommerce' ); ?></option>
    309                         <option value="show"><?php esc_html_e( 'Show selected', 'netmagic-category-manager-for-woocommerce' ); ?></option>
    310                         <option value="set_image">🖼️ <?php esc_html_e( 'Set image (Pro)', 'netmagic-category-manager-for-woocommerce' ); ?></option>
    311                         <option value="remove_image">🗑️ <?php esc_html_e( 'Remove image (Pro)', 'netmagic-category-manager-for-woocommerce' ); ?></option>
    312                         <option value="lock">🔒 <?php esc_html_e( 'Lock selected (Pro)', 'netmagic-category-manager-for-woocommerce' ); ?></option>
    313                         <option value="unlock">🔓 <?php esc_html_e( 'Unlock selected (Pro)', 'netmagic-category-manager-for-woocommerce' ); ?></option>
    314                         <option value="struct_lock">🏛️ <?php esc_html_e( 'Struct Lock selected (Pro)', 'netmagic-category-manager-for-woocommerce' ); ?></option>
    315                         <option value="struct_unlock">🏛️🔓 <?php esc_html_e( 'Struct Unlock selected (Pro)', 'netmagic-category-manager-for-woocommerce' ); ?></option>
    316                         <?php
    317                         // C7 Pro: Feed source bulk assign options
    318                         $configured_sources = NMCM_Feed_Source::get_configured_sources();
    319                         foreach ( $configured_sources as $slug => $label ) :
    320                         ?>
    321                         <option value="feed_source:<?php echo esc_attr( $slug ); ?>">📡 <?php printf( esc_html__( 'Assign feed: %s (Pro)', 'netmagic-category-manager-for-woocommerce' ), esc_html( $label ) ); ?></option>
    322                         <?php endforeach; ?>
    323                         <?php if ( ! empty( $configured_sources ) ) : ?>
    324                         <option value="feed_source_clear">📡✕ <?php esc_html_e( 'Clear feed source (Pro)', 'netmagic-category-manager-for-woocommerce' ); ?></option>
    325                         <?php endif; ?>
    326                         <option value="delete"><?php esc_html_e( 'Delete selected', 'netmagic-category-manager-for-woocommerce' ); ?></option>
    327                     </select>
    328                     <button type="button" id="catpro-apply-bulk" class="button">
    329                         <?php esc_html_e( 'Apply', 'netmagic-category-manager-for-woocommerce' ); ?>
    330                     </button>
    331                 </div>
    332115            </div>
    333116
    … …  
    341124                <thead>
    342125                    <tr>
    343                         <th style="width:20px;"></th>
    344                         <td class="manage-column column-cb check-column">
    345                             <input type="checkbox" id="cb-select-all">
    346                         </td>
    347126                        <th class="column-name"><?php esc_html_e( 'Category', 'netmagic-category-manager-for-woocommerce' ); ?></th>
    348127                        <th class="column-count" style="cursor:pointer;" id="sort-count">
    … …  
    356135            </table>
    357136
    358             <div id="catpro-modal" class="catpro-modal" style="display:none;">
    359                 <div class="catpro-modal-content">
    360                     <h2 id="catpro-modal-title"></h2>
    361                     <div id="catpro-modal-body"></div>
    362                     <div class="catpro-modal-footer">
    363                         <button type="button" id="catpro-modal-confirm" class="button button-primary"></button>
    364                         <button type="button" id="catpro-modal-cancel" class="button">
    365                             <?php esc_html_e( 'Cancel', 'netmagic-category-manager-for-woocommerce' ); ?>
    366                         </button>
    367                     </div>
    368                 </div>
    369             </div>
     137            <p class="description" style="margin-top:16px;">
     138                <?php esc_html_e( 'Need drag & drop sorting, inline editing, Excel export, bulk actions and more?', 'netmagic-category-manager-for-woocommerce' ); ?>
     139                <a href="<?php echo esc_url( NMCM_UPGRADE_URL ); ?>" target="_blank" rel="noopener noreferrer">
     140                    <?php esc_html_e( 'See the Pro edition', 'netmagic-category-manager-for-woocommerce' ); ?>
     141                </a>
     142            </p>
    370143        </div>
    371 
    372144        <?php
    373     }
    374 
    375     // C3 Pro: Frontend CSS for shortcode category list
    376     public function enqueue_frontend_styles() {
    377         wp_enqueue_style(
    378             'catpro-frontend',
    379             CATPRO_URL . 'assets/css/frontend.css',
    380             [],
    381             CATPRO_VERSION
    382         );
    383145    }
    384146
    … …  
    386148    public function ajax_toggle_hide() {
    387149        check_ajax_referer( 'nmcm_nonce', 'nonce' );
    388         if ( ! current_user_can( 'manage_woocommerce' ) ) wp_die();
     150        if ( ! current_user_can( 'manage_woocommerce' ) ) {
     151            wp_send_json_error( __( 'You are not allowed to do this.', 'netmagic-category-manager-for-woocommerce' ), 403 );
     152        }
    389153        $term_id = absint( wp_unslash( $_POST['term_id'] ?? 0 ) );
    390154        $hide    = absint( wp_unslash( $_POST['hide'] ?? 0 ) );
    391         $term = get_term( $term_id, 'product_cat' );
    392         $term_name = ( $term && ! is_wp_error( $term ) ) ? $term->name : '';
     155        $term    = get_term( $term_id, 'product_cat' );
     156        if ( ! $term || is_wp_error( $term ) ) {
     157            wp_send_json_error( __( 'Category not found.', 'netmagic-category-manager-for-woocommerce' ), 404 );
     158        }
    393159        if ( $hide ) {
    394160            NMCM_Helpers::hide_category( $term_id );
    395             NMCM_Audit_Log::log( 'hide', $term_id, $term_name, 'visible', 'hidden' );
    396161        } else {
    397162            NMCM_Helpers::show_category( $term_id );
    398             NMCM_Audit_Log::log( 'show', $term_id, $term_name, 'hidden', 'visible' );
    399163        }
    400164        wp_send_json_success( [ 'hidden' => (bool) $hide, 'term_id' => $term_id ] );
    401     }
    402 
    403     // AJAX: Delete
    404     public function ajax_delete_category() {
    405         check_ajax_referer( 'nmcm_nonce', 'nonce' );
    406         if ( ! current_user_can( 'manage_woocommerce' ) ) wp_die();
    407         $term_id     = absint( wp_unslash( $_POST['term_id'] ?? 0 ) );
    408         // C4a: structural lock check
    409         if ( NMCM_Helpers::is_struct_locked( $term_id ) ) {
    410             wp_send_json_error( __( 'This category is structurally locked and cannot be deleted.', 'netmagic-category-manager-for-woocommerce' ) );
    411         }
    412         $action      = sanitize_text_field( wp_unslash( $_POST['delete_action'] ?? 'uncategorize' ) );
    413         $move_target = absint( wp_unslash( $_POST['move_target'] ?? 0 ) );
    414         if ( $action === 'move' && $move_target ) {
    415             $this->move_products_to_category( $term_id, $move_target );
    416         }
    417         $del_term = get_term( $term_id, 'product_cat' );
    418         $del_name = ( $del_term && ! is_wp_error( $del_term ) ) ? $del_term->name : '';
    419         NMCM_Helpers::show_category( $term_id );
    420         $result = wp_delete_term( $term_id, 'product_cat' );
    421         if ( is_wp_error( $result ) ) wp_send_json_error( $result->get_error_message() );
    422         NMCM_Audit_Log::log( 'delete', $term_id, $del_name, $del_name, '' );
    423         wp_send_json_success( [ 'term_id' => $term_id ] );
    424     }
    425 
    426     // AJAX: Rename
    427     public function ajax_rename_category() {
    428         check_ajax_referer( 'nmcm_nonce', 'nonce' );
    429         if ( ! current_user_can( 'manage_woocommerce' ) ) wp_die();
    430         $term_id  = absint( wp_unslash( $_POST['term_id'] ?? 0 ) );
    431         // C4a: structural lock check
    432         if ( NMCM_Helpers::is_struct_locked( $term_id ) ) {
    433             wp_send_json_error( __( 'This category is structurally locked and cannot be renamed.', 'netmagic-category-manager-for-woocommerce' ) );
    434         }
    435         $new_name = NMCM_Helpers::sanitize_term_name( wp_unslash( $_POST['new_name'] ?? '' ) );
    436         if ( ! $new_name ) wp_send_json_error( 'Invalid name' );
    437         $old_term = get_term( $term_id, 'product_cat' );
    438         $old_name = ( $old_term && ! is_wp_error( $old_term ) ) ? $old_term->name : '';
    439         $result = wp_update_term( $term_id, 'product_cat', [ 'name' => $new_name ] );
    440         if ( is_wp_error( $result ) ) wp_send_json_error( $result->get_error_message() );
    441         NMCM_Audit_Log::log( 'rename', $term_id, $new_name, $old_name, $new_name );
    442         wp_send_json_success( [ 'term_id' => $term_id, 'name' => $new_name ] );
    443     }
    444 
    445     // AJAX: Create
    446     public function ajax_create_category() {
    447         check_ajax_referer( 'nmcm_nonce', 'nonce' );
    448         if ( ! current_user_can( 'manage_woocommerce' ) ) wp_die();
    449         $name      = NMCM_Helpers::sanitize_term_name( wp_unslash( $_POST['name'] ?? '' ) );
    450         $parent_id = absint( wp_unslash( $_POST['parent_id'] ?? 0 ) );
    451         // C4a: if the parent is struct_locked, adding a child is also blocked
    452         if ( $parent_id && NMCM_Helpers::is_struct_locked( $parent_id ) ) {
    453             wp_send_json_error( __( 'The parent category is structurally locked. Cannot add subcategories.', 'netmagic-category-manager-for-woocommerce' ) );
    454         }
    455         if ( ! $name ) wp_send_json_error( 'Invalid name' );
    456         $result = wp_insert_term( $name, 'product_cat', [ 'parent' => $parent_id ] );
    457         if ( is_wp_error( $result ) ) wp_send_json_error( $result->get_error_message() );
    458         wp_send_json_success( [ 'term_id' => $result['term_id'], 'name' => $name ] );
    459     }
    460 
    461     // AJAX: Duplicate
    462     public function ajax_duplicate_category() {
    463         check_ajax_referer( 'nmcm_nonce', 'nonce' );
    464         if ( ! current_user_can( 'manage_woocommerce' ) ) wp_die();
    465         $term_id = absint( wp_unslash( $_POST['term_id'] ?? 0 ) );
    466         $term    = get_term( $term_id, 'product_cat' );
    467         if ( ! $term || is_wp_error( $term ) ) wp_send_json_error( 'Not found' );
    468         $result = wp_insert_term(
    469             $term->name . ' (Copy)',
    470             'product_cat',
    471             [ 'parent' => $term->parent, 'description' => $term->description, 'slug' => $term->slug . '-copy-' . time() ]
    472         );
    473         if ( is_wp_error( $result ) ) wp_send_json_error( $result->get_error_message() );
    474         wp_send_json_success( [ 'new_term_id' => $result['term_id'] ] );
    475     }
    476 
    477     // AJAX: Bulk action
    478     public function ajax_bulk_action() {
    479         check_ajax_referer( 'nmcm_nonce', 'nonce' );
    480         if ( ! current_user_can( 'manage_woocommerce' ) ) wp_die();
    481         $action = sanitize_text_field( wp_unslash( $_POST['bulk_action'] ?? '' ) );
    482         $ids    = array_map( 'absint', wp_unslash( $_POST['ids'] ?? [] ) );
    483         if ( empty( $ids ) || ! $action ) wp_send_json_error( 'Invalid request' );
    484         foreach ( $ids as $id ) {
    485             $b_term = get_term( $id, 'product_cat' );
    486             $b_name = ( $b_term && ! is_wp_error( $b_term ) ) ? $b_term->name : '';
    487             if ( $action === 'hide' ) {
    488                 NMCM_Helpers::hide_category( $id );
    489                 NMCM_Audit_Log::log( 'hide', $id, $b_name, 'visible', 'hidden' );
    490             } elseif ( $action === 'show' ) {
    491                 NMCM_Helpers::show_category( $id );
    492                 NMCM_Audit_Log::log( 'show', $id, $b_name, 'hidden', 'visible' );
    493             } elseif ( $action === 'lock' ) {
    494                 // C4b Pro: bulk lock
    495                 NMCM_Helpers::lock_category( $id );
    496                 NMCM_Audit_Log::log( 'lock', $id, $b_name, 'unlocked', 'locked' );
    497             } elseif ( $action === 'unlock' ) {
    498                 // C4b Pro: bulk unlock (C1 auto-hide lock)
    499                 NMCM_Helpers::unlock_category( $id );
    500                 NMCM_Audit_Log::log( 'unlock', $id, $b_name, 'locked', 'unlocked' );
    501             } elseif ( $action === 'struct_lock' ) {
    502                 // C4a Pro: bulk structural lock
    503                 NMCM_Helpers::struct_lock( $id );
    504                 NMCM_Audit_Log::log( 'struct_lock', $id, $b_name, 'struct_unlocked', 'struct_locked' );
    505             } elseif ( $action === 'struct_unlock' ) {
    506                 // C4a Pro: bulk structural unlock
    507                 NMCM_Helpers::struct_unlock( $id );
    508                 NMCM_Audit_Log::log( 'struct_unlock', $id, $b_name, 'struct_locked', 'struct_unlocked' );
    509             } elseif ( strpos( $action, 'feed_source:' ) === 0 ) {
    510                 // C7 Pro: bulk assign feed source
    511                 $source_slug = sanitize_key( substr( $action, strlen( 'feed_source:' ) ) );
    512                 $sources     = NMCM_Feed_Source::get_configured_sources();
    513                 if ( isset( $sources[ $source_slug ] ) ) {
    514                     NMCM_Feed_Source::set_source( $id, $source_slug );
    515                     NMCM_Audit_Log::log( 'feed_source_set', $id, $b_name, NMCM_Feed_Source::get_source( $id ), $source_slug );
    516                 }
    517             } elseif ( $action === 'feed_source_clear' ) {
    518                 // C7 Pro: bulk clear feed source
    519                 $old_src = NMCM_Feed_Source::get_source( $id );
    520                 NMCM_Feed_Source::clear_source( $id );
    521                 NMCM_Audit_Log::log( 'feed_source_clear', $id, $b_name, $old_src, '' );
    522             } elseif ( $action === 'delete' ) {
    523                 NMCM_Helpers::show_category( $id );
    524                 wp_delete_term( $id, 'product_cat' );
    525                 NMCM_Audit_Log::log( 'delete', $id, $b_name, $b_name, '' );
    526             }
    527         }
    528         wp_send_json_success( [ 'processed' => count( $ids ), 'action' => $action ] );
    529     }
    530 
    531     // AJAX: Move products
    532     public function ajax_move_products() {
    533         check_ajax_referer( 'nmcm_nonce', 'nonce' );
    534         if ( ! current_user_can( 'manage_woocommerce' ) ) wp_die();
    535         $this->move_products_to_category(
    536             absint( wp_unslash( $_POST['from_id'] ?? 0 ) ),
    537             absint( wp_unslash( $_POST['to_id'] ?? 0 ) )
    538         );
    539         wp_send_json_success();
    540     }
    541 
    542     // AJAX: Export all categories
    543     public function ajax_export_all_categories() {
    544         check_ajax_referer( 'nmcm_nonce', 'nonce' );
    545         if ( ! current_user_can( 'manage_woocommerce' ) ) wp_die();
    546         $categories = NMCM_Helpers::get_all_categories();
    547         $data = [];
    548         foreach ( $categories as $cat ) {
    549             $parent_name = '';
    550             if ( $cat->parent ) {
    551                 $parent_term = get_term( $cat->parent, 'product_cat' );
    552                 if ( $parent_term && ! is_wp_error( $parent_term ) ) {
    553                     $parent_name = $parent_term->name;
    554                 }
    555             }
    556             $data[] = [
    557                 'id'          => $cat->term_id,
    558                 'name'        => $cat->name,
    559                 'parent_name' => $parent_name,
    560                 'slug'        => $cat->slug,
    561                 'count'       => (int) $cat->count,
    562                 'hidden'      => NMCM_Helpers::is_category_hidden( $cat->term_id ),
    563             ];
    564         }
    565         wp_send_json_success( [ 'categories' => $data, 'total' => count( $data ) ] );
    566     }
    567 
    568     // AJAX: Export (legacy)
    569     public function ajax_export_excel() {
    570         check_ajax_referer( 'nmcm_nonce', 'nonce' );
    571         if ( ! current_user_can( 'manage_woocommerce' ) ) wp_die();
    572         wp_send_json_success();
    573     }
    574 
    575     // AJAX: Change parent
    576     public function ajax_change_parent() {
    577         check_ajax_referer( 'nmcm_nonce', 'nonce' );
    578         if ( ! current_user_can( 'manage_woocommerce' ) ) wp_die();
    579         $term_id       = absint( wp_unslash( $_POST['term_id'] ?? 0 ) );
    580         // C4a: struct_locked category cannot be moved to a different parent
    581         if ( NMCM_Helpers::is_struct_locked( $term_id ) ) {
    582             wp_send_json_error( __( 'This category is structurally locked and cannot be moved.', 'netmagic-category-manager-for-woocommerce' ) );
    583         }
    584         $new_parent_id = absint( wp_unslash( $_POST['new_parent_id'] ?? 0 ) );
    585         $result = wp_update_term( $term_id, 'product_cat', [ 'parent' => $new_parent_id ] );
    586         if ( is_wp_error( $result ) ) wp_send_json_error( $result->get_error_message() );
    587         wp_send_json_success( [ 'term_id' => $term_id, 'new_parent' => $new_parent_id ] );
    588     }
    589 
    590     // AJAX: Reorder
    591     public function ajax_reorder_categories() {
    592         check_ajax_referer( 'nmcm_nonce', 'nonce' );
    593         if ( ! current_user_can( 'manage_woocommerce' ) ) wp_die();
    594         $order = array_map( 'absint', wp_unslash( $_POST['order'] ?? [] ) );
    595         foreach ( $order as $position => $term_id ) {
    596             update_term_meta( $term_id, 'catpro_order', $position );
    597         }
    598         set_transient( 'catpro_order_saved_' . get_current_user_id(), true, 60 );
    599         wp_send_json_success( [ 'saved' => count( $order ) ] );
    600     }
    601 
    602     // AJAX: Toggle lock (C1 Pro feature)
    603     public function ajax_toggle_lock() {
    604         check_ajax_referer( 'nmcm_nonce', 'nonce' );
    605         if ( ! current_user_can( 'manage_woocommerce' ) ) wp_die();
    606         $term_id = absint( wp_unslash( $_POST['term_id'] ?? 0 ) );
    607         $lock    = absint( wp_unslash( $_POST['lock'] ?? 0 ) );
    608         if ( ! $term_id ) wp_send_json_error( 'Invalid term_id' );
    609         $lock_term = get_term( $term_id, 'product_cat' );
    610         $lock_name = ( $lock_term && ! is_wp_error( $lock_term ) ) ? $lock_term->name : '';
    611         if ( $lock ) {
    612             NMCM_Helpers::lock_category( $term_id );
    613             NMCM_Audit_Log::log( 'lock', $term_id, $lock_name, 'unlocked', 'locked' );
    614         } else {
    615             NMCM_Helpers::unlock_category( $term_id );
    616             NMCM_Audit_Log::log( 'unlock', $term_id, $lock_name, 'locked', 'unlocked' );
    617         }
    618         wp_send_json_success( [ 'term_id' => $term_id, 'locked' => (bool) $lock ] );
    619     }
    620 
    621     // AJAX: Toggle structural lock (C4a Pro feature)
    622     public function ajax_toggle_struct_lock() {
    623         check_ajax_referer( 'nmcm_nonce', 'nonce' );
    624         if ( ! current_user_can( 'manage_woocommerce' ) ) wp_die();
    625         $term_id = absint( wp_unslash( $_POST['term_id'] ?? 0 ) );
    626         $lock    = absint( wp_unslash( $_POST['lock'] ?? 0 ) );
    627         if ( ! $term_id ) wp_send_json_error( 'Invalid term_id' );
    628         $sl_term = get_term( $term_id, 'product_cat' );
    629         $sl_name = ( $sl_term && ! is_wp_error( $sl_term ) ) ? $sl_term->name : '';
    630         if ( $lock ) {
    631             NMCM_Helpers::struct_lock( $term_id );
    632             NMCM_Audit_Log::log( 'struct_lock', $term_id, $sl_name, 'struct_unlocked', 'struct_locked' );
    633         } else {
    634             NMCM_Helpers::struct_unlock( $term_id );
    635             NMCM_Audit_Log::log( 'struct_unlock', $term_id, $sl_name, 'struct_locked', 'struct_unlocked' );
    636         }
    637         wp_send_json_success( [ 'term_id' => $term_id, 'struct_locked' => (bool) $lock ] );
    638     }
    639 
    640     // AJAX: Set image (C2 Pro feature)
    641     public function ajax_set_image() {
    642         check_ajax_referer( 'nmcm_nonce', 'nonce' );
    643         if ( ! current_user_can( 'manage_woocommerce' ) ) wp_die();
    644         $term_ids = array_map( 'absint', wp_unslash( $_POST['term_ids'] ?? [] ) );
    645         $image_id = absint( wp_unslash( $_POST['image_id'] ?? 0 ) );
    646         if ( empty( $term_ids ) || ! $image_id ) wp_send_json_error( 'Invalid request' );
    647         if ( ! wp_attachment_is_image( $image_id ) ) wp_send_json_error( 'Not an image' );
    648         foreach ( $term_ids as $term_id ) {
    649             update_term_meta( $term_id, 'thumbnail_id', $image_id );
    650         }
    651         $img = wp_get_attachment_image_src( $image_id, 'thumbnail' );
    652         wp_send_json_success( [
    653             'image_id'      => $image_id,
    654             'thumbnail_url' => $img ? $img[0] : '',
    655             'updated'       => count( $term_ids ),
    656         ] );
    657     }
    658 
    659     // AJAX: Remove image (C2 Pro feature)
    660     public function ajax_remove_image() {
    661         check_ajax_referer( 'nmcm_nonce', 'nonce' );
    662         if ( ! current_user_can( 'manage_woocommerce' ) ) wp_die();
    663         $term_ids = array_map( 'absint', wp_unslash( $_POST['term_ids'] ?? [] ) );
    664         if ( empty( $term_ids ) ) wp_send_json_error( 'Invalid request' );
    665         foreach ( $term_ids as $term_id ) {
    666             delete_term_meta( $term_id, 'thumbnail_id' );
    667         }
    668         wp_send_json_success( [ 'removed' => count( $term_ids ) ] );
    669     }
    670 
    671     private function move_products_to_category( $from_id, $to_id ) {
    672         $products = get_posts( [
    673             'post_type'      => 'product',
    674             'posts_per_page' => -1,
    675             'fields'         => 'ids',
    676             'tax_query'      => [ [ 'taxonomy' => 'product_cat', 'field' => 'term_id', 'terms' => $from_id ] ],
    677         ] );
    678         foreach ( $products as $product_id ) {
    679             $current = wp_get_post_terms( $product_id, 'product_cat', [ 'fields' => 'ids' ] );
    680             $new     = array_unique( array_merge( array_diff( $current, [ $from_id ] ), [ $to_id ] ) );
    681             wp_set_post_terms( $product_id, $new, 'product_cat' );
    682         }
    683165    }
    684166
    … …  
    687169        $hidden = NMCM_Helpers::get_hidden_category_ids();
    688170        if ( ! empty( $hidden ) ) {
    689             $args['exclude'] = array_merge( $args['exclude'] ?? [], $hidden );
     171            $args['exclude'] = array_merge( (array) ( $args['exclude'] ?? [] ), $hidden );
    690172        }
    691173        return $args;
    … …  
    720202        return $args;
    721203    }
    722 
    723     public function auto_hide_empty_categories( $terms, $taxonomies, $args ) {
    724         if ( is_admin() ) return $terms;
    725         if ( ! in_array( 'product_cat', (array) $taxonomies, true ) ) return $terms;
    726         if ( ! NMCM_Helpers::auto_hide_empty_enabled() ) return $terms;
    727         return array_values( array_filter( $terms, function( $term ) {
    728             if ( ! is_object( $term ) ) return true;
    729             // C1 Pro: locked categories are exempt from auto-hide
    730             if ( NMCM_Helpers::is_category_locked( $term->term_id ) ) return true;
    731             return $term->count > 0;
    732         } ) );
    733     }
    734204}
  • netmagic-category-manager-for-woocommerce/trunk/admin/class-catpro-category-search.php

    r3769691 r3803816  
    2222        if ( ! $post || $post->post_type !== 'product' ) return;
    2323
    24         $show_count = get_option( 'catpro_show_product_count', true );
     24        $show_count = (bool) get_option( 'catpro_show_product_count', true );
     25        $counts     = [];
     26        if ( $show_count ) {
     27            global $wpdb;
     28            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
     29            $rows = $wpdb->get_results(
     30                $wpdb->prepare( "SELECT term_id, count FROM {$wpdb->term_taxonomy} WHERE taxonomy = %s", 'product_cat' )
     31            );
     32            foreach ( (array) $rows as $row ) {
     33                $counts[ (int) $row->term_id ] = (int) $row->count;
     34            }
     35        }
    2536
    2637        $css = '
    … …  
    4354            'search_placeholder' => __( 'Search categories...', 'netmagic-category-manager-for-woocommerce' ),
    4455            'no_results'         => __( 'No categories found.', 'netmagic-category-manager-for-woocommerce' ),
    45             'show_count'         => (bool) $show_count,
     56            'show_count'         => $show_count,
     57            'counts'             => (object) $counts,
    4658        ] );
    4759
    … …  
    5668    if (!allPanel) return;
    5769    clearInterval(wcpSearchTimer);
     70
     71    // Remember each category name before anything is added to the label.
     72    allPanel.querySelectorAll('li').forEach(function(li) {
     73        var label = li.querySelector('label');
     74        if (label) { li.setAttribute('data-nmcm-name', label.textContent.trim().toLowerCase()); }
     75    });
     76
     77    // Product count next to each category (setting: Show product count).
     78    if (catproSearch.show_count) {
     79        allPanel.querySelectorAll('li').forEach(function(li) {
     80            var box = li.querySelector('input[type=checkbox]');
     81            var label = li.querySelector('label');
     82            if (!box || !label || label.querySelector('.catpro-cat-count')) { return; }
     83            var n = catproSearch.counts[box.value];
     84            if (typeof n === 'undefined') { return; }
     85            var span = document.createElement('span');
     86            span.className = 'catpro-cat-count';
     87            span.textContent = '(' + n + ')';
     88            label.appendChild(span);
     89        });
     90    }
    5891
    5992    var wrap = document.createElement('div');
    … …  
    80113        items.forEach(function(li) { li.style.display = 'none'; });
    81114        items.forEach(function(li) {
    82             var label = li.querySelector('label');
    83             if (!label) return;
    84             if (label.textContent.toLowerCase().indexOf(v) >= 0) {
     115            var name = li.getAttribute('data-nmcm-name');
     116            if (name === null) {
     117                var label = li.querySelector('label');
     118                if (!label) return;
     119                name = label.textContent.toLowerCase();
     120            }
     121            if (name.indexOf(v) >= 0) {
    85122                li.style.display = '';
    86123                visibleCount++;
  • netmagic-category-manager-for-woocommerce/trunk/includes/class-catpro-helpers.php

    r3769691 r3803816  
    33
    44class NMCM_Helpers {
     5
     6    /** Maximum number of levels read by the Excel import. */
     7    const MAX_IMPORT_DEPTH = 2;
    58
    69    private static function get_table_name() {
    … …  
    2326        require_once ABSPATH . 'wp-admin/includes/upgrade.php';
    2427        dbDelta( $sql );
    25 
    26         // C11 Pro: Audit log table
    27         NMCM_Audit_Log::create_table();
    2828    }
    2929
     30    /**
     31     * All product categories, in the saved manual order when one exists,
     32     * otherwise alphabetically. Categories without a saved position are
     33     * listed after the ordered ones, never dropped.
     34     *
     35     * @param array $args Optional get_terms() arguments.
     36     * @return WP_Term[]
     37     */
    3038    public static function get_all_categories( $args = array() ) {
    3139        $defaults = array(
    3240            'taxonomy'   => 'product_cat',
    3341            'hide_empty' => false,
    34             'orderby'    => 'meta_value_num',
    35             'meta_key'   => 'catpro_order',
     42            'orderby'    => 'name',
    3643            'order'      => 'ASC',
    3744        );
    3845        $args  = wp_parse_args( $args, $defaults );
    3946        $terms = get_terms( $args );
    40         if ( ! is_wp_error( $terms ) ) {
    41             $has_order = false;
    42             foreach ( $terms as $term ) {
    43                 if ( get_term_meta( $term->term_id, 'catpro_order', true ) !== '' ) {
    44                     $has_order = true;
    45                     break;
     47        if ( is_wp_error( $terms ) || ! is_array( $terms ) ) {
     48            return array();
     49        }
     50
     51        $positions = array();
     52        foreach ( $terms as $term ) {
     53            $position = get_term_meta( $term->term_id, 'catpro_order', true );
     54            if ( '' !== $position && false !== $position ) {
     55                $positions[ $term->term_id ] = (int) $position;
     56            }
     57        }
     58        if ( ! empty( $positions ) ) {
     59            usort( $terms, function( $a, $b ) use ( $positions ) {
     60                $pa = isset( $positions[ $a->term_id ] ) ? $positions[ $a->term_id ] : PHP_INT_MAX;
     61                $pb = isset( $positions[ $b->term_id ] ) ? $positions[ $b->term_id ] : PHP_INT_MAX;
     62                if ( $pa === $pb ) {
     63                    return strcasecmp( $a->name, $b->name );
    4664                }
    47             }
    48             if ( ! $has_order ) {
    49                 usort( $terms, function( $a, $b ) {
    50                     return strcmp( $a->name, $b->name );
    51                 } );
    52             }
     65                return $pa < $pb ? -1 : 1;
     66            } );
    5367        }
    5468        return $terms;
    5569    }
    5670
    57     public static function get_category_product_count( $term_id ) {
    58         $term = get_term( $term_id, 'product_cat' );
    59         return $term ? (int) $term->count : 0;
    60     }
    61 
    6271    public static function is_category_hidden( $term_id ) {
    63         global $wpdb;
    64         $table = self::get_table_name();
    65         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
    66         $result = $wpdb->get_var( $wpdb->prepare( 'SELECT id FROM `' . esc_sql( $table ) . '` WHERE term_id = %d', (int) $term_id ) );
    67         return ! is_null( $result );
     72        return in_array( (int) $term_id, self::get_hidden_category_ids(), true );
    6873    }
    6974
    … …  
    9196    }
    9297
     98    /**
     99     * IDs of all manually hidden categories.
     100     *
     101     * @return int[]
     102     */
    93103    public static function get_hidden_category_ids() {
    94104        global $wpdb;
    95105        $table = self::get_table_name();
    96106        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
    97         return $wpdb->get_col( 'SELECT term_id FROM `' . $table . '`' );
     107        $ids = $wpdb->get_col( 'SELECT term_id FROM `' . esc_sql( $table ) . '`' );
     108        return array_map( 'intval', (array) $ids );
    98109    }
    99110
    100     public static function build_category_tree( $parent_id = 0, $depth = 0 ) {
    101         $terms = get_terms( array(
    102             'taxonomy'   => 'product_cat',
    103             'hide_empty' => false,
    104             'parent'     => $parent_id,
    105             'orderby'    => 'name',
    106             'order'      => 'ASC',
    107         ) );
    108         $tree = array();
    109         foreach ( $terms as $term ) {
    110             $tree[] = array(
    111                 'term'     => $term,
    112                 'depth'    => $depth,
    113                 'children' => self::build_category_tree( $term->term_id, $depth + 1 ),
    114                 'hidden'   => self::is_category_hidden( $term->term_id ),
    115             );
     111    /**
     112     * Products in each category including all of its subcategories.
     113     * Read straight from the taxonomy table, once per request.
     114     *
     115     * @return int[] term_id => product count of the whole subtree.
     116     */
     117    public static function get_subtree_counts() {
     118        static $cache = null;
     119        if ( null !== $cache ) {
     120            return $cache;
    116121        }
    117         return $tree;
     122        global $wpdb;
     123        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
     124        $rows = $wpdb->get_results(
     125            $wpdb->prepare( "SELECT term_id, parent, count FROM {$wpdb->term_taxonomy} WHERE taxonomy = %s", 'product_cat' )
     126        );
     127        $own      = [];
     128        $children = [];
     129        foreach ( (array) $rows as $row ) {
     130            $own[ (int) $row->term_id ]         = (int) $row->count;
     131            $children[ (int) $row->parent ][]   = (int) $row->term_id;
     132        }
     133        $cache = [];
     134        $sum   = function( $id, $seen = [] ) use ( &$sum, &$cache, $own, $children ) {
     135            if ( isset( $cache[ $id ] ) ) {
     136                return $cache[ $id ];
     137            }
     138            if ( isset( $seen[ $id ] ) ) {
     139                return 0; // Broken parent loop in the database: do not recurse forever.
     140            }
     141            $seen[ $id ] = true;
     142            $total       = $own[ $id ] ?? 0;
     143            foreach ( $children[ $id ] ?? [] as $child ) {
     144                $total += $sum( $child, $seen );
     145            }
     146            return $cache[ $id ] = $total;
     147        };
     148        foreach ( array_keys( $own ) as $id ) {
     149            $sum( $id );
     150        }
     151        return $cache;
    118152    }
    119153
    120154    public static function get_max_import_depth() {
    121         $depth = (int) get_option( 'catpro_max_import_depth', 4 );
    122         return max( 2, min( 10, $depth ) );
     155        return self::MAX_IMPORT_DEPTH;
    123156    }
    124157
    … …  
    126159        return sanitize_text_field( trim( $name ) );
    127160    }
    128 
    129     public static function auto_hide_empty_enabled() {
    130         return (bool) get_option( 'catpro_auto_hide_empty', false );
    131     }
    132 
    133     /**
    134      * C1 Pro: Manual category lock.
    135      * A locked category is excluded from auto-hide (it stays as-is).
    136      * The lock state is stored in term meta.
    137      */
    138 
    139     public static function is_category_locked( $term_id ) {
    140         return (bool) get_term_meta( (int) $term_id, 'catpro_locked', true );
    141     }
    142 
    143     public static function lock_category( $term_id ) {
    144         update_term_meta( (int) $term_id, 'catpro_locked', 1 );
    145     }
    146 
    147     public static function unlock_category( $term_id ) {
    148         delete_term_meta( (int) $term_id, 'catpro_locked' );
    149     }
    150 
    151     /**
    152      * C5 Pro: Get recursive product counts for all product_cat terms.
    153      *
    154      * WordPress term->count is direct-only. This method walks the full tree
    155      * and returns an array of [ term_id => recursive_count ]. A category
    156      * with count 0 is a "ghost" — no products anywhere in its subtree.
    157      *
    158      * @return int[] Associative array keyed by term_id.
    159      */
    160     public static function get_all_recursive_counts() {
    161         $terms = get_terms( [
    162             'taxonomy'   => 'product_cat',
    163             'hide_empty' => false,
    164             'fields'     => 'all',
    165         ] );
    166 
    167         if ( is_wp_error( $terms ) || empty( $terms ) ) {
    168             return [];
    169         }
    170 
    171         // Build a parent → children map and store direct counts
    172         $direct_counts = [];
    173         $children_map  = [];
    174         foreach ( $terms as $term ) {
    175             $direct_counts[ $term->term_id ] = (int) $term->count;
    176             if ( ! isset( $children_map[ $term->parent ] ) ) {
    177                 $children_map[ $term->parent ] = [];
    178             }
    179             $children_map[ $term->parent ][] = $term->term_id;
    180         }
    181 
    182         // Recursive sum function
    183         $recursive_counts = [];
    184         $sum = null;
    185         $sum = function( $term_id ) use ( &$sum, &$recursive_counts, $direct_counts, $children_map ) {
    186             if ( isset( $recursive_counts[ $term_id ] ) ) {
    187                 return $recursive_counts[ $term_id ];
    188             }
    189             $total = isset( $direct_counts[ $term_id ] ) ? $direct_counts[ $term_id ] : 0;
    190             if ( ! empty( $children_map[ $term_id ] ) ) {
    191                 foreach ( $children_map[ $term_id ] as $child_id ) {
    192                     $total += $sum( $child_id );
    193                 }
    194             }
    195             $recursive_counts[ $term_id ] = $total;
    196             return $total;
    197         };
    198 
    199         foreach ( $terms as $term ) {
    200             $sum( $term->term_id );
    201         }
    202 
    203         return $recursive_counts;
    204     }
    205 
    206     /**
    207      * C4a Pro: Structural lock.
    208      * Prevents rename, delete, add-child, drag reorder on a category.
    209      * Stored in term meta as 'catpro_struct_locked'.
    210      */
    211     public static function is_struct_locked( $term_id ) {
    212         return (bool) get_term_meta( (int) $term_id, 'catpro_struct_locked', true );
    213     }
    214 
    215     public static function struct_lock( $term_id ) {
    216         update_term_meta( (int) $term_id, 'catpro_struct_locked', 1 );
    217     }
    218 
    219     public static function struct_unlock( $term_id ) {
    220         delete_term_meta( (int) $term_id, 'catpro_struct_locked' );
    221     }
    222161}
Note: See TracChangeset for help on using the changeset viewer.