class WC_Deduplicator {
// 🧪 Уніфікована перевірка dry-run
public static function is_dry_run(): bool {
return isset($_POST['dry_run']) && $_POST['dry_run'] === '1';
}
// 📝 Логування дій
public static function log_action($lines) {
if (self::is_protected() && !self::is_dry_run()) return;
$upload_dir = wp_upload_dir();
$log_file = $upload_dir['basedir'] . '/deduplicator-log.txt';
try {
$content = implode(PHP_EOL, $lines) . PHP_EOL;
if (is_writable($upload_dir['basedir'])) {
file_put_contents($log_file, $content, FILE_APPEND);
} else {
$fallback = ABSPATH . 'deduplicator-fallback-log.txt';
file_put_contents($fallback, $content, FILE_APPEND);
error_log("Deduplicator fallback log записано в $fallback");
}
} catch (Throwable $e) {
self::log_error('log_action', $e);
}
}
// 🛠️ Логування помилок
public static function log_error($context, Throwable $e) {
$timestamp = current_time('mysql');
error_log("[$timestamp] ❌ [$context] " . $e->getMessage());
}
// 🔍 Вивід кількості
public static function log_and_display_count($label, $count, $type = 'info') {
$timestamp = current_time('mysql');
if (!self::is_protected() || self::is_dry_run()) {
self::log_action(["[$timestamp] 🔍 $label: $count"]);
}
$class = $type === 'error' ? 'notice-error' : ($type === 'warning' ? 'notice-warning' : 'notice-info');
echo "
🔍 $label: $count
";
return $count; // ✅ Додано для передачі результату в UI
}
// 🔍 Пошук дублікатів SKU
public static function get_duplicate_skus() {
global $wpdb;
if (self::is_protected() && !self::is_dry_run()) {
echo "
🔒 Режим захисту активний. Пошук дублікатів SKU заблоковано. Використовуйте dry-run.
";
return [];
}
return $wpdb->get_results("
SELECT meta_value AS sku,
GROUP_CONCAT(DISTINCT post_id ORDER BY post_id SEPARATOR ',') AS product_ids
FROM {$wpdb->prefix}postmeta
WHERE meta_key = '_sku' AND meta_value != ''
AND post_id IN (
SELECT ID FROM {$wpdb->prefix}posts
WHERE post_type = 'product' AND post_status IN ('publish', 'draft')
)
GROUP BY meta_value
HAVING COUNT(*) > 1
");
}
// 🔍 Пошук дублікатів назв
public static function get_duplicate_titles() {
global $wpdb;
if (self::is_protected() && !self::is_dry_run()) {
echo "
🔒 Режим захисту активний. Пошук дублікатів назв заблоковано. Використовуйте dry-run.
";
return [];
}
return $wpdb->get_results("
SELECT post_title,
GROUP_CONCAT(ID ORDER BY ID SEPARATOR ',') AS product_ids
FROM {$wpdb->prefix}posts
WHERE post_type = 'product'
AND post_status IN ('publish', 'draft') AND post_title != ''
GROUP BY post_title
HAVING COUNT(*) > 1
");
}
// ⚡ Видалення товарів (або dry-run)
public static function delete_fast($ids, $preview = false) {
global $wpdb;
if (empty($ids)) return;
$safe_ids = implode(',', array_map('intval', $ids));
if ($preview || self::is_dry_run()) {
self::log_and_display_count('Попередній перегляд товарів для видалення', count($ids), 'warning');
echo "
🧪 Dry-run: " . count($ids) . " товарів буде видалено. ID: $safe_ids
";
return;
}
if (self::is_protected()) {
echo "
🔒 Режим захисту активний. Видалення заблоковано. Використовуйте dry-run.
";
return;
}
$wpdb->query('START TRANSACTION');
$deleted_posts = $wpdb->query("DELETE FROM {$wpdb->prefix}posts WHERE ID IN ($safe_ids)");
$deleted_meta = $wpdb->query("DELETE FROM {$wpdb->prefix}postmeta WHERE post_id IN ($safe_ids)");
if ($deleted_posts === false || $deleted_meta === false) {
$wpdb->query('ROLLBACK');
self::log_action(["❌ Rollback через помилку при видаленні ID: $safe_ids"]);
echo "
";
$names = $wpdb->get_results("
SELECT term_id, name FROM {$wpdb->terms}
WHERE term_id IN (" . implode(',', array_map('intval', $empty)) . ")
");
if ($names) {
echo "
";
}
// 🧠 Обʼєднання категорій
public static function handle_category_merging() {
global $wpdb;
if (!isset($_POST['merge_duplicate_categories'])) return;
$groups = $wpdb->get_results("
SELECT t.name, GROUP_CONCAT(t.term_id) AS ids
FROM {$wpdb->terms} t
JOIN {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id
WHERE tt.taxonomy = 'product_cat'
GROUP BY t.name
HAVING COUNT(*) > 1
");
self::log_and_display_count('Груп категорій з однаковими назвами для обʼєднання', count($groups));
$moved = 0;
$deleted = 0;
$preview = self::is_dry_run();
// 🔒 Захист перед реальним обʼєднанням
if (self::is_protected() && !$preview) {
echo "
🔒 Режим захисту активний. Обʼєднання категорій заблоковано. Використовуйте dry-run.
";
return;
}
foreach ($groups as $group) {
$ids = array_map('intval', explode(',', $group->ids));
$counts = [];
foreach ($ids as $id) {
$count = $wpdb->get_var("
SELECT count FROM {$wpdb->term_taxonomy}
WHERE term_id = $id AND taxonomy = 'product_cat'
");
$counts[$id] = intval($count);
}
arsort($counts);
$main_id = array_key_first($counts);
$to_merge = array_diff($ids, [$main_id]);
if ($preview) {
$names = $wpdb->get_results("
SELECT term_id, name FROM {$wpdb->terms}
WHERE term_id IN (" . implode(',', array_map('intval', $to_merge)) . ")
");
echo "
";
$moved += count($to_merge);
$deleted += count($to_merge);
continue;
}
foreach ($to_merge as $id) {
$wpdb->query("
UPDATE {$wpdb->term_relationships} tr
JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
SET tr.term_taxonomy_id = (
SELECT term_taxonomy_id FROM {$wpdb->term_taxonomy}
WHERE term_id = $main_id AND taxonomy = 'product_cat' LIMIT 1
)
WHERE tt.term_id = $id AND tt.taxonomy = 'product_cat'
");
if (term_exists($id, 'product_cat')) {
wp_delete_term($id, 'product_cat');
}
$deleted++;
}
$moved += count($to_merge);
}
$timestamp = current_time('mysql');
self::log_action(["[$timestamp] 🧠 Категорії обʼєднано: $moved, видалено: $deleted"]);
if ($preview) {
echo "
🧪 Dry-run завершено: $moved категорій буде перенесено, $deleted буде видалено.
";
} else {
echo "
🧠 Перенесено $moved товарів у головні категорії, видалено $deleted дублікатів категорій.
";
}
}
// ⚠️ Видалення записів не типу 'product'
public static function handle_delete() {
global $wpdb;
if (!isset($_POST['delete_non_products'])) return;
try {
$ids = $wpdb->get_col("
SELECT ID FROM {$wpdb->prefix}posts
WHERE post_type != 'product'
");
$count = count($ids);
self::log_and_display_count('Записів не типу product для видалення', $count);
if ($count === 0) {
echo "
";
self::log_action(["[$timestamp] 🧪 Dry-run: $count записів не типу product", "ID: $safe_ids"]);
return;
}
// 🔒 Захист перед реальним видаленням
if (self::is_protected()) {
echo "
🔒 Режим захисту активний. Видалення записів заблоковано. Використовуйте dry-run.
";
return;
}
$wpdb->query('START TRANSACTION');
$deleted_posts = $wpdb->query("DELETE FROM {$wpdb->prefix}posts WHERE ID IN ($safe_ids)");
$deleted_meta = $wpdb->query("DELETE FROM {$wpdb->prefix}postmeta WHERE post_id IN ($safe_ids)");
if ($deleted_posts === false || $deleted_meta === false) {
$wpdb->query('ROLLBACK');
throw new Exception('Помилка при видаленні записів або метаданих');
}
$wpdb->query('COMMIT');
self::log_action([
"[$timestamp] ⚠️ Видалено записів: $count",
"ID: $safe_ids"
]);
echo "
🚫 Помилка при видаленні: " . esc_html($e->getMessage()) . "
";
}
}
// 🔍 Пошук товарів без зображення
public static function handle_count_no_image() {
if (!isset($_POST['count_no_image'])) return;
// 🔒 Захист: дозволити тільки dry-run у SAFE_MODE
if (self::is_protected() && !self::is_dry_run()) {
echo "
🔒 Режим захисту активний. Пошук товарів без зображення заблоковано. Використовуйте dry-run.
🚫 Помилка при пошуку: " . esc_html($e->getMessage()) . "
";
}
}
// 🔒 Перевірка режиму захисту
public static function is_protected(): bool {
return defined('WC_DEDUPLICATOR_SAFE_MODE') && WC_DEDUPLICATOR_SAFE_MODE === true;
}
// 🔍 Пошук товарів без наявності
public static function handle_count_outofstock() {
// ✅ Підтримка обох кнопок: звичайної та dry-run
if (!isset($_POST['count_outofstock']) && !isset($_POST['count_outofstock_dry'])) return;
// ✅ Встановлення dry_run вручну
$_POST['dry_run'] = isset($_POST['count_outofstock_dry']) ? '1' : '0';
// 🔒 Захист: дозволити тільки dry-run у SAFE_MODE
if (self::is_protected() && !self::is_dry_run()) {
echo "
🔒 Режим захисту активний. Пошук товарів без наявності заблоковано. Використовуйте dry-run.
";
return;
}
try {
global $wpdb;
$count = (int) $wpdb->get_var("
SELECT COUNT(*) FROM {$wpdb->prefix}postmeta pm
JOIN {$wpdb->prefix}posts p ON pm.post_id = p.ID
WHERE pm.meta_key = '_stock_status' AND pm.meta_value = 'outofstock'
AND p.post_type = 'product' AND p.post_status IN ('publish', 'draft')
");
self::log_and_display_count('Товарів без наявності', $count);
} catch (Throwable $e) {
self::log_error('handle_count_outofstock', $e);
echo "
🚫 Помилка при пошуку: " . esc_html($e->getMessage()) . "
";
}
}
// 🔍 Пошук товарів без артикулу
public static function handle_count_missing_sku() {
if (!isset($_POST['count_missing_sku'])) return;
// 🔒 Захист: дозволити тільки dry-run у SAFE_MODE
if (self::is_protected() && !self::is_dry_run()) {
echo "
🔒 Режим захисту активний. Пошук товарів без артикулу заблоковано. Використовуйте dry-run.
";
return;
}
try {
global $wpdb;
$count = $wpdb->get_var("
SELECT COUNT(*) FROM {$wpdb->prefix}posts p
LEFT JOIN {$wpdb->prefix}postmeta pm ON p.ID = pm.post_id AND pm.meta_key = '_sku'
WHERE p.post_type = 'product' AND p.post_status IN ('publish', 'draft')
AND (pm.meta_value IS NULL OR pm.meta_value = '')
");
self::log_and_display_count('Товарів без артикулу', $count);
} catch (Throwable $e) {
self::log_error('handle_count_missing_sku', $e);
echo "
🚫 Помилка при пошуку: " . esc_html($e->getMessage()) . "
";
}
}
// 🔍 Пошук дублікатів товарів
public static function handle_count_duplicates() {
if (!isset($_POST['count_duplicates'])) return;
// 🔒 Захист: дозволити тільки dry-run у SAFE_MODE
if (self::is_protected() && !self::is_dry_run()) {
echo "
🔒 Режим захисту активний. Пошук дублікатів товарів заблоковано. Використовуйте dry-run.
🚫 Помилка при пошуку: " . esc_html($e->getMessage()) . "
";
}
}
// 🔍 Пошук дублікатів категорій
public static function handle_count_duplicate_categories() {
global $wpdb;
if (!isset($_POST['count_duplicate_categories'])) return;
// 🔒 Захист: дозволити тільки dry-run у SAFE_MODE
if (self::is_protected() && !self::is_dry_run()) {
echo "
🔒 Режим захисту активний. Пошук дублікатів категорій заблоковано. Використовуйте dry-run.
";
return;
}
try {
$groups = $wpdb->get_results("
SELECT t.name, COUNT(*) AS total
FROM {$wpdb->terms} t
JOIN {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id
WHERE tt.taxonomy = 'product_cat'
GROUP BY t.name
HAVING total > 1
");
self::log_and_display_count('Груп дублікатів категорій', count($groups));
} catch (Throwable $e) {
self::log_error('handle_count_duplicate_categories', $e);
echo "
🚫 Помилка при пошуку: " . esc_html($e->getMessage()) . "
";
}
}
// 🔍 Пошук записів не типу product
public static function handle_count_non_products() {
global $wpdb;
if (!isset($_POST['count_non_products'])) return;
// 🔒 Захист: дозволити тільки dry-run у SAFE_MODE
if (self::is_protected() && !self::is_dry_run()) {
echo "
🔒 Режим захисту активний. Пошук записів не типу product заблоковано. Використовуйте dry-run.
";
return;
}
try {
$count = $wpdb->get_var("
SELECT COUNT(*) FROM {$wpdb->prefix}posts
WHERE post_type != 'product'
");
self::log_and_display_count('Записів не типу product', $count);
} catch (Throwable $e) {
self::log_error('handle_count_non_products', $e);
echo "
🚫 Помилка при пошуку: " . esc_html($e->getMessage()) . "
";
}
}
// ⚡ Видалення товарів без артикулу
public static function handle_missing_sku_fast_deletion() {
global $wpdb;
if (!isset($_POST['delete_missing_sku_fast'])) return;
try {
$ids = $wpdb->get_col("
SELECT p.ID FROM {$wpdb->prefix}posts p
LEFT JOIN {$wpdb->prefix}postmeta pm ON p.ID = pm.post_id AND pm.meta_key = '_sku'
WHERE p.post_type = 'product' AND p.post_status IN ('publish', 'draft')
AND (pm.meta_value IS NULL OR pm.meta_value = '')
");
self::log_and_display_count('Товарів без артикулу для видалення', count($ids));
// 🔒 Захист перед реальним видаленням
if (self::is_protected() && !self::is_dry_run()) {
echo "
🔒 Режим захисту активний. Видалення товарів без артикулу заблоковано. Використовуйте dry-run.
";
}
}
// ⚡ Видалення товарів без фото / без наявності
public static function handle_fast_deletions() {
global $wpdb;
$key = isset($_POST['delete_no_image_fast']) ? 'image' :
(isset($_POST['delete_outofstock_fast']) ? 'stock' : null);
if (!$key) return;
try {
$ids = [];
if ($key === 'image') {
$ids = $wpdb->get_col("
SELECT p.ID FROM {$wpdb->prefix}posts p
LEFT JOIN {$wpdb->prefix}postmeta pm ON p.ID = pm.post_id AND pm.meta_key = '_thumbnail_id'
WHERE p.post_type = 'product' AND p.post_status IN ('publish', 'draft')
AND (pm.meta_value IS NULL OR pm.meta_value = '')
");
self::log_and_display_count('Товарів без зображення для видалення', count($ids));
} else {
$ids = $wpdb->get_col("
SELECT p.ID FROM {$wpdb->prefix}postmeta pm
JOIN {$wpdb->prefix}posts p ON pm.post_id = p.ID
WHERE pm.meta_key = '_stock_status' AND pm.meta_value = 'outofstock'
AND p.post_type = 'product' AND p.post_status IN ('publish', 'draft')
");
self::log_and_display_count('Товарів без наявності для видалення', count($ids));
}
// 🔒 Захист перед реальним видаленням
if (self::is_protected() && !self::is_dry_run()) {
echo "
🔒 Режим захисту активний. Видалення товарів заблоковано. Використовуйте dry-run.
";
}
}
// ⚡ Видалення товарів з найменшою ціною серед дублікатів
public static function handle_low_price_cleanup() {
global $wpdb;
if (!isset($_POST['delete_low_price_duplicates'])) return;
$sku_groups = $wpdb->get_results("
SELECT meta_value AS sku,
GROUP_CONCAT(DISTINCT post_id ORDER BY post_id SEPARATOR ',') AS product_ids
FROM {$wpdb->prefix}postmeta
WHERE meta_key = '_sku' AND meta_value != ''
AND post_id IN (
SELECT ID FROM {$wpdb->prefix}posts
WHERE post_type = 'product' AND post_status IN ('publish', 'draft')
)
GROUP BY meta_value
HAVING COUNT(*) > 1
");
$ids_to_delete = [];
foreach ($sku_groups as $group) {
$ids = explode(',', $group->product_ids);
$prices = [];
foreach ($ids as $id) {
$price = $wpdb->get_var($wpdb->prepare("
SELECT meta_value FROM {$wpdb->prefix}postmeta
WHERE post_id = %d AND meta_key = '_price'
", $id));
$prices[$id] = floatval($price);
}
if (count($prices) <= 1) continue;
asort($prices); // lowest first
$to_delete = array_keys($prices);
array_shift($to_delete); // keep one
$ids_to_delete = array_merge($ids_to_delete, $to_delete);
}
self::log_and_display_count('Товарів з найменшою ціною для видалення', count($ids_to_delete));
self::delete_fast($ids_to_delete, self::is_dry_run());
}
} TAY Товари для дітей – Сторінка 291