get_id(); foreach (array('product_brand', 'pwb-brand', 'yith_product_brand', 'berocket_brand') as $tax) { if (taxonomy_exists($tax)) { $terms = wp_get_post_terms($id, $tax, array('fields' => 'names')); if (is_array($terms) && !empty($terms)) { return (string) $terms[0]; } } } foreach (array('pa_brand', 'pa_marca', 'brand', 'marca') as $attr) { $v = method_exists($product, 'get_attribute') ? (string) $product->get_attribute($attr) : ''; if (trim($v) !== '') { return trim(explode(',', $v)[0]); } } return ''; } /** GTIN: WooCommerce's own field (9.2+), then the usual plugin meta keys. */ function citami_catalog_gtin($product) { if (method_exists($product, 'get_global_unique_id')) { $g = (string) $product->get_global_unique_id(); if (trim($g) !== '') { return trim($g); } } return citami_catalog_meta($product->get_id(), array('_gtin', '_ean', '_wpm_gtin_code', 'hwp_product_gtin', '_ts_gtin', '_barcode')); } /** One product (or variation) object → the platform's shape (lib/commerce/woo.ts). */ function citami_catalog_product_array($product) { $id = $product->get_id(); $images = array(); $image_ids = array_filter(array_merge(array((int) $product->get_image_id()), array_map('intval', (array) $product->get_gallery_image_ids()))); foreach (array_slice(array_values(array_unique($image_ids)), 0, 5) as $img_id) { $url = wp_get_attachment_url($img_id); if ($url) { $images[] = array('url' => (string) $url, 'alt' => (string) get_post_meta($img_id, '_wp_attachment_image_alt', true)); } } $variant_of = function ($p) { $options = array(); if (method_exists($p, 'get_variation_attributes')) { foreach ((array) $p->get_variation_attributes() as $name => $value) { $label = function_exists('wc_attribute_label') ? wc_attribute_label(str_replace('attribute_', '', (string) $name)) : (string) $name; if ((string) $value !== '') { $options[$label] = (string) $value; } } } $price = $p->get_price(); $regular = method_exists($p, 'get_regular_price') ? $p->get_regular_price() : ''; return array( 'id' => (string) $p->get_id(), 'title' => (string) $p->get_name(), 'sku' => (string) $p->get_sku(), 'gtin' => citami_catalog_gtin($p), 'price' => $price === '' ? null : (float) $price, 'compare_at_price' => ($regular !== '' && (float) $regular > (float) $price) ? (float) $regular : null, 'available' => method_exists($p, 'is_in_stock') ? (bool) $p->is_in_stock() : null, 'options' => $options, ); }; $variants = array(); if ($product->is_type('variable') && method_exists($product, 'get_children')) { foreach (array_slice((array) $product->get_children(), 0, 100) as $child_id) { $child = function_exists('wc_get_product') ? wc_get_product($child_id) : null; if ($child) { $variants[] = $variant_of($child); } } } else { $variants[] = $variant_of($product); } $cats = function_exists('wc_get_product_category_list') ? wp_strip_all_tags((string) wc_get_product_category_list($id, ' > ')) : ''; $tags = wp_get_post_terms($id, 'product_tag', array('fields' => 'names')); return array( 'id' => (string) $id, 'title' => (string) $product->get_name(), 'slug' => (string) $product->get_slug(), 'url' => (string) get_permalink($id), 'status' => (string) $product->get_status(), 'description_html' => (string) $product->get_description(), 'brand' => citami_catalog_brand($product), 'product_type' => (string) $product->get_type(), 'category' => $cats, 'tags' => is_array($tags) ? array_values(array_map('strval', $tags)) : array(), 'images' => $images, 'variants' => $variants, 'seo_title' => citami_catalog_meta($id, array(CITAMI_META_TITLE, '_yoast_wpseo_title', 'rank_math_title', '_aioseo_title')), 'seo_description' => citami_catalog_meta($id, array(CITAMI_META_DESC, '_yoast_wpseo_metadesc', 'rank_math_description', '_aioseo_description')), 'rating' => method_exists($product, 'get_average_rating') ? (float) $product->get_average_rating() : null, 'review_count' => method_exists($product, 'get_review_count') ? (int) $product->get_review_count() : null, 'total_sales' => method_exists($product, 'get_total_sales') ? (int) $product->get_total_sales() : 0, 'updated_at' => $product->get_date_modified() ? $product->get_date_modified()->date('c') : '', ); } /** Store-level facts: name, currency, return policy page, shipping zones. */ function citami_catalog_shop_facts() { $refund_page = (int) get_option('woocommerce_refund_returns_page_id', 0); $has_return = $refund_page > 0 && get_post_status($refund_page) === 'publish' && trim(wp_strip_all_tags((string) get_post_field('post_content', $refund_page))) !== ''; $has_shipping = false; if (class_exists('WC_Shipping_Zones')) { $zones = WC_Shipping_Zones::get_zones(); $has_shipping = is_array($zones) && !empty($zones); } $counts = wp_count_posts('product'); return array( 'name' => get_bloginfo('name'), 'domain' => (string) wp_parse_url(home_url('/'), PHP_URL_HOST), 'currency' => function_exists('get_woocommerce_currency') ? get_woocommerce_currency() : '', 'has_return_policy' => $has_return, 'has_shipping_policy' => $has_shipping, 'total_products' => isset($counts->publish) ? (int) $counts->publish : 0, // 2.72.1 — the site's language: what the dashboard writes on the store (guides, rewrites, link sentences) follows it. 'locale' => (string) get_locale(), ); } define('CITAMI_CATALOG_PREV_META', '_citami_prev_description'); // 2.69 — the part of the mini-guide shown BELOW the product grid (criteria, // ideal for, FAQ) and its FAQ for the FAQPage JSON-LD. The term description // keeps only the 2–3 sentence intro above the grid (the block an answer // engine extracts), as in the case studies behind this feature. define('CITAMI_CATALOG_GUIDE_META', '_citami_category_guide'); define('CITAMI_CATALOG_FAQ_META', '_citami_category_faq'); // 2.70 — product rewrites approved in the dashboard: the description is // written on the product, the previous text (and SEO fields) kept in post // meta so "Annulla" puts them back. define('CITAMI_CATALOG_PREV_CONTENT_META', '_citami_prev_content'); define('CITAMI_CATALOG_PREV_SEO_META', '_citami_prev_seo'); define('CITAMI_CATALOG_MAX_CATEGORIES', 300); /** SEO title/description of a term: our meta, then Yoast (option) and Rank Math (term meta). */ function citami_catalog_term_seo($term_id) { $title = ''; $desc = ''; $yoast = get_option('wpseo_taxonomy_meta', array()); if (is_array($yoast) && isset($yoast['product_cat'][$term_id]) && is_array($yoast['product_cat'][$term_id])) { $title = isset($yoast['product_cat'][$term_id]['wpseo_title']) ? (string) $yoast['product_cat'][$term_id]['wpseo_title'] : ''; $desc = isset($yoast['product_cat'][$term_id]['wpseo_desc']) ? (string) $yoast['product_cat'][$term_id]['wpseo_desc'] : ''; } if ($title === '' && function_exists('get_term_meta')) { $title = (string) get_term_meta($term_id, 'rank_math_title', true); } if ($desc === '' && function_exists('get_term_meta')) { $desc = (string) get_term_meta($term_id, 'rank_math_description', true); } return array('title' => trim($title), 'description' => trim($desc)); } /** One product category → the platform's shape (lib/commerce/woo.ts normalizeWooCollection). */ function citami_catalog_collection_array($term) { $term_id = (int) $term->term_id; $seo = citami_catalog_term_seo($term_id); $image = null; $thumb = function_exists('get_term_meta') ? (int) get_term_meta($term_id, 'thumbnail_id', true) : 0; if ($thumb > 0) { $url = wp_get_attachment_url($thumb); if ($url) { $image = array('url' => (string) $url, 'alt' => (string) get_post_meta($thumb, '_wp_attachment_image_alt', true)); } } $ids = array(); $sales = 0; if (function_exists('wc_get_products')) { $products = wc_get_products(array( 'status' => 'publish', 'limit' => 24, 'category' => array($term->slug), 'orderby' => 'meta_value_num', 'meta_key' => 'total_sales', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- WooCommerce's own best-seller meta 'order' => 'DESC', )); foreach (is_array($products) ? $products : array() as $p) { $ids[] = (string) $p->get_id(); $sales += method_exists($p, 'get_total_sales') ? (int) $p->get_total_sales() : 0; } } $link = get_term_link($term); return array( 'id' => (string) $term_id, 'title' => (string) $term->name, 'slug' => (string) $term->slug, 'url' => is_string($link) ? $link : '', 'description_html' => (string) $term->description, 'seo_title' => $seo['title'], 'seo_description' => $seo['description'], 'image' => $image, 'product_count' => isset($term->count) ? (int) $term->count : count($ids), 'product_ids' => $ids, 'parent_id' => isset($term->parent) ? (string) (int) $term->parent : '0', 'total_sales' => $sales, ); } /** All product categories (non-empty first, capped). */ function citami_catalog_collections() { if (!function_exists('taxonomy_exists') || !taxonomy_exists('product_cat')) { return array(); } $terms = get_terms(array('taxonomy' => 'product_cat', 'hide_empty' => false, 'number' => CITAMI_CATALOG_MAX_CATEGORIES, 'orderby' => 'count', 'order' => 'DESC')); if (!is_array($terms)) { return array(); } $out = array(); foreach ($terms as $term) { if (is_object($term) && isset($term->term_id)) { $out[] = citami_catalog_collection_array($term); } } return $out; } /** * 2.68 — writes the category texts the platform sends with the sync: * { collection_changes: [{ id, term_id, description_html, action }] }. * "apply" keeps the previous text in term meta; "revert" writes the text * the platform kept. Each result is reported back. */ function citami_catalog_apply_changes($data) { if (!is_array($data) || empty($data['collection_changes']) || !is_array($data['collection_changes']) || !function_exists('wp_update_term')) { return array(); } $results = array(); foreach (array_slice($data['collection_changes'], 0, 50) as $change) { if (!is_array($change) || empty($change['id']) || !isset($change['term_id'])) { continue; } $term_id = absint($change['term_id']); $action = isset($change['action']) && $change['action'] === 'revert' ? 'revert' : 'apply'; $html = isset($change['description_html']) ? wp_kses_post((string) $change['description_html']) : ''; $guide = isset($change['guide_html']) ? wp_kses_post((string) $change['guide_html']) : ''; $faq = array(); if (isset($change['faq']) && is_array($change['faq'])) { foreach (array_slice($change['faq'], 0, 10) as $f) { if (is_array($f) && !empty($f['q']) && !empty($f['a'])) { $faq[] = array('q' => sanitize_text_field((string) $f['q']), 'a' => sanitize_text_field((string) $f['a'])); } } } $term = $term_id > 0 ? get_term($term_id, 'product_cat') : null; if (!$term || is_wp_error($term)) { $results[] = array('id' => (string) $change['id'], 'ok' => false, 'error' => 'term_not_found'); continue; } if ($action === 'apply' && function_exists('update_term_meta')) { // Keep the merchant's text once: a second apply must not overwrite the original. if (get_term_meta($term_id, CITAMI_CATALOG_PREV_META, true) === '') { update_term_meta($term_id, CITAMI_CATALOG_PREV_META, (string) $term->description); } } $res = wp_update_term($term_id, 'product_cat', array('description' => $html)); if (is_wp_error($res)) { $results[] = array('id' => (string) $change['id'], 'ok' => false, 'error' => $res->get_error_message()); continue; } if ($action === 'revert') { if (function_exists('delete_term_meta')) { delete_term_meta($term_id, CITAMI_CATALOG_PREV_META); delete_term_meta($term_id, CITAMI_CATALOG_GUIDE_META); delete_term_meta($term_id, CITAMI_CATALOG_FAQ_META); } } elseif (function_exists('update_term_meta')) { update_term_meta($term_id, CITAMI_CATALOG_GUIDE_META, $guide); update_term_meta($term_id, CITAMI_CATALOG_FAQ_META, $faq); } $results[] = array('id' => (string) $change['id'], 'ok' => true, 'error' => null); do_action('citami_fix_applied', 'category_guide', (string) $term->name); // the activity log } if (!empty($results)) { citami_catalog_request('POST', array('applied' => $results)); } return $results; } add_action('citami_sync_response', 'citami_catalog_apply_changes'); /** * 2.70 — writes the product rewrites the platform sends with the sync: * { product_changes: [{ id, product_id, description_html, seo_title, * seo_description, action }] }. "apply" keeps the previous content and * SEO fields once; "revert" writes the previous text back. Reported. */ function citami_catalog_apply_product_changes($data) { if (!is_array($data) || empty($data['product_changes']) || !is_array($data['product_changes']) || !function_exists('wp_update_post')) { return array(); } $results = array(); foreach (array_slice($data['product_changes'], 0, 50) as $change) { if (!is_array($change) || empty($change['id']) || !isset($change['product_id'])) { continue; } $post_id = absint($change['product_id']); $action = isset($change['action']) && $change['action'] === 'revert' ? 'revert' : 'apply'; $html = isset($change['description_html']) ? wp_kses_post((string) $change['description_html']) : ''; $post = $post_id > 0 ? get_post($post_id) : null; if (!$post || $post->post_type !== 'product') { $results[] = array('id' => (string) $change['id'], 'ok' => false, 'error' => 'product_not_found'); continue; } if ($action === 'apply' && get_post_meta($post_id, CITAMI_CATALOG_PREV_CONTENT_META, true) === '') { update_post_meta($post_id, CITAMI_CATALOG_PREV_CONTENT_META, (string) $post->post_content); update_post_meta($post_id, CITAMI_CATALOG_PREV_SEO_META, array( 'title' => (string) get_post_meta($post_id, CITAMI_META_TITLE, true), 'description' => (string) get_post_meta($post_id, CITAMI_META_DESC, true), )); } $r = wp_update_post(array('ID' => $post_id, 'post_content' => $html), true); if (is_wp_error($r)) { $results[] = array('id' => (string) $change['id'], 'ok' => false, 'error' => $r->get_error_message()); continue; } if (!empty($change['seo_title']) || !empty($change['seo_description'])) { if (!empty($change['seo_title'])) { update_post_meta($post_id, CITAMI_META_TITLE, sanitize_text_field((string) $change['seo_title'])); } if (!empty($change['seo_description'])) { update_post_meta($post_id, CITAMI_META_DESC, sanitize_text_field((string) $change['seo_description'])); } } if ($action === 'revert') { delete_post_meta($post_id, CITAMI_CATALOG_PREV_CONTENT_META); delete_post_meta($post_id, CITAMI_CATALOG_PREV_SEO_META); } $results[] = array('id' => (string) $change['id'], 'ok' => true, 'error' => null); do_action('citami_fix_applied', 'product_rewrite', (string) $post->post_title); // the activity log } if (!empty($results)) { citami_catalog_request('POST', array('applied_products' => $results)); } return $results; } add_action('citami_sync_response', 'citami_catalog_apply_product_changes'); /** The below-the-grid guide of a category (HTML) and its FAQ, or null when none. */ function citami_catalog_guide_for_term($term_id) { if (!function_exists('get_term_meta')) { return null; } $guide = (string) get_term_meta((int) $term_id, CITAMI_CATALOG_GUIDE_META, true); if (trim($guide) === '') { return null; } $faq = get_term_meta((int) $term_id, CITAMI_CATALOG_FAQ_META, true); return array('html' => $guide, 'faq' => is_array($faq) ? $faq : array()); } /** Markup printed under the product grid: the guide and, when it has FAQ, the FAQPage JSON-LD (same questions as the visible text). */ function citami_catalog_guide_markup($term_id) { $g = citami_catalog_guide_for_term($term_id); if (!$g) { return ''; } $out = '
' . $g['html'] . '
'; if (!empty($g['faq'])) { $entities = array(); foreach ($g['faq'] as $f) { if (is_array($f) && !empty($f['q']) && !empty($f['a'])) { $entities[] = array('@type' => 'Question', 'name' => (string) $f['q'], 'acceptedAnswer' => array('@type' => 'Answer', 'text' => (string) $f['a'])); } } if (!empty($entities)) { $out .= ''; } } return $out; } /** Prints the guide after the product loop on category archives. */ function citami_catalog_print_guide() { if (!function_exists('is_product_category') || !is_product_category()) { return; } $term = get_queried_object(); if (!$term || !isset($term->term_id)) { return; } echo citami_catalog_guide_markup((int) $term->term_id); // phpcs:ignore WordPress.Security.EscapeOutput -- sanitized with wp_kses_post when stored } add_action('woocommerce_after_shop_loop', 'citami_catalog_print_guide', 20); function citami_catalog_request($method, ?array $body = null) { $args = array( 'timeout' => 30, 'headers' => array( 'Authorization' => 'Bearer ' . citami_get_api_key(), 'Content-Type' => 'application/json', 'User-Agent' => 'CitamiConnector/' . CITAMI_PLUGIN_VERSION . '; ' . home_url('/'), ), ); $url = citami_get_endpoint() . '/api/plugin/catalog'; if ($method === 'GET') { $res = wp_remote_get($url, $args); } else { $args['body'] = wp_json_encode($body); $res = wp_remote_post($url, $args); } if (is_wp_error($res)) { return $res; } $code = (int) wp_remote_retrieve_response_code($res); $data = json_decode((string) wp_remote_retrieve_body($res), true); if ($code < 200 || $code >= 300) { return new WP_Error('citami_catalog_http', 'HTTP ' . $code); } return is_array($data) ? $data : array(); } /** One push: best sellers first, up to the plan's limit, in pages. */ function citami_catalog_push_handler() { $summary = array('ts' => time(), 'ok' => false, 'sent' => 0, 'limit' => 0, 'message' => ''); if (!class_exists('WooCommerce') || !function_exists('wc_get_products') || empty(citami_get_api_key())) { $summary['message'] = 'WooCommerce or API key missing'; update_option(CITAMI_CATALOG_LAST_OPT, $summary, false); return $summary; } $cfg = citami_catalog_request('GET'); if (is_wp_error($cfg)) { $summary['message'] = $cfg->get_error_message(); update_option(CITAMI_CATALOG_LAST_OPT, $summary, false); return $summary; } $limit = isset($cfg['limit']) ? max(0, (int) $cfg['limit']) : 50; $summary['limit'] = $limit; $run = gmdate('c'); $shop = citami_catalog_shop_facts(); $page = 1; while ($summary['sent'] < $limit) { $per = min(CITAMI_CATALOG_PAGE, $limit - $summary['sent']); $products = wc_get_products(array( 'status' => 'publish', 'limit' => $per, 'page' => $page, 'orderby' => 'meta_value_num', 'meta_key' => 'total_sales', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- WooCommerce's own best-seller meta 'order' => 'DESC', )); if (!is_array($products) || empty($products)) { break; } $rows = array_map('citami_catalog_product_array', $products); $last = count($products) < $per || $summary['sent'] + count($products) >= $limit; $res = citami_catalog_request('POST', array('run_started_at' => $run, 'final' => $last, 'shop' => $shop, 'products' => $rows)); if (is_wp_error($res)) { $summary['message'] = $res->get_error_message(); break; } $summary['sent'] += count($products); $summary['ok'] = true; if ($last) { break; } $page++; } // 2.68 — the categories, after the products they are scored against. if ($summary['ok']) { $collections = citami_catalog_collections(); if (!empty($collections)) { $res = citami_catalog_request('POST', array('collections' => $collections)); $summary['categories'] = is_wp_error($res) ? 0 : count($collections); } } update_option(CITAMI_CATALOG_LAST_OPT, $summary, false); return $summary; }