Changeset 3803816
- Timestamp:
- 11/15/2026 08:18:28 PM (less than one hour ago)
- Location:
- netmagic-category-manager-for-woocommerce/trunk
- Files:
-
- 3 added
- 6 deleted
- 5 edited
-
admin/class-catpro-admin.php (modified) (8 diffs)
-
admin/class-catpro-audit-log.php (deleted)
-
admin/class-catpro-bulk-import.php (modified) (11 diffs)
-
admin/class-catpro-category-manager.php (modified) (10 diffs)
-
admin/class-catpro-category-search.php (modified) (4 diffs)
-
admin/class-catpro-feed-source.php (deleted)
-
admin/class-catpro-ghost-detector.php (deleted)
-
admin/class-catpro-name-mapper.php (deleted)
-
assets/js/catpro-manager.js (deleted)
-
assets/js/review-notice.js (added)
-
includes/class-catpro-helpers.php (modified) (4 diffs)
-
includes/class-catpro-profiles.php (deleted)
-
includes/class-catpro-review-notice.php (added)
-
includes/nmcm-bootstrap.php (added)
Legend:
- Unmodified
- Added
- Removed
-
netmagic-category-manager-for-woocommerce/trunk/admin/class-catpro-admin.php
r3769691 r3803816 18 18 add_action( 'admin_notices', [ $this, 'show_notices' ] ); 19 19 add_action( 'admin_init', [ $this, 'register_settings' ] ); 20 add_filter( 'plugin_action_links_' . CATPRO_BASENAME, [ $this, 'plugin_action_links' ] ); 20 21 } 21 22 … … 47 48 [ $this, 'render_settings_page' ] 48 49 ); 49 50 // C5 Pro: Ghost Category Detector51 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 Mapper61 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 Log71 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 );79 50 } 80 51 … … 84 55 'product_page_nmcm-import', 85 56 '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',91 57 ]; 92 58 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; 99 60 100 61 wp_enqueue_style( … … 113 74 ); 114 75 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 ) { 117 78 wp_enqueue_script( 118 79 'nmcm-xlsx', … … 123 84 ); 124 85 } 125 126 // C2 Pro: Media Library for bulk image assignment127 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 strings145 '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 strings148 '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 strings158 '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 strings176 'fs_placeholder' => __( 'Display name (e.g. SellGate)', 'netmagic-category-manager-for-woocommerce' ),177 // Bulk import ZIP strings178 '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 script228 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 script245 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 scripts300 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 section305 $("#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 section330 $("#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 upload363 function escapeHtml(str){return String(str).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");}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 // Preview416 $("#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 // Apply450 $("#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 }476 86 } 477 87 478 88 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 ] );491 89 register_setting( 'nmcm_settings', 'catpro_show_product_count', [ 492 90 'type' => 'boolean', … … 494 92 'sanitize_callback' => 'rest_sanitize_boolean', 495 93 ] ); 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 ] );503 94 } 504 95 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; 522 101 } 523 102 … … 563 142 </td> 564 143 </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>588 144 </table> 589 145 <?php submit_button( __( 'Save Changes', 'netmagic-category-manager-for-woocommerce' ) ); ?> … … 591 147 </div> 592 148 593 <!-- C7 Pro: Feed Source configuration -->149 <!-- Pro edition --> 594 150 <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' ); ?> 598 154 </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> 625 160 </div> 626 161 -
netmagic-category-manager-for-woocommerce/trunk/admin/class-catpro-bulk-import.php
r3769691 r3803816 14 14 15 15 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' ] ); 21 18 } 22 19 … … 29 26 <?php 30 27 /* translators: %d: maximum category import depth */ 31 echo esc_html( sprintf( __( 'Upload an Excel (.xlsx) file to create or updatecategories. 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 ) ); 32 29 ?> 33 30 </p> 34 31 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 47 32 <!-- FORMAT INFO --> 48 33 <div class="catpro-card" style="margin-bottom:16px;background:#f0f6fc;border-left:4px solid #2271b1;"> 49 <h3 style="margin-top:0;">📋 <?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;">📋 <?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> 76 47 </div> 77 48 … … 79 50 <div class="catpro-card"> 80 51 <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 📥 <?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 📥 <?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 📤 <?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 📥 <?php esc_html_e( 'Download template', 'netmagic-category-manager-for-woocommerce' ); ?> 54 </button> 96 55 </div> 97 56 … … 150 109 </div> 151 110 </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;">🖼️ <?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 📂 <?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 ▶️ <?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">✕</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>182 111 </div> 183 112 <?php 184 113 $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' => [ 189 118 'wait_xlsx' => __( 'Please wait for the library to finish loading.', 'netmagic-category-manager-for-woocommerce' ), 190 119 'invalid_file' => __( 'Please upload an Excel (.xlsx, .xls) or CSV file.', 'netmagic-category-manager-for-woocommerce' ), … … 192 121 'empty_file' => __( 'The file is empty or has no data rows.', 'netmagic-category-manager-for-woocommerce' ), 193 122 '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' ), 194 124 'checking_existing' => __( 'Checking existing categories...', 'netmagic-category-manager-for-woocommerce' ), 125 'starting' => __( 'Starting...', 'netmagic-category-manager-for-woocommerce' ), 195 126 'created' => __( 'Created', 'netmagic-category-manager-for-woocommerce' ), 196 'updated' => __( 'Updated', 'netmagic-category-manager-for-woocommerce' ),197 127 'skipped' => __( 'Skipped', 'netmagic-category-manager-for-woocommerce' ), 198 128 'errors_label' => __( 'Errors', 'netmagic-category-manager-for-woocommerce' ), … … 200 130 'unique_cats' => __( 'unique categories to create', 'netmagic-category-manager-for-woocommerce' ), 201 131 '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' ), 205 133 'done' => __( 'Done!', 'netmagic-category-manager-for-woocommerce' ), 206 134 'level' => __( 'Level', 'netmagic-category-manager-for-woocommerce' ), … … 208 136 ] ); 209 137 wp_add_inline_script( 'nmcm-admin', 'var nmcmImport = ' . $import_vars . ';' ); 210 211 // C2b: ZIP image import JS — no PHP string interpolation, strings from catpro.strings212 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 ' );271 138 } 272 139 … … 276 143 public function ajax_preview_import() { 277 144 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 } 279 148 280 149 $raw = isset( $_POST['tree'] ) ? wp_unslash( $_POST['tree'] ) : '[]'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized … … 284 153 $existing = []; 285 154 foreach ( $tree as $item ) { 155 if ( ! is_array( $item ) ) continue; 286 156 $name = NMCM_Helpers::sanitize_term_name( $item['name'] ?? '' ); 287 157 if ( ! $name ) continue; … … 300 170 301 171 /** 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. 334 174 */ 335 175 public function ajax_process_import() { 336 176 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 340 181 $raw_batch = isset( $_POST['batch'] ) ? wp_unslash( $_POST['batch'] ) : '[]'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized 341 182 $batch = json_decode( sanitize_textarea_field( $raw_batch ), true ); … … 344 185 $max_depth = NMCM_Helpers::get_max_import_depth(); 345 186 $created = 0; 346 $updated = 0;347 187 $skipped = 0; 348 188 $errors = 0; 349 189 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 } 426 217 wp_send_json_success( [ 427 218 'created' => $created, 428 'updated' => $updated,429 219 'skipped' => $skipped, 430 220 '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 ), 432 227 ] ); 433 228 } 434 435 /**436 * C2b Pro: AJAX — ZIP batch image import437 * 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 above449 $tmp_path = $file['tmp_name'];450 $mime = mime_content_type( $tmp_path );451 452 // Validate: must be a ZIP453 $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 available459 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 uploads469 $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 extensions476 $allowed_ext = [ 'jpg', 'jpeg', 'png', 'gif', 'webp', 'avif' ];477 478 // Get all product_cat slugs for quick lookup: slug → term_id479 $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 subdirectories501 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 sideloading525 $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 uploads533 $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 post542 null543 );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.Discouraged549 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 directory561 $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 }589 229 } -
netmagic-category-manager-for-woocommerce/trunk/admin/class-catpro-category-manager.php
r3769691 r3803816 14 14 15 15 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' ] ); 32 17 33 18 // Admin JS for the category manager page 34 19 add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_manager_scripts' ] ); 35 20 36 // C3 Pro: Profiles — frontend CSS for shortcode list37 add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_frontend_styles' ] );38 39 21 add_filter( 'woocommerce_product_categories_widget_args', [ $this, 'filter_hidden_categories' ] ); 40 22 add_filter( 'get_terms_args', [ $this, 'filter_hidden_from_frontend' ], 10, 2 ); 41 add_filter( 'get_terms', [ $this, 'auto_hide_empty_categories' ], 10, 3 );42 23 } 43 24 … … 46 27 */ 47 28 public function enqueue_manager_scripts( $hook ) { 48 // Only load on the Category Manager page49 // Hook name: product_page_nmcm-manager (registered under edit.php?post_type=product)50 29 if ( 'product_page_nmcm-manager' !== $hook ) { 51 30 return; 52 31 } 53 32 54 // SheetJS vendor library55 wp_enqueue_script(56 'nmcm-xlsx',57 CATPRO_URL . 'assets/js/vendor/xlsx.full.min.js',58 [],59 '0.20.3',60 true61 );62 63 // Main category manager script64 33 wp_enqueue_script( 65 34 'nmcm-manager', 66 35 CATPRO_URL . 'assets/js/nm-category-manager.js', 67 [ 'jquery' , 'nmcm-xlsx', 'media-upload', 'thickbox'],36 [ 'jquery' ], 68 37 CATPRO_VERSION, 69 38 true 70 39 ); 71 40 72 // Build category data for JS73 $ categories = NMCM_Helpers::get_all_categories();41 $hidden_ids = NMCM_Helpers::get_hidden_category_ids(); 42 $subtree = NMCM_Helpers::get_subtree_counts(); 74 43 $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 ) { 83 45 $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 ), 97 53 ]; 98 54 } 99 55 100 56 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' ), 187 70 ], 188 71 ] ); … … 190 73 191 74 public function render_page() { 192 $ categories = NMCM_Helpers::get_all_categories();75 $total = count( NMCM_Helpers::get_all_categories() ); 193 76 ?> 194 77 <div class="wrap catpro-wrap"> 195 <?php196 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>✅ <strong><?php esc_html_e( 'Order saved successfully!', 'netmagic-category-manager-for-woocommerce' ); ?></strong></p>201 </div>202 <?php203 }204 ?>205 78 <h1> 206 79 <?php esc_html_e( 'Category Manager', 'netmagic-category-manager-for-woocommerce' ); ?> … … 208 81 📥 <?php esc_html_e( 'Import from Excel', 'netmagic-category-manager-for-woocommerce' ); ?> 209 82 </a> 210 <button type="button" id="catpro-export-btn" class="page-title-action"> 211 📤 <?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"> 214 84 ➕ <?php esc_html_e( 'New category', 'netmagic-category-manager-for-woocommerce' ); ?> 215 </ button>85 </a> 216 86 </h1> 217 87 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 <?php229 $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 278 88 <div class="catpro-stats-bar"> 279 <span class="catpro-stat-link" data-filter="" >280 📁 <strong id="stat-total"><?php echo count( $categories ); ?></strong>89 <span class="catpro-stat-link" data-filter="" style="cursor:pointer;"> 90 📁 <strong id="stat-total"><?php echo (int) $total; ?></strong> 281 91 <?php esc_html_e( 'total categories', 'netmagic-category-manager-for-woocommerce' ); ?> 282 92 </span> … … 303 113 </select> 304 114 </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 <?php317 // C7 Pro: Feed source bulk assign options318 $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>332 115 </div> 333 116 … … 341 124 <thead> 342 125 <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>347 126 <th class="column-name"><?php esc_html_e( 'Category', 'netmagic-category-manager-for-woocommerce' ); ?></th> 348 127 <th class="column-count" style="cursor:pointer;" id="sort-count"> … … 356 135 </table> 357 136 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> 370 143 </div> 371 372 144 <?php 373 }374 375 // C3 Pro: Frontend CSS for shortcode category list376 public function enqueue_frontend_styles() {377 wp_enqueue_style(378 'catpro-frontend',379 CATPRO_URL . 'assets/css/frontend.css',380 [],381 CATPRO_VERSION382 );383 145 } 384 146 … … 386 148 public function ajax_toggle_hide() { 387 149 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 } 389 153 $term_id = absint( wp_unslash( $_POST['term_id'] ?? 0 ) ); 390 154 $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 } 393 159 if ( $hide ) { 394 160 NMCM_Helpers::hide_category( $term_id ); 395 NMCM_Audit_Log::log( 'hide', $term_id, $term_name, 'visible', 'hidden' );396 161 } else { 397 162 NMCM_Helpers::show_category( $term_id ); 398 NMCM_Audit_Log::log( 'show', $term_id, $term_name, 'hidden', 'visible' );399 163 } 400 164 wp_send_json_success( [ 'hidden' => (bool) $hide, 'term_id' => $term_id ] ); 401 }402 403 // AJAX: Delete404 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 check409 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: Rename427 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 check432 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: Create446 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 blocked452 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: Duplicate462 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 action478 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 lock495 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 lock503 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 unlock507 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 source511 $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 source519 $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 products532 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 categories543 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 parent576 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 parent581 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: Reorder591 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 }683 165 } 684 166 … … 687 169 $hidden = NMCM_Helpers::get_hidden_category_ids(); 688 170 if ( ! empty( $hidden ) ) { 689 $args['exclude'] = array_merge( $args['exclude'] ?? [], $hidden );171 $args['exclude'] = array_merge( (array) ( $args['exclude'] ?? [] ), $hidden ); 690 172 } 691 173 return $args; … … 720 202 return $args; 721 203 } 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-hide730 if ( NMCM_Helpers::is_category_locked( $term->term_id ) ) return true;731 return $term->count > 0;732 } ) );733 }734 204 } -
netmagic-category-manager-for-woocommerce/trunk/admin/class-catpro-category-search.php
r3769691 r3803816 22 22 if ( ! $post || $post->post_type !== 'product' ) return; 23 23 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 } 25 36 26 37 $css = ' … … 43 54 'search_placeholder' => __( 'Search categories...', 'netmagic-category-manager-for-woocommerce' ), 44 55 '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, 46 58 ] ); 47 59 … … 56 68 if (!allPanel) return; 57 69 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 } 58 91 59 92 var wrap = document.createElement('div'); … … 80 113 items.forEach(function(li) { li.style.display = 'none'; }); 81 114 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) { 85 122 li.style.display = ''; 86 123 visibleCount++; -
netmagic-category-manager-for-woocommerce/trunk/includes/class-catpro-helpers.php
r3769691 r3803816 3 3 4 4 class NMCM_Helpers { 5 6 /** Maximum number of levels read by the Excel import. */ 7 const MAX_IMPORT_DEPTH = 2; 5 8 6 9 private static function get_table_name() { … … 23 26 require_once ABSPATH . 'wp-admin/includes/upgrade.php'; 24 27 dbDelta( $sql ); 25 26 // C11 Pro: Audit log table27 NMCM_Audit_Log::create_table();28 28 } 29 29 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 */ 30 38 public static function get_all_categories( $args = array() ) { 31 39 $defaults = array( 32 40 'taxonomy' => 'product_cat', 33 41 'hide_empty' => false, 34 'orderby' => 'meta_value_num', 35 'meta_key' => 'catpro_order', 42 'orderby' => 'name', 36 43 'order' => 'ASC', 37 44 ); 38 45 $args = wp_parse_args( $args, $defaults ); 39 46 $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 ); 46 64 } 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 } ); 53 67 } 54 68 return $terms; 55 69 } 56 70 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 62 71 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 ); 68 73 } 69 74 … … 91 96 } 92 97 98 /** 99 * IDs of all manually hidden categories. 100 * 101 * @return int[] 102 */ 93 103 public static function get_hidden_category_ids() { 94 104 global $wpdb; 95 105 $table = self::get_table_name(); 96 106 // 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 ); 98 109 } 99 110 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; 116 121 } 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; 118 152 } 119 153 120 154 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; 123 156 } 124 157 … … 126 159 return sanitize_text_field( trim( $name ) ); 127 160 } 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 tree155 * and returns an array of [ term_id => recursive_count ]. A category156 * 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 counts172 $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 function183 $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 }222 161 }
Note:
See TracChangeset
for help on using the changeset viewer.