/** * Plugin Name: SV Bridge BE Sender Optimized * Description: Invia dal backend al frontend post, meta, tassonomie e featured image con coda, hash payload, badge admin e mapping attachment backend/frontend, verifica reale hash contenuto FE, badge modifica confermata e ricezione modifiche FE -> BE solo se il post FE è publish. * Version: 5.7.0-state-truth-log24h */ if (!defined('ABSPATH')) exit; class SV_Bridge_BE_Sender_Optimized { protected $is_normalizing_backend_content = false; protected $is_receiving_frontend_sync = false; const QUEUE_OPTION = 'sv_bridge_be_queue'; const CRON_HOOK = 'sv_bridge_be_process_queue'; const META_LAST_HASH = '_sv_bridge_last_payload_hash'; const META_LAST_SENT_HASH = '_sv_bridge_last_sent_payload_hash'; const META_LAST_TRY = '_sv_bridge_last_try'; const META_LAST_ERROR = '_sv_bridge_last_error'; const META_LAST_RESPONSE_CODE = '_sv_bridge_last_response_code'; const META_LAST_RESPONSE_BODY = '_sv_bridge_last_response_body'; const META_LAST_DIAG = '_sv_bridge_last_diag'; const META_QUEUE_STATUS = '_sv_bridge_queue_status'; const META_QUEUE_AT = '_sv_bridge_queue_at'; const META_DELIVERED = '_sv_bridge_delivered_to_frontend'; const META_DELIVERED_AT = '_sv_bridge_delivered_to_frontend_at'; const META_FEATURED_FAIL = '_sv_bridge_featured_sync_failed'; const META_FEATURED_FAIL_MSG = '_sv_bridge_featured_sync_failed_msg'; const META_WAITING_FEATURED_SINCE = '_sv_bridge_waiting_featured_since'; const META_REMOTE_POST_ID = '_sv_bridge_remote_post_id'; const META_REMOTE_STATUS = '_sv_bridge_remote_status'; const META_REMOTE_TITLE = '_sv_bridge_remote_title'; const META_REMOTE_LAST_SYNC = '_sv_bridge_remote_last_sync'; const META_REMOTE_FEATURED_OK = '_sv_bridge_remote_featured_ok'; const META_REMOTE_LEGACY_LINKED_AT = '_sv_bridge_remote_legacy_linked_at'; const META_REMOTE_LAST_VERIFIED_AT = '_sv_bridge_remote_last_verified_at'; const META_REMOTE_CONTENT_VERIFIED = '_sv_bridge_remote_content_verified'; const META_REMOTE_CONTENT_HASH = '_sv_bridge_remote_content_hash'; const META_REMOTE_EXPECTED_CONTENT_HASH = '_sv_bridge_remote_expected_content_hash'; const META_REMOTE_TITLE_HASH = '_sv_bridge_remote_title_hash'; const META_REMOTE_EXCERPT_HASH = '_sv_bridge_remote_excerpt_hash'; const META_REMOTE_VERIFICATION_MSG = '_sv_bridge_remote_verification_msg'; const META_REMOTE_POST_TYPE = '_sv_bridge_remote_post_type'; const META_REMOTE_IS_ATTACHMENT = '_sv_bridge_remote_is_attachment'; const META_LAST_UPDATE_FROM_FE_AT = '_sv_bridge_last_update_from_fe_at'; const META_LAST_FE_SYNC_STATUS = '_sv_bridge_last_fe_sync_status'; const META_LAST_FE_SYNC_MSG = '_sv_bridge_last_fe_sync_msg'; const META_LAST_FE_POST_ID = '_sv_bridge_last_fe_post_id'; const META_LAST_FE_CHANGED_FIELDS = '_sv_bridge_last_fe_changed_fields'; const META_LAST_FE_SYNCED_META_KEYS = '_sv_bridge_last_fe_synced_meta_keys'; const META_LAST_FE_SYNCED_TAXONOMIES = '_sv_bridge_last_fe_synced_taxonomies'; const LOCK_PREFIX = 'sv_bridge_be_lock_'; const META_ARTICLE_LOG = '_sv_bridge_article_state_log'; const GLOBAL_LOG_OPTION = 'sv_bridge_be_global_log_24h'; const LOG_CLEANUP_TRANSIENT = 'sv_bridge_be_log_cleanup_lock'; const SCHEDULER_VERSION = '5.6.0'; const SCHEDULER_OPTION = 'sv_bridge_be_scheduler_version'; protected $frontend_uploads_base = 'https://www.cronachedellacampania.it/wp-content/uploads'; public function __construct() { add_filter('upload_dir', [$this, 'force_frontend_upload_urls'], 20); add_filter('wp_get_attachment_url', [$this, 'force_attachment_url_to_frontend'], 20, 2); add_filter('wp_calculate_image_srcset', [$this, 'force_srcset_to_frontend'], 20); add_action('rest_api_init', [$this, 'routes']); add_action('save_post', [$this, 'on_save_post'], 20, 3); add_action('added_post_meta', [$this, 'on_thumbnail_meta_changed'], 20, 4); add_action('updated_post_meta', [$this, 'on_thumbnail_meta_changed'], 20, 4); /* * Sync cancellazioni: * - wp_trash_post intercetta lo spostamento nel cestino dal backend. * - transition_post_status copre plugin/codici che impostano direttamente post_status=trash. * - before_delete_post resta come fallback per eliminazione definitiva. */ add_action('wp_trash_post', [$this, 'on_trash_post'], 20); add_action('transition_post_status', [$this, 'on_transition_to_trash'], 20, 3); add_action('transition_post_status', [$this, 'on_transition_from_auto_draft_to_real_status'], 25, 3); add_action('before_delete_post', [$this, 'on_delete_post'], 20); add_action(self::CRON_HOOK, [$this, 'process_queue']); add_action('init', [$this, 'ensure_cron']); add_action('init', [$this, 'cleanup_stale_delivered_queue_items'], 30); add_filter('cron_schedules', [$this, 'add_cron_interval']); add_action('admin_menu', [$this, 'add_settings_page']); add_action('admin_menu', [$this, 'add_observability_page'], 30); add_action('admin_post_sv_bridge_clear_be_log', [$this, 'handle_clear_global_log']); add_action('admin_post_sv_bridge_check_fe_post', [$this, 'handle_check_fe_post']); add_action('init', [$this, 'cleanup_global_log_24h'], 45); add_action('admin_init', [$this, 'maybe_kick_queue_on_admin'], 20); add_action('add_meta_boxes', [$this, 'add_metabox']); add_action('admin_post_sv_bridge_resend_post', [$this, 'handle_manual_resend']); // CSS compatto per la colonna Frontend in edit.php, così il badge non invade celle vicine. add_action('admin_head-edit.php', [$this, 'output_admin_list_css']); add_action('admin_head-post.php', [$this, 'output_admin_list_css']); add_action('admin_head-post-new.php', [$this, 'output_admin_list_css']); add_filter('manage_post_posts_columns', [$this, 'add_admin_column']); add_action('manage_post_posts_custom_column', [$this, 'render_admin_column'], 10, 2); add_filter('manage_page_posts_columns', [$this, 'add_admin_column']); add_action('manage_page_posts_custom_column', [$this, 'render_admin_column'], 10, 2); } protected function endpoint() { return defined('SV_BRIDGE_FE_ENDPOINT') ? SV_BRIDGE_FE_ENDPOINT : ''; } protected function status_endpoint() { $endpoint = $this->endpoint(); if (!$endpoint) return ''; return preg_replace('#/sync/?$#', '/status', $endpoint); } protected function secret() { return defined('SV_BRIDGE_SECRET') ? SV_BRIDGE_SECRET : ''; } public function routes() { register_rest_route('sv-bridge/v1', '/fe-sync', [ 'methods' => 'POST', 'callback' => [$this, 'handle_frontend_sync'], 'permission_callback' => '__return_true', ]); } protected function get_incoming_bridge_key(WP_REST_Request $request) { $headers = $request->get_headers(); $candidates = [ $headers['x-bridge-key'][0] ?? '', $headers['X-Bridge-Key'][0] ?? '', $_SERVER['HTTP_X_BRIDGE_KEY'] ?? '', $_SERVER['REDIRECT_HTTP_X_BRIDGE_KEY'] ?? '', ]; foreach ($candidates as $candidate) { $candidate = trim((string)$candidate); if ($candidate !== '') return $candidate; } return ''; } protected function auth_ok(WP_REST_Request $request) { $secret = $this->secret(); $incoming = $this->get_incoming_bridge_key($request); return ($secret !== '' && $incoming !== '' && hash_equals($secret, $incoming)); } protected function skip_frontend_to_backend_meta_key($key) { if ($this->skip_meta_key($key)) return true; /* * Non sincronizziamo ID media del frontend verso il backend: * i due siti possono avere ID attachment diversi anche se condividono uploads. */ $blocked = ['_thumbnail_id']; return in_array((string)$key, $blocked, true); } protected function sync_frontend_terms_to_backend($post_id, $terms_payload) { $post_id = (int)$post_id; $changed_taxonomies = []; if (!$post_id || !is_array($terms_payload)) return $changed_taxonomies; foreach ($terms_payload as $taxonomy => $terms) { $taxonomy = sanitize_key((string)$taxonomy); if (!$taxonomy || !taxonomy_exists($taxonomy) || !is_array($terms)) continue; $before = wp_get_object_terms($post_id, $taxonomy, ['fields' => 'ids']); if (is_wp_error($before)) $before = []; $before = array_values(array_unique(array_map('intval', (array)$before))); sort($before); $term_ids = []; foreach ($terms as $term_payload) { if (!is_array($term_payload)) continue; $slug = !empty($term_payload['slug']) ? sanitize_title((string)$term_payload['slug']) : ''; $name = !empty($term_payload['name']) ? wp_strip_all_tags((string)$term_payload['name']) : $slug; if ($name === '' && $slug === '') continue; $term = null; if ($slug) $term = get_term_by('slug', $slug, $taxonomy); if (!$term && $name) $term = get_term_by('name', $name, $taxonomy); if (!$term) { $args = []; if ($slug) $args['slug'] = $slug; if (!empty($term_payload['description'])) $args['description'] = wp_kses_post((string)$term_payload['description']); $created = wp_insert_term($name ?: $slug, $taxonomy, $args); if (is_wp_error($created)) continue; $term_ids[] = (int)$created['term_id']; } else { $term_ids[] = (int)$term->term_id; } } $term_ids = array_values(array_unique($term_ids)); $after = $term_ids; sort($after); if (maybe_serialize($before) !== maybe_serialize($after)) { wp_set_object_terms($post_id, $term_ids, $taxonomy, false); $changed_taxonomies[] = $taxonomy; } } return array_values(array_unique($changed_taxonomies)); } protected function sync_frontend_meta_to_backend($post_id, $incoming_meta) { $post_id = (int)$post_id; if (!$post_id || !is_array($incoming_meta)) return []; $synced = []; foreach ($incoming_meta as $key => $vals) { $key = (string)$key; if ($this->skip_frontend_to_backend_meta_key($key)) continue; $values = []; foreach ((array)$vals as $v) { $values[] = maybe_unserialize($v); } $current = get_post_meta($post_id, $key, false); if (maybe_serialize($current) === maybe_serialize($values)) { continue; } delete_post_meta($post_id, $key); foreach ($values as $v) { add_post_meta($post_id, $key, $v); } $synced[] = $key; } return $synced; } public function handle_frontend_sync(WP_REST_Request $request) { if (!$this->auth_ok($request)) { return new WP_REST_Response([ 'success' => false, 'message' => 'Unauthorized', ], 403); } $payload = $request->get_json_params(); if (!is_array($payload)) $payload = []; $entity = !empty($payload['entity']) ? sanitize_key((string)$payload['entity']) : ''; $action = !empty($payload['action']) ? sanitize_key((string)$payload['action']) : ''; $data = !empty($payload['data']) && is_array($payload['data']) ? $payload['data'] : []; if ($entity !== 'post' || $action !== 'frontend_upsert') { return new WP_REST_Response([ 'success' => false, 'message' => 'Azione FE -> BE non supportata', ], 400); } $incoming_post = !empty($data['post']) && is_array($data['post']) ? $data['post'] : []; $frontend_status = !empty($incoming_post['post_status']) ? sanitize_key((string)$incoming_post['post_status']) : ''; if ($frontend_status !== 'publish') { $blocked_backend_id = !empty($data['backend_post_id']) ? (int)$data['backend_post_id'] : 0; if ($blocked_backend_id) $this->add_article_log($blocked_backend_id, 'FE→BE', 'Sync bloccata: FE non publish', ['stato_fe' => $frontend_status ?: 'missing']); return new WP_REST_Response([ 'success' => false, 'message' => 'Sync FE -> BE bloccata: il post sul frontend non risulta publish.', 'frontend_post_id' => !empty($data['frontend_post_id']) ? (int)$data['frontend_post_id'] : 0, 'frontend_status' => $frontend_status ?: 'missing', ], 409); } $backend_post_id = !empty($data['backend_post_id']) ? (int)$data['backend_post_id'] : 0; if (!$backend_post_id && !empty($data['post']['ID'])) { $backend_post_id = (int)$data['post']['ID']; } $post = $backend_post_id ? get_post($backend_post_id) : null; if (!$post || !in_array($post->post_type, ['post', 'page', 'product'], true)) { return new WP_REST_Response([ 'success' => false, 'message' => 'Articolo backend non trovato o post type non valido', 'backend_post_id' => $backend_post_id, ], 404); } $changed_fields = []; $field_labels = [ 'post_title' => 'titolo', 'post_content' => 'contenuto', 'post_excerpt' => 'excerpt', 'post_name' => 'permalink/slug', 'comment_status' => 'stato commenti', 'ping_status' => 'stato ping', 'post_password' => 'password articolo', ]; foreach ($field_labels as $field => $label) { if (!array_key_exists($field, $incoming_post)) continue; $current_value = (string)get_post_field($field, $backend_post_id, 'raw'); if ((string)$incoming_post[$field] !== $current_value) { $changed_fields[] = $label; } } if (isset($incoming_post['menu_order']) && (int)$incoming_post['menu_order'] !== (int)get_post_field('menu_order', $backend_post_id, 'raw')) { $changed_fields[] = 'ordine menu'; } if (!empty($incoming_post['post_status']) && $incoming_post['post_status'] === 'publish' && get_post_status($backend_post_id) !== 'publish') { $changed_fields[] = 'stato articolo → publish'; } $update = ['ID' => $backend_post_id]; foreach (['post_title', 'post_content', 'post_excerpt', 'post_name', 'comment_status', 'ping_status', 'post_password'] as $field) { if (array_key_exists($field, $incoming_post)) { $update[$field] = wp_slash((string)$incoming_post[$field]); } } if (isset($incoming_post['menu_order'])) { $update['menu_order'] = (int)$incoming_post['menu_order']; } // FE -> BE può portare lo stato solo a publish. Stati FE diversi da publish sono bloccati prima. if (!empty($incoming_post['post_status']) && $incoming_post['post_status'] === 'publish') { $update['post_status'] = 'publish'; } $this->is_receiving_frontend_sync = true; $updated = wp_update_post($update, true); if (is_wp_error($updated)) { $this->is_receiving_frontend_sync = false; $this->add_article_log($backend_post_id, 'FE→BE', 'Errore aggiornamento BE da FE', ['errore' => $updated->get_error_message()]); return new WP_REST_Response([ 'success' => false, 'message' => $updated->get_error_message(), ], 500); } $changed_taxonomies = []; if (!empty($data['terms']) && is_array($data['terms'])) { $changed_taxonomies = $this->sync_frontend_terms_to_backend($backend_post_id, $data['terms']); } $synced_meta = []; if (!empty($data['meta']) && is_array($data['meta'])) { $synced_meta = $this->sync_frontend_meta_to_backend($backend_post_id, $data['meta']); } $this->is_receiving_frontend_sync = false; $payload_hash = !empty($data['frontend_hash']) ? sanitize_text_field((string)$data['frontend_hash']) : md5(wp_json_encode($data)); update_post_meta($backend_post_id, self::META_LAST_UPDATE_FROM_FE_AT, current_time('mysql')); update_post_meta($backend_post_id, self::META_LAST_FE_SYNC_STATUS, 'received'); update_post_meta($backend_post_id, self::META_LAST_FE_SYNC_MSG, 'Modifica ricevuta dal frontend e applicata al backend.'); if (!empty($data['frontend_post_id'])) { update_post_meta($backend_post_id, self::META_LAST_FE_POST_ID, (int)$data['frontend_post_id']); } update_post_meta($backend_post_id, self::META_LAST_FE_CHANGED_FIELDS, array_values(array_unique($changed_fields))); update_post_meta($backend_post_id, self::META_LAST_FE_SYNCED_META_KEYS, array_values(array_unique($synced_meta))); update_post_meta($backend_post_id, self::META_LAST_FE_SYNCED_TAXONOMIES, array_values(array_unique($changed_taxonomies))); $this->add_article_log($backend_post_id, 'FE→BE', 'Modifica FE applicata al BE', [ 'frontend_post_id' => !empty($data['frontend_post_id']) ? (int)$data['frontend_post_id'] : 0, 'campi' => implode(',', array_slice(array_values(array_unique($changed_fields)), 0, 8)), 'meta' => implode(',', array_slice(array_values(array_unique($synced_meta)), 0, 8)), 'tax' => implode(',', array_slice(array_values(array_unique($changed_taxonomies)), 0, 8)), ]); update_post_meta($backend_post_id, self::META_LAST_HASH, $payload_hash); update_post_meta($backend_post_id, self::META_LAST_SENT_HASH, $payload_hash); update_post_meta($backend_post_id, self::META_QUEUE_STATUS, 'synced_from_frontend'); update_post_meta($backend_post_id, self::META_LAST_DIAG, 'Modificato nel frontend e sincronizzato al backend. FE post ID: ' . (!empty($data['frontend_post_id']) ? (int)$data['frontend_post_id'] : 0) . '.'); clean_post_cache($backend_post_id); return new WP_REST_Response([ 'success' => true, 'message' => 'Modifica FE applicata al BE', 'backend_post_id' => $backend_post_id, 'frontend_post_id' => !empty($data['frontend_post_id']) ? (int)$data['frontend_post_id'] : 0, 'post_status' => get_post_status($backend_post_id), 'post_title_md5' => md5((string)get_post_field('post_title', $backend_post_id, 'raw')), 'post_content_md5' => md5((string)get_post_field('post_content', $backend_post_id, 'raw')), 'post_excerpt_md5' => md5((string)get_post_field('post_excerpt', $backend_post_id, 'raw')), 'changed_fields' => array_values(array_unique($changed_fields)), 'synced_meta_keys' => $synced_meta, 'synced_taxonomies' => $changed_taxonomies, 'synced_at' => current_time('mysql'), ], 200); } protected function be_to_fe_meta_blacklist_option_name() { return 'sv_bridge_be_to_fe_meta_blacklist'; } protected function normalize_meta_blacklist_input($raw) { if (is_array($raw)) { $parts = $raw; } else { $raw = (string)$raw; $parts = preg_split('/[\r\n,;]+/', $raw); } $out = []; foreach ((array)$parts as $key) { $key = trim((string)$key); if ($key === '') continue; // I metakey WordPress possono contenere underscore, trattini, punti e due punti. $key = preg_replace('/[^A-Za-z0-9_\-\.\:]/', '', $key); if ($key === '') continue; $out[$key] = true; } return array_keys($out); } protected function get_be_to_fe_meta_blacklist() { $list = get_option($this->be_to_fe_meta_blacklist_option_name(), []); return $this->normalize_meta_blacklist_input($list); } protected function is_be_to_fe_meta_blocked($key) { $key = (string)$key; if ($key === '') return false; return in_array($key, $this->get_be_to_fe_meta_blacklist(), true); } protected function be_to_fe_status_mode_option_name() { return 'sv_bridge_be_to_fe_status_mode'; } protected function be_to_fe_selected_statuses_option_name() { return 'sv_bridge_be_to_fe_selected_statuses'; } protected function allowed_bridge_post_statuses() { $statuses = get_post_stati([], 'names'); if (!is_array($statuses) || empty($statuses)) { $statuses = ['publish', 'future', 'draft', 'pending', 'private']; } // Stati tecnici/di sistema che non devono mai creare o aggiornare articoli sul FE. $blocked = ['auto-draft', 'inherit', 'trash']; $out = []; foreach ($statuses as $status) { $status = sanitize_key((string)$status); if ($status === '' || in_array($status, $blocked, true)) continue; $out[$status] = $status; } foreach (['publish', 'future', 'draft', 'pending', 'private'] as $default_status) { if (!isset($out[$default_status])) $out[$default_status] = $default_status; } return array_values($out); } protected function normalize_status_list_input($raw) { if (is_array($raw)) { $parts = $raw; } else { $parts = preg_split('/[\r\n,;]+/', (string)$raw); } $allowed = array_flip($this->allowed_bridge_post_statuses()); $out = []; foreach ((array)$parts as $status) { $status = sanitize_key((string)$status); if ($status === '' || !isset($allowed[$status])) continue; $out[$status] = true; } return array_keys($out); } protected function get_be_to_fe_status_mode() { $mode = get_option($this->be_to_fe_status_mode_option_name(), 'publish_only'); $mode = sanitize_key((string)$mode); if (!in_array($mode, ['all', 'publish_only', 'selected'], true)) $mode = 'all'; return $mode; } protected function get_be_to_fe_selected_statuses() { $list = get_option($this->be_to_fe_selected_statuses_option_name(), ['publish']); $list = $this->normalize_status_list_input($list); return !empty($list) ? $list : ['publish']; } protected function get_be_to_fe_syncable_statuses() { $mode = $this->get_be_to_fe_status_mode(); if ($mode === 'publish_only') return ['publish']; if ($mode === 'selected') return $this->get_be_to_fe_selected_statuses(); return $this->allowed_bridge_post_statuses(); } protected function is_post_status_allowed_for_be_to_fe($status) { $status = sanitize_key((string)$status); if ($status === '' || in_array($status, ['auto-draft', 'inherit', 'trash'], true)) return false; return in_array($status, $this->get_be_to_fe_syncable_statuses(), true); } protected function cleanup_be_queue_after_status_rule_change() { $removed = 0; $queue = $this->get_queue(); if (empty($queue)) return 0; $new_queue = []; foreach ((array)$queue as $item) { $post_id = !empty($item['post_id']) ? (int)$item['post_id'] : 0; if (!$post_id) continue; $post = get_post($post_id); if (!$post || !$this->is_syncable_post_for_frontend($post)) { update_post_meta($post_id, self::META_QUEUE_STATUS, 'blocked_status_removed_from_queue'); update_post_meta($post_id, self::META_LAST_DIAG, 'Coda BE → FE rimossa: lo stato articolo non è consentito dalle impostazioni di sync stati.'); $this->add_article_log($post_id, 'BE→FE', 'Coda rimossa per stato non consentito', [ 'post_status' => $post ? $post->post_status : 'missing', 'allowed_statuses' => implode(',', $this->get_be_to_fe_syncable_statuses()), ]); $removed++; continue; } $new_queue[] = $item; } if ($removed > 0) $this->save_queue($new_queue); return $removed; } public function add_settings_page() { add_options_page( 'SV Bridge Meta Blacklist', 'SV Bridge Meta', 'manage_options', 'sv-bridge-meta-blacklist', [$this, 'render_meta_blacklist_settings_page'] ); } protected function get_distinct_article_metakeys($limit = 300, $offset = 0, $search = '') { global $wpdb; $limit = max(50, min(1000, (int)$limit)); $offset = max(0, (int)$offset); $search = trim((string)$search); $post_types = ['post', 'page', 'product']; $placeholders = implode(',', array_fill(0, count($post_types), '%s')); $where = "WHERE p.post_type IN ($placeholders) AND pm.meta_key <> ''"; $params = $post_types; if ($search !== '') { $where .= " AND pm.meta_key LIKE %s"; $params[] = '%' . $wpdb->esc_like($search) . '%'; } $sql = "SELECT pm.meta_key, COUNT(*) AS total_rows, COUNT(DISTINCT pm.post_id) AS post_count FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id $where GROUP BY pm.meta_key ORDER BY pm.meta_key ASC LIMIT %d OFFSET %d"; $params[] = $limit; $params[] = $offset; $rows = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A); return is_array($rows) ? $rows : []; } protected function count_distinct_article_metakeys($search = '') { global $wpdb; $search = trim((string)$search); $post_types = ['post', 'page', 'product']; $placeholders = implode(',', array_fill(0, count($post_types), '%s')); $where = "WHERE p.post_type IN ($placeholders) AND pm.meta_key <> ''"; $params = $post_types; if ($search !== '') { $where .= " AND pm.meta_key LIKE %s"; $params[] = '%' . $wpdb->esc_like($search) . '%'; } $sql = "SELECT COUNT(*) FROM ( SELECT pm.meta_key FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id $where GROUP BY pm.meta_key ) AS svb_keys"; return (int)$wpdb->get_var($wpdb->prepare($sql, $params)); } protected function cleanup_be_queue_after_meta_blacklist_change($old_list, $new_list) { $old_list = $this->normalize_meta_blacklist_input($old_list); $new_list = $this->normalize_meta_blacklist_input($new_list); $newly_blocked = array_values(array_diff($new_list, $old_list)); if (empty($newly_blocked)) return 0; $removed = 0; $queue = $this->get_queue(); $new_queue = []; foreach ((array)$queue as $item) { $post_id = !empty($item['post_id']) ? (int)$item['post_id'] : 0; if (!$post_id) continue; $keep = true; $post = get_post($post_id); if ($post) { $remote_title_hash = (string)get_post_meta($post_id, self::META_REMOTE_TITLE_HASH, true); $remote_content_hash = (string)get_post_meta($post_id, self::META_REMOTE_CONTENT_HASH, true); $remote_excerpt_hash = (string)get_post_meta($post_id, self::META_REMOTE_EXCERPT_HASH, true); $non_meta_diff = false; if ($remote_title_hash && !hash_equals($remote_title_hash, md5((string)get_post_field('post_title', $post_id, 'raw')))) $non_meta_diff = true; if ($remote_content_hash && !hash_equals($remote_content_hash, md5((string)get_post_field('post_content', $post_id, 'raw')))) $non_meta_diff = true; if ($remote_excerpt_hash && !hash_equals($remote_excerpt_hash, md5((string)get_post_field('post_excerpt', $post_id, 'raw')))) $non_meta_diff = true; if (!$non_meta_diff) { $payload = [ 'entity' => 'post', 'action' => 'upsert', 'data' => $this->build_post($post_id), ]; $filtered_hash = $this->build_payload_hash($payload); update_post_meta($post_id, self::META_LAST_HASH, $filtered_hash); update_post_meta($post_id, self::META_QUEUE_STATUS, 'blocked_meta_removed_from_queue'); update_post_meta($post_id, self::META_LAST_DIAG, 'Coda BE → FE rimossa dopo blacklist metakey: ' . implode(', ', array_slice($newly_blocked, 0, 12)) . '.'); $this->add_article_log($post_id, 'BE→FE', 'Coda rimossa per metakey bloccato', ['metakey' => implode(',', array_slice($newly_blocked, 0, 12))]); $removed++; $keep = false; } } if ($keep) $new_queue[] = $item; } if ($removed > 0) $this->save_queue($new_queue); return $removed; } public function render_meta_blacklist_settings_page() { if (!current_user_can('manage_options')) wp_die('Permessi insufficienti'); $option = $this->be_to_fe_meta_blacklist_option_name(); $saved = $this->get_be_to_fe_meta_blacklist(); $notice = ''; if (!empty($_POST['sv_bridge_save_meta_blacklist'])) { check_admin_referer('sv_bridge_save_be_meta_blacklist'); $old_saved = $saved; $manual = isset($_POST['sv_bridge_meta_blacklist_manual']) ? wp_unslash($_POST['sv_bridge_meta_blacklist_manual']) : ''; $checked = isset($_POST['sv_bridge_meta_blacklist_checked']) ? (array)wp_unslash($_POST['sv_bridge_meta_blacklist_checked']) : []; $new = array_merge($this->normalize_meta_blacklist_input($manual), $this->normalize_meta_blacklist_input($checked)); $new = array_values(array_unique($new)); sort($new, SORT_NATURAL | SORT_FLAG_CASE); update_option($option, $new, false); $saved = $new; $old_status_mode = $this->get_be_to_fe_status_mode(); $old_statuses = $this->get_be_to_fe_selected_statuses(); $status_mode = isset($_POST['sv_bridge_be_to_fe_status_mode']) ? sanitize_key(wp_unslash($_POST['sv_bridge_be_to_fe_status_mode'])) : 'all'; if (!in_array($status_mode, ['all', 'publish_only', 'selected'], true)) $status_mode = 'all'; $selected_statuses = isset($_POST['sv_bridge_be_to_fe_selected_statuses']) ? (array)wp_unslash($_POST['sv_bridge_be_to_fe_selected_statuses']) : []; $selected_statuses = $this->normalize_status_list_input($selected_statuses); if (empty($selected_statuses)) $selected_statuses = ['publish']; update_option($this->be_to_fe_status_mode_option_name(), $status_mode, false); update_option($this->be_to_fe_selected_statuses_option_name(), $selected_statuses, false); $removed = $this->cleanup_be_queue_after_meta_blacklist_change($old_saved, $new); $removed_status = 0; if ($old_status_mode !== $status_mode || maybe_serialize($old_statuses) !== maybe_serialize($selected_statuses)) { $removed_status = $this->cleanup_be_queue_after_status_rule_change(); } $notice = 'Impostazioni SV Bridge salvate.' . ($removed > 0 ? ' Code rimosse/fermate per metakey bloccati: ' . (int)$removed . '.' : '') . ($removed_status > 0 ? ' Code rimosse/fermate per stato non consentito: ' . (int)$removed_status . '.' : ''); } $show_scan = !empty($_GET['sv_bridge_show_metakeys']); $search = isset($_GET['sv_bridge_meta_search']) ? sanitize_text_field(wp_unslash($_GET['sv_bridge_meta_search'])) : ''; $per_page = 300; $paged = isset($_GET['sv_bridge_meta_paged']) ? max(1, (int)$_GET['sv_bridge_meta_paged']) : 1; $offset = ($paged - 1) * $per_page; $rows = $show_scan ? $this->get_distinct_article_metakeys($per_page, $offset, $search) : []; $total_keys = $show_scan ? $this->count_distinct_article_metakeys($search) : 0; $total_pages = $show_scan ? max(1, (int)ceil($total_keys / $per_page)) : 1; $page_url = admin_url('options-general.php?page=sv-bridge-meta-blacklist'); echo '
'; echo '

SV Bridge Meta Blacklist

'; if ($notice) echo '

' . esc_html($notice) . '

'; echo '

Da qui blocchi i metakey che questo backend non deve mandare al frontend durante la sync BE → FE.

'; echo '

Esempio: se inserisci post_views_count o _post_views_count, quei valori non verranno più inviati al FE dal BE.

'; echo '
'; wp_nonce_field('sv_bridge_save_be_meta_blacklist'); $status_mode = $this->get_be_to_fe_status_mode(); $selected_statuses = $this->get_be_to_fe_selected_statuses(); $all_statuses = $this->allowed_bridge_post_statuses(); echo '

Modalità invio articoli BE → FE: stati da sincronizzare

'; echo '

Decidi quali articoli del backend possono entrare in coda ed essere inviati al frontend. auto-draft, trash e inherit restano sempre esclusi per evitare bozze orfane o stati tecnici.

'; echo '
'; echo '

Impostazione attiva: ' . esc_html($status_mode === 'publish_only' ? 'solo publish' : ($status_mode === 'selected' ? 'solo stati selezionati' : 'tutti gli stati editoriali')) . ' — stati effettivi: ' . esc_html(implode(', ', $this->get_be_to_fe_syncable_statuses())) . '

'; echo '

'; echo '

'; echo '
'; foreach ($all_statuses as $status) { echo ''; } echo '
'; echo '

'; echo '

Se cambi questa impostazione, eventuali code BE → FE con stati non più consentiti vengono rimosse automaticamente.

'; echo '
'; echo '

Metakey bloccati BE → FE

'; echo ''; echo '

Puoi separarli anche con virgole. Quando salvi, le code BE → FE legate solo a metakey appena bloccati vengono fermate dove possibile.

'; echo '

Mostra tutti i metakey presenti negli articoli

'; if ($show_scan) { echo '

Metakey trovati negli articoli di questo sito

'; echo '

Lettura diretta da postmeta per post, pagine e prodotti. Totale metakey distinti trovati: ' . (int)$total_keys . '.

'; echo '

'; echo 'Cerca '; if ($search !== '') echo 'Reset ricerca'; echo '

'; if (empty($rows)) { echo '

Nessun metakey trovato.

'; } else { echo '
'; echo ''; foreach ($rows as $row) { $key = isset($row['meta_key']) ? (string)$row['meta_key'] : ''; if ($key === '') continue; $checked = in_array($key, $saved, true) ? ' checked' : ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; } echo '
BloccaMetakeyArticoliRighe meta
' . esc_html($key) . '' . (int)($row['post_count'] ?? 0) . '' . (int)($row['total_rows'] ?? 0) . '
'; if ($total_pages > 1) { echo '

'; if ($paged > 1) echo '← Precedenti '; echo 'Pagina ' . (int)$paged . ' di ' . (int)$total_pages . ''; if ($paged < $total_pages) echo ' Successivi →'; echo '

'; } } } echo '

'; echo '
'; echo '
'; } public function add_cron_interval($schedules) { // Conservato per compatibilità. Non viene più creato un cron ricorrente ogni minuto. return $schedules; } public function ensure_cron() { /* * Migrazione automatica dal vecchio cron ricorrente ogni minuto. * Il worker BE -> FE viene ora creato solo quando la coda contiene lavoro. */ $installed = (string)get_option(self::SCHEDULER_OPTION, ''); if ($installed !== self::SCHEDULER_VERSION) { wp_clear_scheduled_hook(self::CRON_HOOK); update_option(self::SCHEDULER_OPTION, self::SCHEDULER_VERSION, false); } $queue = $this->get_queue(); if (!empty($queue) && !wp_next_scheduled(self::CRON_HOOK)) { wp_schedule_single_event(time() + 10, self::CRON_HOOK); } } public function force_frontend_upload_urls($dirs) { $dirs['baseurl'] = $this->frontend_uploads_base; $dirs['url'] = $this->frontend_uploads_base . $dirs['subdir']; return $dirs; } public function force_attachment_url_to_frontend($url, $post_id) { return preg_replace('#https?://cms\\.cronachedellacampania\\.it/wp-content/uploads#i', $this->frontend_uploads_base, $url); } public function force_srcset_to_frontend($sources) { if (!is_array($sources)) return $sources; foreach ($sources as &$source) { if (!empty($source['url'])) { $source['url'] = preg_replace('#https?://cms\\.cronachedellacampania\\.it/wp-content/uploads#i', $this->frontend_uploads_base, $source['url']); } } return $sources; } protected function skip_meta_key($key) { $blocked_exact = [ '_edit_lock', '_edit_last', '_wp_old_slug', '_wp_trash_meta_status', '_wp_trash_meta_time', self::META_LAST_HASH, self::META_LAST_SENT_HASH, self::META_LAST_TRY, self::META_LAST_ERROR, self::META_LAST_RESPONSE_CODE, self::META_LAST_RESPONSE_BODY, self::META_LAST_DIAG, self::META_QUEUE_STATUS, self::META_QUEUE_AT, self::META_DELIVERED, self::META_DELIVERED_AT, self::META_FEATURED_FAIL, self::META_FEATURED_FAIL_MSG, self::META_WAITING_FEATURED_SINCE, self::META_REMOTE_POST_ID, self::META_REMOTE_STATUS, self::META_REMOTE_TITLE, self::META_REMOTE_LAST_SYNC, self::META_REMOTE_FEATURED_OK, self::META_REMOTE_LEGACY_LINKED_AT, self::META_REMOTE_LAST_VERIFIED_AT, self::META_REMOTE_CONTENT_VERIFIED, self::META_REMOTE_CONTENT_HASH, self::META_REMOTE_EXPECTED_CONTENT_HASH, self::META_REMOTE_TITLE_HASH, self::META_REMOTE_EXCERPT_HASH, self::META_REMOTE_VERIFICATION_MSG, self::META_REMOTE_POST_TYPE, self::META_REMOTE_IS_ATTACHMENT, ]; $blocked_prefix = ['_oembed_', '_sv_bridge_']; if (in_array($key, $blocked_exact, true)) return true; foreach ($blocked_prefix as $prefix) { if (strpos($key, $prefix) === 0) return true; } return false; } protected function diagnose_response($code) { if ((int)$code === 200) return 'Sync riuscita correttamente.'; if ((int)$code === 403) return 'Frontend raggiunto ma richiesta rifiutata (403 Unauthorized).'; if ((int)$code === 404) return 'Endpoint frontend non trovato.'; if ((int)$code === 500) return 'Errore interno sul frontend.'; return 'Risposta inattesa: ' . (int)$code; } protected function build_user($user_id) { $u = get_userdata($user_id); if (!$u) return []; return [ 'ID' => (int)$u->ID, 'user_login' => $u->user_login, 'user_email' => $u->user_email, 'display_name' => $u->display_name, 'first_name' => get_user_meta($u->ID, 'first_name', true), 'last_name' => get_user_meta($u->ID, 'last_name', true), 'role' => !empty($u->roles[0]) ? $u->roles[0] : 'author', ]; } protected function build_term($term_id, $taxonomy) { $t = get_term($term_id, $taxonomy); if (!$t || is_wp_error($t)) return []; return [ 'term_id' => (int)$t->term_id, 'taxonomy' => $taxonomy, 'name' => $t->name, 'slug' => $t->slug, 'description' => $t->description, 'parent' => (int)$t->parent, 'meta' => get_term_meta($t->term_id), ]; } protected function normalize_videopack_shortcodes($content) { $content = (string)$content; if ($content == '') return $content; $content = preg_replace_callback('/\[videopack([^\]]*)\]/i', function ($m) { $attrs = isset($m[1]) ? (string)$m[1] : ''; $attrs = preg_replace('/\s+id\s*=\s*(["\']).*?\1/i', '', $attrs); $attrs = preg_replace('/\s+id\s*=\s*[^\s\]]+/i', '', $attrs); $attrs = trim(preg_replace('/\s+/', ' ', $attrs)); return $attrs !== '' ? '[videopack ' . $attrs . ']' : '[videopack]'; }, $content); $content = preg_replace('/\[videopack\s+\]/i', '[videopack]', $content); return $content; } protected function maybe_normalize_backend_content($post_id, $post) { if ($this->is_normalizing_backend_content || !$post) return; $normalized = $this->normalize_videopack_shortcodes($post->post_content); if ($normalized === (string)$post->post_content) return; $this->is_normalizing_backend_content = true; wp_update_post([ 'ID' => (int)$post_id, 'post_content' => $normalized, ]); $this->is_normalizing_backend_content = false; } protected function scalar_contains_attachment_reference($value, $attachment_ids) { if (is_array($value) || is_object($value)) { foreach ((array)$value as $sub) { if ($this->scalar_contains_attachment_reference($sub, $attachment_ids)) return true; } return false; } if (is_numeric($value) && in_array((int)$value, $attachment_ids, true)) return true; if (is_string($value) && $value !== '') { foreach ($attachment_ids as $aid) { if (preg_match('/(^|[^\d])' . preg_quote((string)$aid, '/') . '([^\d]|$)/', $value)) { return true; } } } return false; } protected function key_looks_media_related($key) { return (bool)preg_match('/(thumb|thumbnail|image|img|video|videopack|poster|cover|fifu|attachment|media|player)/i', (string)$key); } protected function detect_meta_reference_hints($clean_meta, $attachment_ids) { $hints = []; $attachment_ids = array_values(array_unique(array_map('intval', (array)$attachment_ids))); if (empty($attachment_ids)) return $hints; foreach ((array)$clean_meta as $key => $vals) { $reason = []; if ($this->key_looks_media_related($key)) { $reason[] = 'media_like_key'; } foreach ((array)$vals as $raw) { $value = maybe_unserialize($raw); if ($this->scalar_contains_attachment_reference($value, $attachment_ids)) { $reason[] = 'contains_backend_attachment_id'; break; } } if (!empty($reason)) { $hints[$key] = array_values(array_unique($reason)); } } return $hints; } protected function build_attachment($attachment_id) { $a = get_post($attachment_id); if (!$a || $a->post_type !== 'attachment') return []; $a_meta = get_post_meta($attachment_id); $clean_a_meta = []; foreach ($a_meta as $k => $vals) { if ($this->skip_meta_key($k)) continue; if ($this->is_be_to_fe_meta_blocked($k)) continue; $clean_a_meta[$k] = $vals; } $attached_file = get_post_meta($attachment_id, '_wp_attached_file', true); $url = wp_get_attachment_url($attachment_id); $basename = $attached_file ? wp_basename($attached_file) : ($url ? wp_basename(parse_url($url, PHP_URL_PATH)) : ''); return [ 'ID' => (int)$a->ID, 'post_type' => 'attachment', 'post_status' => $a->post_status, 'post_mime_type' => $a->post_mime_type, 'post_title' => $a->post_title, 'post_content' => $this->normalize_videopack_shortcodes($a->post_content), 'post_excerpt' => $a->post_excerpt, 'post_name' => $a->post_name, 'post_date' => $a->post_date, 'post_date_gmt' => $a->post_date_gmt, 'guid' => $url, 'attached_file' => $attached_file, 'file_basename' => $basename, 'meta' => $clean_a_meta, ]; } protected function build_post($post_id) { $p = get_post($post_id); if (!$p) return []; $meta = get_post_meta($post_id); $clean_meta = []; foreach ($meta as $k => $vals) { if ($this->skip_meta_key($k)) continue; if ($this->is_be_to_fe_meta_blocked($k)) continue; $clean_meta[$k] = $vals; } $terms_payload = []; $taxes = get_object_taxonomies($p->post_type, 'names'); foreach ($taxes as $taxonomy) { $terms = wp_get_object_terms($p->ID, $taxonomy, ['hide_empty' => false]); if (is_wp_error($terms) || empty($terms)) continue; $terms_payload[$taxonomy] = []; foreach ($terms as $t) { $terms_payload[$taxonomy][] = $this->build_term($t->term_id, $taxonomy); } } $attachments_payload = []; $attachment_ids = []; $thumb_id = get_post_thumbnail_id($p->ID); if ($thumb_id) $attachment_ids[] = (int)$thumb_id; $attached = get_attached_media('', $p->ID); if (!empty($attached)) { foreach ($attached as $att) { $attachment_ids[] = (int)$att->ID; } } $attachment_ids = array_values(array_unique(array_filter($attachment_ids))); foreach ($attachment_ids as $aid) { $built = $this->build_attachment($aid); if (!empty($built)) $attachments_payload[] = $built; } $featured_url = $thumb_id ? wp_get_attachment_url($thumb_id) : ''; $featured_attached_file = $thumb_id ? get_post_meta($thumb_id, '_wp_attached_file', true) : ''; $featured_basename = $featured_attached_file ? wp_basename($featured_attached_file) : ($featured_url ? wp_basename(parse_url($featured_url, PHP_URL_PATH)) : ''); $normalized_content = $this->normalize_videopack_shortcodes($p->post_content); $meta_reference_hints = $this->detect_meta_reference_hints($clean_meta, $attachment_ids); return [ 'post' => [ 'ID' => (int)$p->ID, 'post_type' => $p->post_type, 'post_title' => $p->post_title, 'post_content' => $normalized_content, 'post_excerpt' => $p->post_excerpt, 'post_status' => $p->post_status, 'post_name' => $p->post_name, 'post_date' => $p->post_date, 'post_date_gmt' => $p->post_date_gmt, 'post_modified' => $p->post_modified, 'post_modified_gmt' => $p->post_modified_gmt, 'menu_order' => (int)$p->menu_order, 'comment_status' => $p->comment_status, 'ping_status' => $p->ping_status, 'post_password' => $p->post_password, 'post_parent' => (int)$p->post_parent, '_thumbnail_id' => $thumb_id ? (int)$thumb_id : 0, '_featured_image_url' => $featured_url, '_featured_file_name' => $featured_basename, '_featured_attached_file' => $featured_attached_file, ], 'author' => $this->build_user($p->post_author), 'terms' => $terms_payload, 'attachments' => $attachments_payload, 'meta' => $clean_meta, 'meta_reference_hints' => $meta_reference_hints, ]; } protected function build_payload_hash($payload) { return md5(wp_json_encode($payload)); } protected function is_syncable_post_for_frontend($post) { if (!$post || !($post instanceof WP_Post)) return false; if (wp_is_post_revision($post->ID)) return false; if (!in_array($post->post_type, ['post', 'page', 'product'], true)) return false; /* * WordPress crea post provvisori con post_status=auto-draft quando si apre * l'editor. Questi NON sono vere bozze editoriali e non devono mai arrivare * sul frontend, altrimenti restano come bozze abbandonate/orfane. */ if (in_array($post->post_status, ['auto-draft', 'inherit', 'trash'], true)) return false; if (!$this->is_post_status_allowed_for_be_to_fe($post->post_status)) return false; $title = trim((string)$post->post_title); $content = trim((string)$post->post_content); $excerpt = trim((string)$post->post_excerpt); // Evita di sincronizzare finte bozze vuote o il titolo tecnico "Auto Draft". if ($post->post_status === 'draft' && $title === '' && $content === '' && $excerpt === '') return false; if ($post->post_status === 'draft' && preg_match('/^auto\s*draft$/i', $title) && $content === '' && $excerpt === '') return false; return true; } protected function cleanup_unsyncable_post_state($post_id, $reason = 'unsyncable') { $post_id = (int)$post_id; if (!$post_id) return; $this->remove_post_from_queue($post_id); update_post_meta($post_id, self::META_QUEUE_STATUS, 'ignored_' . sanitize_key($reason)); update_post_meta($post_id, self::META_LAST_DIAG, 'Sync FE ignorata: ' . $reason . '. Nessuna bozza automatica deve essere creata sul frontend.'); delete_post_meta($post_id, self::META_LAST_ERROR); } protected function should_queue_post($post_id, $post, $payload_hash) { if (!$this->is_syncable_post_for_frontend($post)) { $this->cleanup_unsyncable_post_state($post_id, 'auto_draft_or_empty_draft'); return false; } $last_hash = (string)get_post_meta($post_id, self::META_LAST_HASH, true); update_post_meta($post_id, self::META_LAST_HASH, $payload_hash); return $last_hash !== $payload_hash; } protected function get_queue() { $queue = get_option(self::QUEUE_OPTION, []); return is_array($queue) ? $queue : []; } protected function save_queue($queue) { $queue = array_values((array)$queue); update_option(self::QUEUE_OPTION, $queue, false); // Compatibilità object cache persistente: forza la lettura aggiornata della option. if (function_exists('wp_cache_delete')) { wp_cache_delete(self::QUEUE_OPTION, 'options'); } } protected function remove_post_from_queue($post_id) { $post_id = (int) $post_id; if (!$post_id) return 0; $queue = $this->get_queue(); $new_queue = []; $removed = 0; foreach ($queue as $item) { if (!empty($item['post_id']) && (int)$item['post_id'] === $post_id) { $removed++; continue; } $new_queue[] = $item; } if ($removed > 0) { $this->save_queue($new_queue); } return $removed; } protected function queue_post($post_id, $force = false) { $post_id = (int) $post_id; if (!$post_id) return; $post = get_post($post_id); if (!$this->is_syncable_post_for_frontend($post)) { $this->cleanup_unsyncable_post_state($post_id, 'auto_draft_or_empty_draft'); return; } $queue = $this->get_queue(); $replacement = ['post_id' => $post_id, 'queued_at' => time()]; $new_queue = []; $inserted = false; foreach ($queue as $item) { if (!empty($item['post_id']) && (int)$item['post_id'] === $post_id) { if (!$inserted) { $new_queue[] = $replacement; $inserted = true; } continue; } $new_queue[] = $item; } if (!$inserted) $new_queue[] = $replacement; $this->save_queue($new_queue); update_post_meta($post_id, self::META_QUEUE_STATUS, 'queued'); update_post_meta($post_id, self::META_QUEUE_AT, current_time('mysql')); delete_post_meta($post_id, self::META_LAST_ERROR); $this->add_article_log($post_id, 'BE→FE', 'Messa in coda modifica BE', ['stato' => get_post_status($post_id)]); // Worker one-shot: nessun polling quando la coda è vuota. if (!wp_next_scheduled(self::CRON_HOOK)) { wp_schedule_single_event(time() + 5, self::CRON_HOOK); } } protected function dequeue_first() { $queue = $this->get_queue(); if (empty($queue)) return null; $item = array_shift($queue); $this->save_queue($queue); return $item; } protected function acquire_lock($post_id, $ttl = 120) { $key = self::LOCK_PREFIX . (int)$post_id; if (get_transient($key)) return false; set_transient($key, 1, $ttl); return true; } protected function release_lock($post_id) { delete_transient(self::LOCK_PREFIX . (int)$post_id); } protected function expected_frontend_hashes_from_data($data) { $data = is_array($data) ? $data : []; return [ 'post_content_md5' => md5(isset($data['post_content']) ? (string)$data['post_content'] : ''), 'post_title_md5' => md5(isset($data['post_title']) ? (string)$data['post_title'] : ''), 'post_excerpt_md5' => md5(isset($data['post_excerpt']) ? (string)$data['post_excerpt'] : ''), ]; } protected function remote_hashes_match($remote, $expected) { if (!is_array($remote) || !is_array($expected)) return false; foreach (['post_content_md5', 'post_title_md5', 'post_excerpt_md5'] as $key) { if (!isset($remote[$key]) || !isset($expected[$key])) return false; if (!hash_equals((string)$expected[$key], (string)$remote[$key])) return false; } return true; } protected function remote_bridge_payload_was_received($remote, $expected) { if (!is_array($remote) || !is_array($expected)) return false; $map = [ 'post_content_md5' => ['bridge_expected_content_md5', 'incoming_content_md5', 'last_be_content_md5'], 'post_title_md5' => ['bridge_expected_title_md5', 'incoming_title_md5', 'last_be_title_md5'], 'post_excerpt_md5' => ['bridge_expected_excerpt_md5', 'incoming_excerpt_md5', 'last_be_excerpt_md5'], ]; foreach ($map as $expected_key => $remote_keys) { if (empty($expected[$expected_key])) return false; $matched = false; foreach ($remote_keys as $remote_key) { if (isset($remote[$remote_key]) && hash_equals((string)$expected[$expected_key], (string)$remote[$remote_key])) { $matched = true; break; } } if (!$matched) return false; } return true; } protected function save_remote_status_from_response($post_id, $remote = [], $context = 'sync', $expected_hashes = []) { $post_id = (int)$post_id; if (!$post_id || !is_array($remote)) return; if (isset($remote['post_id'])) update_post_meta($post_id, self::META_REMOTE_POST_ID, (int)$remote['post_id']); if (isset($remote['post_status'])) update_post_meta($post_id, self::META_REMOTE_STATUS, sanitize_key((string)$remote['post_status'])); if (isset($remote['status'])) update_post_meta($post_id, self::META_REMOTE_STATUS, sanitize_key((string)$remote['status'])); if (isset($remote['title'])) update_post_meta($post_id, self::META_REMOTE_TITLE, wp_strip_all_tags((string)$remote['title'])); if (isset($remote['post_type'])) update_post_meta($post_id, self::META_REMOTE_POST_TYPE, sanitize_key((string)$remote['post_type'])); if (isset($remote['is_attachment'])) update_post_meta($post_id, self::META_REMOTE_IS_ATTACHMENT, !empty($remote['is_attachment']) ? 1 : 0); if (isset($remote['post_content_md5'])) update_post_meta($post_id, self::META_REMOTE_CONTENT_HASH, sanitize_text_field((string)$remote['post_content_md5'])); if (isset($remote['post_title_md5'])) update_post_meta($post_id, self::META_REMOTE_TITLE_HASH, sanitize_text_field((string)$remote['post_title_md5'])); if (isset($remote['post_excerpt_md5'])) update_post_meta($post_id, self::META_REMOTE_EXCERPT_HASH, sanitize_text_field((string)$remote['post_excerpt_md5'])); if (!empty($expected_hashes['post_content_md5'])) update_post_meta($post_id, self::META_REMOTE_EXPECTED_CONTENT_HASH, sanitize_text_field((string)$expected_hashes['post_content_md5'])); if (!empty($expected_hashes)) { $verified = $this->remote_hashes_match($remote, $expected_hashes); $payload_received = $this->remote_bridge_payload_was_received($remote, $expected_hashes); /* * Differenza importante: * - verified = l'hash reale del post FE coincide al 100% con il payload BE. * - payload_received = il FE ha ricevuto e memorizzato il payload BE, ma qualche hook/plugin del FE * può avere normalizzato il contenuto salvato, producendo un hash finale diverso. * * In questo secondo caso NON dobbiamo lasciare l'articolo in coda infinita: la consegna BE -> FE è riuscita. */ update_post_meta($post_id, self::META_REMOTE_CONTENT_VERIFIED, ($verified || $payload_received) ? 1 : 0); if ($verified) { $msg = 'OK: titolo, contenuto ed excerpt salvati sul FE coincidono con il payload BE.'; } elseif ($payload_received) { $msg = 'OK: il FE ha ricevuto il payload BE. Nota: l\'hash finale del contenuto FE è diverso, probabilmente per normalizzazioni/hook del frontend, ma la sync non resta in coda.'; } else { $msg = 'AVVISO: il FE ha risposto HTTP 200, ma almeno un hash reale tra titolo/contenuto/excerpt non coincide e il FE non ha restituito gli hash di payload ricevuto.'; } update_post_meta($post_id, self::META_REMOTE_VERIFICATION_MSG, $msg); } if (isset($remote['last_sync'])) update_post_meta($post_id, self::META_REMOTE_LAST_SYNC, sanitize_text_field((string)$remote['last_sync'])); if (isset($remote['featured_ok'])) update_post_meta($post_id, self::META_REMOTE_FEATURED_OK, !empty($remote['featured_ok']) ? 1 : 0); if (isset($remote['featured_synced_ok'])) update_post_meta($post_id, self::META_REMOTE_FEATURED_OK, !empty($remote['featured_synced_ok']) ? 1 : 0); if (!empty($remote['legacy_linked_at'])) { update_post_meta($post_id, self::META_REMOTE_LEGACY_LINKED_AT, sanitize_text_field((string)$remote['legacy_linked_at'])); } elseif (!empty($remote['legacy_linked'])) { if (!get_post_meta($post_id, self::META_REMOTE_LEGACY_LINKED_AT, true)) { update_post_meta($post_id, self::META_REMOTE_LEGACY_LINKED_AT, current_time('mysql')); } } update_post_meta($post_id, self::META_REMOTE_LAST_VERIFIED_AT, current_time('mysql')); $remote_id = get_post_meta($post_id, self::META_REMOTE_POST_ID, true); $remote_sync = get_post_meta($post_id, self::META_REMOTE_LAST_SYNC, true); $legacy = get_post_meta($post_id, self::META_REMOTE_LEGACY_LINKED_AT, true); $diag = 'Frontend aggiornato correttamente (' . $context . ').'; if ($remote_id) $diag .= ' ID FE: ' . $remote_id . '.'; if ($remote_sync) $diag .= ' Ultimo sync FE: ' . $remote_sync . '.'; if ($legacy) $diag .= ' Articolo legacy agganciato: ' . $legacy . '.'; $verified = get_post_meta($post_id, self::META_REMOTE_CONTENT_VERIFIED, true); if ($verified !== '') $diag .= ((int)$verified ? ' Modifica contenuto verificata via hash.' : ' ATTENZIONE: modifica contenuto NON verificata via hash.'); if ((int)get_post_meta($post_id, self::META_REMOTE_IS_ATTACHMENT, true)) $diag .= ' ATTENZIONE: il FE ha risposto con un attachment, non con un articolo.'; update_post_meta($post_id, self::META_LAST_DIAG, $diag); } protected function mark_post_delivered($post_id, $context = 'remote_check') { $this->remove_post_from_queue($post_id); update_post_meta($post_id, self::META_DELIVERED, 1); if (!get_post_meta($post_id, self::META_DELIVERED_AT, true)) { update_post_meta($post_id, self::META_DELIVERED_AT, current_time('mysql')); } update_post_meta($post_id, self::META_QUEUE_STATUS, 'sent'); update_post_meta($post_id, self::META_REMOTE_LAST_VERIFIED_AT, current_time('mysql')); delete_post_meta($post_id, self::META_LAST_ERROR); update_post_meta($post_id, self::META_LAST_DIAG, 'Verifica frontend OK (' . $context . ').'); } protected function hard_close_queue_as_sent($post_id, $context = 'frontend_confirmed', $remote = []) { $post_id = (int) $post_id; if (!$post_id) return false; // Pulizia forte: elimina tutte le occorrenze residue nella option queue. $removed = $this->remove_post_from_queue($post_id); update_post_meta($post_id, self::META_DELIVERED, 1); if (!get_post_meta($post_id, self::META_DELIVERED_AT, true)) { update_post_meta($post_id, self::META_DELIVERED_AT, current_time('mysql')); } update_post_meta($post_id, self::META_QUEUE_STATUS, 'sent'); update_post_meta($post_id, self::META_REMOTE_LAST_VERIFIED_AT, current_time('mysql')); delete_post_meta($post_id, self::META_LAST_ERROR); if (is_array($remote)) { if (isset($remote['post_id'])) update_post_meta($post_id, self::META_REMOTE_POST_ID, (int)$remote['post_id']); if (isset($remote['post_status'])) update_post_meta($post_id, self::META_REMOTE_STATUS, sanitize_key((string)$remote['post_status'])); if (isset($remote['status'])) update_post_meta($post_id, self::META_REMOTE_STATUS, sanitize_key((string)$remote['status'])); if (isset($remote['last_sync'])) update_post_meta($post_id, self::META_REMOTE_LAST_SYNC, sanitize_text_field((string)$remote['last_sync'])); if (isset($remote['featured_ok'])) update_post_meta($post_id, self::META_REMOTE_FEATURED_OK, !empty($remote['featured_ok']) ? 1 : 0); if (isset($remote['featured_synced_ok'])) update_post_meta($post_id, self::META_REMOTE_FEATURED_OK, !empty($remote['featured_synced_ok']) ? 1 : 0); } $diag = 'Coda chiusa forzatamente: frontend confermato (' . sanitize_text_field((string)$context) . ').'; if ($removed > 0) $diag .= ' Rimossi ' . (int)$removed . ' residui dalla queue.'; update_post_meta($post_id, self::META_LAST_DIAG, $diag); return true; } public function cleanup_stale_delivered_queue_items() { if (get_transient('sv_bridge_be_cleanup_queue_gate')) return; set_transient('sv_bridge_be_cleanup_queue_gate', 1, 5 * MINUTE_IN_SECONDS); $queue = $this->get_queue(); if (empty($queue)) return; $changed = false; $new_queue = []; $seen = []; $limit = 200; $i = 0; foreach ($queue as $item) { $i++; if ($i > $limit) { $new_queue[] = $item; continue; } $pid = !empty($item['post_id']) ? (int)$item['post_id'] : 0; if (!$pid) { $changed = true; continue; } // Elimina duplicati nella queue. if (isset($seen[$pid])) { $changed = true; continue; } $seen[$pid] = true; $remote_id = (int)get_post_meta($pid, self::META_REMOTE_POST_ID, true); $delivered = (bool)get_post_meta($pid, self::META_DELIVERED, true); $http_code = (int)get_post_meta($pid, self::META_LAST_RESPONSE_CODE, true); $remote_status = (string)get_post_meta($pid, self::META_REMOTE_STATUS, true); $remote_sync = (string)get_post_meta($pid, self::META_REMOTE_LAST_SYNC, true); // Caso reale del tuo log: FE confermato, HTTP 200, ma meta queue rimasta queued. if ($remote_id && ($delivered || ($http_code >= 200 && $http_code < 300)) && ($remote_status || $remote_sync)) { $this->hard_close_queue_as_sent($pid, 'cleanup stale queue'); $changed = true; continue; } $new_queue[] = $item; } if ($changed) { $this->save_queue($new_queue); } } protected function verify_remote_post_exists($post_id, $post_type = 'post') { $status_endpoint = $this->status_endpoint(); $secret = $this->secret(); if (!$status_endpoint || !$secret || !$post_id) return false; $response = wp_remote_get(add_query_arg([ 'origin_post_id' => (int)$post_id, 'post_type' => sanitize_key($post_type ?: 'post'), 't' => time(), // evita cache aggressive/proxy su endpoint status ], $status_endpoint), [ 'timeout' => 15, 'headers' => [ 'x-bridge-key' => $secret, 'Accept' => 'application/json', 'Cache-Control' => 'no-cache', ], ]); if (is_wp_error($response)) { return false; } $code = (int) wp_remote_retrieve_response_code($response); $body = wp_remote_retrieve_body($response); $json = json_decode($body, true); if ($code < 200 || $code >= 300 || !is_array($json) || empty($json['success']) || empty($json['exists'])) { return false; } /* * Non blocchiamo più la riconciliazione solo perché il FE segnala featured_error: * l'articolo può essere già presente e pubblicato. L'errore immagine resta salvato * come diagnostica, ma non deve lasciare residui infiniti in coda. */ return [ 'post_id' => !empty($json['post_id']) ? (int)$json['post_id'] : 0, 'post_status' => !empty($json['post_status']) ? (string)$json['post_status'] : '', 'title' => !empty($json['title']) ? (string)$json['title'] : '', 'received_at' => !empty($json['received_at']) ? (string)$json['received_at'] : '', 'last_sync' => !empty($json['last_sync']) ? (string)$json['last_sync'] : '', 'featured_ok' => isset($json['featured_ok']) ? (bool)$json['featured_ok'] : false, 'featured_error' => !empty($json['featured_error']), 'featured_error_msg' => !empty($json['featured_error_msg']) ? (string)$json['featured_error_msg'] : '', 'legacy_linked' => !empty($json['legacy_linked']), 'legacy_linked_at' => !empty($json['legacy_linked_at']) ? (string)$json['legacy_linked_at'] : '', 'post_type' => !empty($json['post_type']) ? (string)$json['post_type'] : '', 'is_attachment' => !empty($json['is_attachment']), 'post_content_md5' => !empty($json['post_content_md5']) ? (string)$json['post_content_md5'] : '', 'post_title_md5' => !empty($json['post_title_md5']) ? (string)$json['post_title_md5'] : '', 'post_excerpt_md5' => !empty($json['post_excerpt_md5']) ? (string)$json['post_excerpt_md5'] : '', 'post_modified' => !empty($json['post_modified']) ? (string)$json['post_modified'] : '', ]; } protected function maybe_reconcile_with_frontend($post_id, $post_type = 'post') { $remote = $this->verify_remote_post_exists($post_id, $post_type); if (!$remote) return false; $payload = $this->build_post($post_id); $expected_hashes = !empty($payload['post']) ? $this->expected_frontend_hashes_from_data($payload['post']) : []; /* * La sola esistenza del post sul FE non basta per chiudere la coda: * lo stato remoto deve coincidere con quello reale del backend. * In particolare, un publish BE non può essere riconciliato come draft FE. * Il controllo avviene solo quando serve una riconciliazione, quindi non aggiunge scansioni. */ $expected_status = sanitize_key((string)get_post_status($post_id)); $remote_status = !empty($remote['post_status']) ? sanitize_key((string)$remote['post_status']) : ''; if ($expected_status && $remote_status && $expected_status !== $remote_status) { $this->save_remote_status_from_response($post_id, $remote, 'verifica status mismatch', $expected_hashes); update_post_meta($post_id, self::META_LAST_ERROR, 'Status mismatch FE: atteso ' . $expected_status . ', trovato ' . $remote_status); update_post_meta($post_id, self::META_QUEUE_STATUS, 'error'); update_post_meta($post_id, self::META_LAST_DIAG, 'Riconciliazione non chiusa: il frontend esiste ma ha stato ' . $remote_status . ', mentre il backend richiede ' . $expected_status . '. Verrà ritentato il normale invio BE → FE.'); $this->add_article_log($post_id, 'BE→FE', 'Riconciliazione rifiutata per stato diverso', [ 'stato_be' => $expected_status, 'stato_fe' => $remote_status, ]); return false; } $this->hard_close_queue_as_sent($post_id, 'remote_status_status_ok', $remote); $this->save_remote_status_from_response($post_id, $remote, 'verifica status coerente', $expected_hashes); if (!empty($remote['received_at'])) { update_post_meta($post_id, self::META_DELIVERED_AT, $remote['received_at']); } update_post_meta($post_id, self::META_LAST_RESPONSE_CODE, 209); update_post_meta($post_id, self::META_LAST_RESPONSE_BODY, wp_json_encode([ 'success' => true, 'reconciled' => true, 'remote_post_id' => $remote['post_id'], 'remote_status' => $remote['post_status'], 'featured_ok' => $remote['featured_ok'], 'featured_error' => !empty($remote['featured_error']), 'legacy_linked' => !empty($remote['legacy_linked']), 'legacy_linked_at' => !empty($remote['legacy_linked_at']) ? $remote['legacy_linked_at'] : '', ])); return true; } protected function send($entity, $action, $data, $extra = [], $log_post_id = 0) { $endpoint = $this->endpoint(); $secret = $this->secret(); if (!$endpoint || !$secret) { if ($log_post_id) { update_post_meta($log_post_id, self::META_LAST_ERROR, 'Endpoint o secret mancanti'); update_post_meta($log_post_id, self::META_LAST_DIAG, 'Controlla SV_BRIDGE_FE_ENDPOINT e SV_BRIDGE_SECRET'); update_post_meta($log_post_id, self::META_LAST_TRY, current_time('mysql')); update_post_meta($log_post_id, self::META_QUEUE_STATUS, 'error'); } return false; } $payload = array_merge(['entity' => $entity, 'action' => $action, 'data' => $data], $extra); if ($log_post_id) { $this->add_article_log($log_post_id, 'BE→FE', 'Invio HTTP al frontend', [ 'azione' => $action, 'stato_be_inviato' => isset($data['post_status']) ? (string)$data['post_status'] : '', 'titolo' => isset($data['post_title']) ? wp_trim_words(wp_strip_all_tags((string)$data['post_title']), 8, '…') : '', ]); } $expected_hashes = ($entity === 'post' && $action !== 'delete' && is_array($data)) ? $this->expected_frontend_hashes_from_data($data) : []; $response = wp_remote_post($endpoint, [ 'method' => 'POST', 'timeout' => 25, 'headers' => [ 'Content-Type' => 'application/json', 'x-bridge-key' => $secret, 'Cache-Control' => 'no-cache', ], 'body' => wp_json_encode($payload), ]); if (is_wp_error($response)) { if ($log_post_id) { update_post_meta($log_post_id, self::META_LAST_ERROR, $response->get_error_message()); update_post_meta($log_post_id, self::META_LAST_DIAG, 'wp_remote_post fallita'); update_post_meta($log_post_id, self::META_LAST_TRY, current_time('mysql')); update_post_meta($log_post_id, self::META_QUEUE_STATUS, 'error'); $post = get_post($log_post_id); if ($post && $this->maybe_reconcile_with_frontend($log_post_id, $post->post_type)) { return true; } } return false; } $code = (int)wp_remote_retrieve_response_code($response); $body = wp_remote_retrieve_body($response); $json = json_decode($body, true); if ($log_post_id) { $this->add_article_log($log_post_id, 'FE→BE', 'Risposta HTTP dal frontend', [ 'http' => $code, 'success' => is_array($json) && !empty($json['success']) ? '1' : '0', 'stato_fe' => is_array($json) ? (string)($json['status'] ?? ($json['post_status'] ?? '')) : '', 'desired_status_fe' => is_array($json) ? (string)($json['desired_status'] ?? '') : '', 'id_fe' => is_array($json) ? (string)($json['post_id'] ?? '') : '', ]); } if ($log_post_id) { update_post_meta($log_post_id, self::META_LAST_TRY, current_time('mysql')); update_post_meta($log_post_id, self::META_LAST_RESPONSE_CODE, $code); update_post_meta($log_post_id, self::META_LAST_RESPONSE_BODY, wp_strip_all_tags($body)); update_post_meta($log_post_id, self::META_LAST_DIAG, $this->diagnose_response($code)); } if ($code >= 200 && $code < 300) { if ($log_post_id && is_array($json) && !empty($expected_hashes) && !$this->remote_hashes_match($json, $expected_hashes)) { /* * Prima questo caso veniva trattato come errore e rimesso in coda. * È il motivo degli articoli "In coda" pur essendo già pubblicati sul frontend. * Da ora lo trattiamo come warning: salviamo la diagnostica, ma la consegna resta OK * se il FE ha restituito HTTP 200 e ID post valido. */ update_post_meta($log_post_id, self::META_LAST_DIAG, 'Frontend aggiornato correttamente con avviso hash: il FE ha ricevuto il post, ma l\'hash reale finale non coincide al 100%. La coda viene comunque chiusa per evitare retry infiniti.'); $this->save_remote_status_from_response($log_post_id, $json, 'sync hash warning', $expected_hashes); } if ($log_post_id && is_array($json) && $entity === 'post' && $action !== 'delete' && is_array($data)) { $expected_status = !empty($data['post_status']) ? sanitize_key((string)$data['post_status']) : ''; $remote_status = ''; if (isset($json['status'])) $remote_status = sanitize_key((string)$json['status']); if (!$remote_status && isset($json['post_status'])) $remote_status = sanitize_key((string)$json['post_status']); if ($expected_status && $remote_status && $expected_status !== $remote_status) { update_post_meta($log_post_id, self::META_LAST_ERROR, 'Status mismatch FE: atteso ' . $expected_status . ', ricevuto ' . $remote_status); update_post_meta($log_post_id, self::META_QUEUE_STATUS, 'status_mismatch'); update_post_meta($log_post_id, self::META_LAST_DIAG, 'Il frontend ha ricevuto il contenuto, ma lo stato non coincide. Atteso dal BE: ' . $expected_status . '. Stato reale FE: ' . $remote_status . '. La sync resta KO per evitare pubblicazioni o bozze errate.'); $this->save_remote_status_from_response($log_post_id, $json, 'sync status mismatch', $expected_hashes); return false; } } if ($log_post_id && is_array($json) && !empty($json['success']) && !empty($json['post_id'])) { // Il FE ha accettato e salvato il post: chiusura forte della coda, anche se gli hash sono warning. $this->hard_close_queue_as_sent($log_post_id, 'sync confirmed by FE', $json); } if ($log_post_id && is_array($json) && !empty($json['featured_required']) && empty($json['featured_synced_ok'])) { $local_post = get_post($log_post_id); update_post_meta($log_post_id, self::META_FEATURED_FAIL, 1); update_post_meta($log_post_id, self::META_FEATURED_FAIL_MSG, 'Il frontend non ha ancora una featured image valida. Lo stato editoriale del backend NON viene modificato.'); update_post_meta($log_post_id, self::META_QUEUE_STATUS, 'image_failed'); update_post_meta($log_post_id, self::META_LAST_DIAG, 'FE ricevuto ma featured non pronta. Il BE resta nello stato canonico ' . ($local_post ? $local_post->post_status : 'sconosciuto') . '; nessuna retrocessione automatica a draft.'); $this->add_article_log($log_post_id, 'BE→FE', 'Featured FE non pronta: stato BE preservato', [ 'stato_be' => $local_post ? $local_post->post_status : 'missing', 'stato_fe' => (string)($json['status'] ?? ''), ]); $this->save_remote_status_from_response($log_post_id, $json, 'sync image warning', $expected_hashes); return false; } if ($log_post_id) { $this->mark_post_delivered($log_post_id, 'sync'); if (is_array($json)) { $this->save_remote_status_from_response($log_post_id, $json, 'sync', $expected_hashes); } delete_post_meta($log_post_id, self::META_FEATURED_FAIL); delete_post_meta($log_post_id, self::META_FEATURED_FAIL_MSG); } return true; } if ($log_post_id) { update_post_meta($log_post_id, self::META_LAST_ERROR, 'HTTP ' . $code); update_post_meta($log_post_id, self::META_QUEUE_STATUS, 'error'); $post = get_post($log_post_id); if ($post && $this->maybe_reconcile_with_frontend($log_post_id, $post->post_type)) { return true; } } return false; } protected function send_full_post($post_id, $action = 'upsert') { $post = get_post($post_id); if (!$this->is_syncable_post_for_frontend($post)) { $this->cleanup_unsyncable_post_state($post_id, 'auto_draft_or_empty_draft'); return true; } $payload = $this->build_post($post_id); if (empty($payload['post'])) return false; $hash = $this->build_payload_hash($payload); $ok = $this->send('post', $action, $payload['post'], [ 'author' => $payload['author'], 'terms' => $payload['terms'], 'attachments' => $payload['attachments'], 'meta' => $payload['meta'], 'meta_reference_hints' => !empty($payload['meta_reference_hints']) ? $payload['meta_reference_hints'] : [], ], $post_id); if ($ok) { $this->remove_post_from_queue($post_id); update_post_meta($post_id, self::META_LAST_SENT_HASH, $hash); } return $ok; } protected function max_featured_wait_seconds() { if (defined('SV_BRIDGE_FEATURED_WAIT_SECONDS')) { return max(60, (int) SV_BRIDGE_FEATURED_WAIT_SECONDS); } return 10 * MINUTE_IN_SECONDS; } protected function require_featured_for_frontend($post) { if (!$post || $post->post_status !== 'publish') return false; if (!in_array($post->post_type, ['post'], true)) return false; if (defined('SV_BRIDGE_REQUIRE_FEATURED_IMAGE') && !SV_BRIDGE_REQUIRE_FEATURED_IMAGE) return false; return true; } protected function has_backend_featured_image_ready($post_id) { $thumb_id = (int) get_post_thumbnail_id($post_id); if ($thumb_id > 0) { return true; } // Fallback per eventuali plugin tipo FIFU: se c'è un URL esterno immagine, non blocchiamo il sync. $fifu_keys = ['fifu_image_url', '_fifu_image_url', '_external_featured_image', 'external_featured_image']; foreach ($fifu_keys as $key) { $url = trim((string) get_post_meta($post_id, $key, true)); if ($url !== '' && filter_var($url, FILTER_VALIDATE_URL)) { return true; } } return false; } protected function hold_post_waiting_for_featured($post_id, $post) { if (!$this->require_featured_for_frontend($post)) return false; if ($this->has_backend_featured_image_ready($post_id)) { delete_post_meta($post_id, self::META_WAITING_FEATURED_SINCE); return false; } $first_wait = (int) get_post_meta($post_id, self::META_WAITING_FEATURED_SINCE, true); if (!$first_wait) { $first_wait = time(); update_post_meta($post_id, self::META_WAITING_FEATURED_SINCE, $first_wait); } if ((time() - $first_wait) < $this->max_featured_wait_seconds()) { $this->queue_post($post_id, true); update_post_meta($post_id, self::META_QUEUE_STATUS, 'waiting_image'); update_post_meta($post_id, self::META_LAST_DIAG, 'In attesa immagine in evidenza prima di inviare al frontend.'); update_post_meta($post_id, self::META_FEATURED_FAIL_MSG, 'Il post è pubblicato sul backend ma la featured image non è ancora pronta. Sync FE rinviato.'); return true; } update_post_meta($post_id, self::META_QUEUE_STATUS, 'image_missing'); update_post_meta($post_id, self::META_FEATURED_FAIL, 1); update_post_meta($post_id, self::META_FEATURED_FAIL_MSG, 'Post pubblicato sul backend senza immagine in evidenza: invio al frontend bloccato.'); update_post_meta($post_id, self::META_LAST_DIAG, 'Invio FE bloccato: immagine in evidenza ancora assente dopo il tempo massimo di attesa.'); return true; } public function on_thumbnail_meta_changed($meta_id, $post_id, $meta_key, $meta_value) { if ($meta_key !== '_thumbnail_id') return; $post = get_post($post_id); if (!$post || wp_is_post_revision($post_id)) return; if (!in_array($post->post_type, ['post', 'page', 'product'], true)) return; delete_post_meta($post_id, self::META_WAITING_FEATURED_SINCE); delete_post_meta($post_id, self::META_FEATURED_FAIL); delete_post_meta($post_id, self::META_FEATURED_FAIL_MSG); delete_post_meta($post_id, self::META_LAST_SENT_HASH); delete_post_meta($post_id, self::META_LAST_HASH); $this->queue_post($post_id, true); $this->process_single_post($post_id); } public function on_save_post($post_id, $post, $update) { if (!$post || wp_is_post_revision($post_id)) return; if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return; if ($post->post_type === 'revision') return; if ($this->is_normalizing_backend_content) return; if ($this->is_receiving_frontend_sync) return; if (!$this->is_syncable_post_for_frontend($post)) { $this->cleanup_unsyncable_post_state($post_id, 'auto_draft_or_empty_draft'); return; } $this->maybe_normalize_backend_content($post_id, $post); $post = get_post($post_id); if (!$post) return; $payload = $this->build_post($post_id); if (empty($payload['post'])) return; $hash = $this->build_payload_hash($payload); $queue_status = (string)get_post_meta($post_id, self::META_QUEUE_STATUS, true); $delivered = (bool)get_post_meta($post_id, self::META_DELIVERED, true); $needs_force_retry = in_array($queue_status, ['queued', 'error', 'processing'], true) || !$delivered; if ($needs_force_retry) { delete_post_meta($post_id, self::META_LAST_SENT_HASH); $this->queue_post($post_id, true); return; } if ($this->should_queue_post($post_id, $post, $hash)) { $this->queue_post($post_id); } } protected function process_single_post($post_id) { $post_id = (int)$post_id; if (!$post_id) return false; $post = get_post($post_id); if (!$post) return false; if (!$this->is_syncable_post_for_frontend($post)) { $this->cleanup_unsyncable_post_state($post_id, 'auto_draft_or_empty_draft'); return true; } if ($this->hold_post_waiting_for_featured($post_id, $post)) { return false; } if (!$this->acquire_lock($post_id)) return false; update_post_meta($post_id, self::META_QUEUE_STATUS, 'processing'); $ok = $this->send_full_post($post_id, 'upsert'); if (!$ok) { $status = (string)get_post_meta($post_id, self::META_QUEUE_STATUS, true); if (in_array($status, ['error', 'processing'], true)) { $this->queue_post($post_id, true); } } $this->release_lock($post_id); return $ok; } public function process_queue() { if (get_transient('sv_bridge_be_queue_worker_lock')) return; set_transient('sv_bridge_be_queue_worker_lock', 1, 90); try { $item = $this->dequeue_first(); if (empty($item['post_id'])) return; $post_id = (int)$item['post_id']; $this->add_article_log($post_id, 'BE→FE', 'Processo coda BE→FE avviato'); $this->process_single_post($post_id); } finally { delete_transient('sv_bridge_be_queue_worker_lock'); } // Un articolo per processo PHP. Se resta coda, parte un processo nuovo. $queue = $this->get_queue(); if (!empty($queue) && !wp_next_scheduled(self::CRON_HOOK)) { wp_schedule_single_event(time() + 10, self::CRON_HOOK); } } protected function send_trash_to_frontend($post_id, $context = 'trash') { $post_id = (int) $post_id; $post = get_post($post_id); if (!$post || wp_is_post_revision($post_id)) return false; if (!in_array($post->post_type, ['post', 'page', 'product'], true)) return false; /* * Evita doppio invio nella stessa richiesta: wp_trash_post, transition_post_status * e before_delete_post possono scattare quasi insieme. */ $lock_key = 'sv_bridge_be_trash_sync_' . $post_id; if (get_transient($lock_key)) return false; set_transient($lock_key, 1, 60); return $this->send('post', 'delete', [ 'ID' => $post_id, 'post_type' => $post->post_type, 'post_status' => 'trash', 'delete_mode' => 'trash', 'source_status' => $post->post_status, 'deleted_at' => current_time('mysql'), 'delete_context' => sanitize_key((string) $context), ], [], $post_id); } public function on_transition_from_auto_draft_to_real_status($new_status, $old_status, $post) { if ($old_status !== 'auto-draft') return; if (!$post || empty($post->ID)) return; if (!in_array($new_status, ['draft', 'pending', 'private', 'future', 'publish'], true)) return; if (!$this->is_syncable_post_for_frontend($post)) return; /* * Quando WordPress trasforma l'auto-draft in una bozza reale, azzeriamo * gli hash tecnici creati nella fase provvisoria e sincronizziamo solo * la bozza vera, mantenendo lo stesso origin_post_id BE -> FE. */ delete_post_meta($post->ID, self::META_LAST_HASH); delete_post_meta($post->ID, self::META_LAST_SENT_HASH); delete_post_meta($post->ID, self::META_LAST_ERROR); $this->remove_post_from_queue($post->ID); $this->queue_post($post->ID, true); $this->process_single_post($post->ID); } public function on_trash_post($post_id) { $this->send_trash_to_frontend($post_id, 'wp_trash_post'); } public function on_transition_to_trash($new_status, $old_status, $post) { if ($new_status !== 'trash') return; if (!$post || empty($post->ID)) return; $this->send_trash_to_frontend((int) $post->ID, 'transition_post_status'); } public function on_delete_post($post_id) { /* * Fallback: anche se l'articolo viene eliminato definitivamente dal backend, * sul frontend lo spostiamo comunque nel cestino e non lo cancelliamo hard. */ $this->send_trash_to_frontend($post_id, 'before_delete_post'); } public function add_observability_page() { add_management_page('SV Bridge Log BE', 'SV Bridge Log BE', 'manage_options', 'sv-bridge-log-be', [$this, 'render_observability_page']); } protected function get_global_log_24h() { $rows = get_option(self::GLOBAL_LOG_OPTION, []); if (!is_array($rows)) $rows = []; $cutoff = time() - DAY_IN_SECONDS; $rows = array_values(array_filter($rows, function($row) use ($cutoff) { return !empty($row['ts']) && (int)$row['ts'] >= $cutoff; })); return array_slice($rows, 0, 500); } protected function append_global_log($post_id, $direction, $event, $details = []) { $rows = $this->get_global_log_24h(); array_unshift($rows, [ 'ts' => time(), 'time' => current_time('mysql'), 'post_id' => (int)$post_id, 'direction' => sanitize_text_field((string)$direction), 'event' => sanitize_text_field((string)$event), 'details' => is_array($details) ? $details : [], ]); $rows = array_slice($rows, 0, 500); if (get_option(self::GLOBAL_LOG_OPTION, null) === null) add_option(self::GLOBAL_LOG_OPTION, $rows, '', false); else update_option(self::GLOBAL_LOG_OPTION, $rows, false); } public function cleanup_global_log_24h() { if (get_transient(self::LOG_CLEANUP_TRANSIENT)) return; set_transient(self::LOG_CLEANUP_TRANSIENT, 1, 15 * MINUTE_IN_SECONDS); update_option(self::GLOBAL_LOG_OPTION, $this->get_global_log_24h(), false); } public function handle_clear_global_log() { if (!current_user_can('manage_options')) wp_die('Permessi insufficienti'); check_admin_referer('sv_bridge_clear_be_log'); update_option(self::GLOBAL_LOG_OPTION, [], false); wp_safe_redirect(admin_url('tools.php?page=sv-bridge-log-be&cleared=1')); exit; } public function maybe_kick_queue_on_admin() { if (!is_admin() || wp_doing_ajax()) return; if (get_transient('sv_bridge_be_admin_kick')) return; $queue = $this->get_queue(); if (empty($queue)) return; set_transient('sv_bridge_be_admin_kick', 1, 30); $next = wp_next_scheduled(self::CRON_HOOK); if (!$next || $next > time() + 30) wp_schedule_single_event(time() + 1, self::CRON_HOOK); if ((!defined('DISABLE_WP_CRON') || !DISABLE_WP_CRON) && function_exists('spawn_cron')) spawn_cron(time()); } protected function publication_badge_data($post_id) { $be = sanitize_key((string)get_post_status($post_id)); $fe = sanitize_key((string)get_post_meta($post_id, self::META_REMOTE_STATUS, true)); if ($fe === 'publish') return ['FE PUBBLICATO', '#00a32a']; if ($be === 'publish' && $fe === 'draft') return ['FE BOZZA ⚠', '#d63638']; if ($fe === 'draft') return ['FE BOZZA', '#646970']; if ($fe === 'future') return ['FE PROGRAMMATO', '#dba617']; if ($fe === 'pending') return ['FE PENDING', '#dba617']; if ($fe === 'private') return ['FE PRIVATO', '#7e57c2']; return ['FE STATO ?', '#646970']; } protected function render_remote_publication_badge($post_id) { list($label, $color) = $this->publication_badge_data($post_id); echo '' . esc_html($label) . ''; } public function handle_check_fe_post() { if (!current_user_can('edit_posts')) wp_die('Permessi insufficienti'); $post_id = isset($_GET['post_id']) ? (int)$_GET['post_id'] : 0; check_admin_referer('sv_bridge_check_fe_post_' . $post_id); $post = $post_id ? get_post($post_id) : null; if (!$post) wp_die('Articolo non trovato'); $remote = $this->verify_remote_post_exists($post_id, $post->post_type); if ($remote) { $payload = $this->build_post($post_id); $expected = !empty($payload['post']) ? $this->expected_frontend_hashes_from_data($payload['post']) : []; $this->save_remote_status_from_response($post_id, $remote, 'controllo manuale stato FE', $expected); } $remote_status = sanitize_key((string)get_post_meta($post_id, self::META_REMOTE_STATUS, true)); if ($remote_status !== sanitize_key((string)$post->post_status)) { delete_post_meta($post_id, self::META_LAST_SENT_HASH); $this->queue_post($post_id, true); $this->add_article_log($post_id, 'BE→FE', 'Controllo manuale: stato FE incoerente, reinvio accodato', ['stato_be'=>$post->post_status,'stato_fe'=>$remote_status]); } else { $this->add_article_log($post_id, 'BE→FE', 'Controllo manuale: stato FE coerente', ['stato'=>$remote_status]); } $url = get_edit_post_link($post_id, 'url'); wp_safe_redirect(add_query_arg('sv_bridge_checked', '1', $url ?: admin_url('edit.php'))); exit; } public function render_observability_page() { if (!current_user_can('manage_options')) return; $rows = $this->get_global_log_24h(); $queue = $this->get_queue(); echo '

SV Bridge · Log Backend → Frontend

'; echo '

Registro rolling delle ultime 24 ore, massimo 500 eventi. Nessuna scansione continua del database.

'; $next = wp_next_scheduled(self::CRON_HOOK); echo '

Coda attuale: ' . count($queue) . ' · Prossimo worker: ' . esc_html($next ? date_i18n('Y-m-d H:i:s',$next) : 'non pianificato') . '

'; if (defined('DISABLE_WP_CRON') && DISABLE_WP_CRON) echo '

DISABLE_WP_CRON è attivo: serve un cron server esterno oppure la coda può restare queued.

'; $clear = wp_nonce_url(admin_url('admin-post.php?action=sv_bridge_clear_be_log'), 'sv_bridge_clear_be_log'); echo '

Svuota log adesso

'; echo ''; foreach ($rows as $row) { $details=[]; foreach ((array)($row['details']??[]) as $k=>$v) if (is_scalar($v)) $details[]=$k . ': ' . $v; $id=(int)($row['post_id']??0); $link=$id?get_edit_post_link($id):''; echo ''; } if (empty($rows)) echo ''; echo '
OraIDFlussoEventoDettagli
' . esc_html($row['time']??'-') . '' . ($link?'#'.$id.'':'-') . '' . esc_html($row['direction']??'-') . '' . esc_html($row['event']??'-') . '' . esc_html(implode(' · ',$details)) . '
Nessun evento nelle ultime 24 ore.
'; } protected function add_article_log($post_id, $direction, $event, $details = []) { $post_id = (int)$post_id; if (!$post_id) return; $logs = get_post_meta($post_id, self::META_ARTICLE_LOG, true); if (!is_array($logs)) $logs = []; $clean_details = []; foreach ((array)$details as $k => $v) { if (is_scalar($v)) $clean_details[sanitize_key((string)$k)] = sanitize_text_field((string)$v); } array_unshift($logs, [ 'time' => current_time('mysql'), 'site' => 'BE', 'direction' => sanitize_text_field((string)$direction), 'event' => sanitize_text_field((string)$event), 'details' => $clean_details, ]); $cutoff = time() - DAY_IN_SECONDS; $logs = array_values(array_filter($logs, function($row) use ($cutoff) { $ts = !empty($row['time']) ? strtotime((string)$row['time']) : 0; return $ts && $ts >= $cutoff; })); $logs = array_slice($logs, 0, 80); update_post_meta($post_id, self::META_ARTICLE_LOG, $logs); $this->append_global_log($post_id, $direction, $event, $clean_details); } public function render_article_log_metabox($post) { if (!current_user_can('manage_options')) { echo '

Registro visibile solo agli amministratori.

'; return; } $logs = get_post_meta($post->ID, self::META_ARTICLE_LOG, true); if (!is_array($logs) || empty($logs)) { echo '

Nessuna operazione registrata per questo articolo.

'; return; } echo '
'; echo ''; echo ''; foreach ($logs as $row) { $details = []; if (!empty($row['details']) && is_array($row['details'])) { foreach ($row['details'] as $k => $v) $details[] = $k . ': ' . $v; } echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; } echo '
OraSitoFlussoOperazioneDettagli
' . esc_html($row['time'] ?? '-') . '' . esc_html($row['site'] ?? 'BE') . '' . esc_html($row['direction'] ?? '-') . '' . esc_html($row['event'] ?? '-') . '' . esc_html(implode(' · ', $details)) . '
'; } public function add_metabox() { add_meta_box('sv-bridge-status', 'SV Bridge', [$this, 'render_metabox'], ['post', 'page', 'product'], 'side', 'high'); if (current_user_can('manage_options')) { add_meta_box('sv-bridge-article-log', 'Registro modifiche BE/FE', [$this, 'render_article_log_metabox'], ['post', 'page', 'product'], 'normal', 'default'); } } protected function get_badge_state($post_id) { $post = get_post($post_id); $queue_status = (string)get_post_meta($post_id, self::META_QUEUE_STATUS, true); $delivered = (bool)get_post_meta($post_id, self::META_DELIVERED, true); $img_fail = (bool)get_post_meta($post_id, self::META_FEATURED_FAIL, true); $http_code = (int)get_post_meta($post_id, self::META_LAST_RESPONSE_CODE, true); $last_try = (string)get_post_meta($post_id, self::META_LAST_TRY, true); $remote_status = (string)get_post_meta($post_id, self::META_REMOTE_STATUS, true); $remote_legacy = (string)get_post_meta($post_id, self::META_REMOTE_LEGACY_LINKED_AT, true); $last_update_from_fe = (string)get_post_meta($post_id, self::META_LAST_UPDATE_FROM_FE_AT, true); $verified = get_post_meta($post_id, self::META_REMOTE_CONTENT_VERIFIED, true); $is_attachment = (int)get_post_meta($post_id, self::META_REMOTE_IS_ATTACHMENT, true); if ($queue_status === 'synced_from_frontend' || $last_update_from_fe) return ['MOD FE', '#00a32a']; if ($img_fail || in_array($queue_status, ['image_missing', 'image_failed'], true)) return ['IMG KO', '#b32d2e']; if ($queue_status === 'waiting_image') return ['IMG', '#dba617']; if (in_array($queue_status, ['queued', 'processing'], true)) return ['MOD BE', '#dba617']; if ($is_attachment) return ['ERR', '#d63638']; if ($queue_status === 'status_mismatch') return ['ST KO', '#d63638']; if ($queue_status === 'content_mismatch' || $verified === '0') return ['KO', '#d63638']; if ($delivered && $remote_status === 'publish') return ['P su FE', '#00a32a']; if ($delivered && $remote_legacy) return ['LEG FE', '#7e57c2']; if ($delivered && (int)$verified === 1) return ['FE OK', '#00a32a']; if ($delivered && $http_code >= 200 && $http_code < 300) { if ($post && $post->post_status === 'draft') return ['B su FE', '#7e57c2']; return ['FE OK', '#00a32a']; } if ($last_try && ($http_code >= 400 || $queue_status === 'error')) return ['ERR', '#d63638']; if ($post && $post->post_status === 'draft' && $delivered) return ['BOZZA', '#646970']; return ['NO FE', '#646970']; } public function output_admin_list_css() { $screen = function_exists('get_current_screen') ? get_current_screen() : null; if (!$screen) return; $allowed = ['edit-post', 'edit-page', 'edit-product', 'post', 'page', 'product']; if (!in_array($screen->id, $allowed, true)) return; echo ''; } public function add_admin_column($columns) { $columns['sv_bridge_delivery'] = 'Frontend'; return $columns; } protected function get_backend_queue_item_for_post($post_id) { $post_id = (int)$post_id; foreach ($this->get_queue() as $item) { if (!empty($item['post_id']) && (int)$item['post_id'] === $post_id) return $item; } return []; } protected function get_backend_pending_change_rows($post_id) { $post_id = (int)$post_id; $post = get_post($post_id); $rows = []; if (!$post) return $rows; $queue_status = (string)get_post_meta($post_id, self::META_QUEUE_STATUS, true); $remote_status = (string)get_post_meta($post_id, self::META_REMOTE_STATUS, true); $remote_id = (string)get_post_meta($post_id, self::META_REMOTE_POST_ID, true); $remote_title_hash = (string)get_post_meta($post_id, self::META_REMOTE_TITLE_HASH, true); $remote_content_hash = (string)get_post_meta($post_id, self::META_REMOTE_CONTENT_HASH, true); $remote_excerpt_hash = (string)get_post_meta($post_id, self::META_REMOTE_EXCERPT_HASH, true); $last_error = (string)get_post_meta($post_id, self::META_LAST_ERROR, true); $diag = (string)get_post_meta($post_id, self::META_LAST_DIAG, true); $last_update_from_fe = (string)get_post_meta($post_id, self::META_LAST_UPDATE_FROM_FE_AT, true); $last_fe_post_id = (string)get_post_meta($post_id, self::META_LAST_FE_POST_ID, true); /* Caso MOD FE: il backend deve mostrare da edit.php cosa è stato ricevuto dal FE. */ if ($queue_status === 'synced_from_frontend' || $last_update_from_fe) { $rows[] = 'Direzione: frontend → backend'; $rows[] = 'Ultima modifica ricevuta dal FE: ' . ($last_update_from_fe ?: 'non disponibile'); if ($last_fe_post_id) $rows[] = 'Articolo FE sorgente: #' . $last_fe_post_id; $changed_fields = get_post_meta($post_id, self::META_LAST_FE_CHANGED_FIELDS, true); $changed_meta = get_post_meta($post_id, self::META_LAST_FE_SYNCED_META_KEYS, true); $changed_tax = get_post_meta($post_id, self::META_LAST_FE_SYNCED_TAXONOMIES, true); if (is_array($changed_fields) && !empty($changed_fields)) { $rows[] = 'Campi aggiornati sul BE: ' . implode(', ', array_slice($changed_fields, 0, 8)); } if (is_array($changed_tax) && !empty($changed_tax)) { $rows[] = 'Tassonomie aggiornate: ' . implode(', ', array_slice($changed_tax, 0, 8)); } if (is_array($changed_meta) && !empty($changed_meta)) { $rows[] = 'Metakey aggiornate: ' . implode(', ', array_slice($changed_meta, 0, 10)); } if (count($rows) <= 3) { $rows[] = 'Modifica FE applicata al BE. Nessuna azione necessaria, salvo nuova coda BE → FE.'; } if ($diag) $rows[] = wp_trim_words($diag, 18, '...'); return array_values(array_unique(array_filter($rows))); } $rows[] = 'Direzione: backend → frontend'; $rows[] = 'Stato coda: ' . ($queue_status ?: 'non impostato'); if ($remote_id) $rows[] = 'Articolo FE collegato: #' . $remote_id; if ($remote_status && $remote_status !== $post->post_status) $rows[] = 'Stato da aggiornare: ' . $remote_status . ' → ' . $post->post_status; if ($remote_title_hash && md5((string)$post->post_title) !== $remote_title_hash) $rows[] = 'Titolo da aggiornare sul FE'; if ($remote_content_hash && md5((string)$post->post_content) !== $remote_content_hash) $rows[] = 'Contenuto da aggiornare sul FE'; if ($remote_excerpt_hash && md5((string)$post->post_excerpt) !== $remote_excerpt_hash) $rows[] = 'Excerpt da aggiornare sul FE'; if (in_array($queue_status, ['waiting_image', 'image_missing', 'image_failed'], true)) { $rows[] = 'Immagine in evidenza/media da sincronizzare o correggere'; } $item = $this->get_backend_queue_item_for_post($post_id); if (!empty($item['queued_at'])) $rows[] = 'In coda dal: ' . date_i18n('Y-m-d H:i:s', (int)$item['queued_at']); if ($last_error) $rows[] = 'Ultimo errore: ' . wp_trim_words($last_error, 18, '...'); if ($diag && count($rows) <= 3) $rows[] = wp_trim_words($diag, 18, '...'); if (count($rows) <= 3 && in_array($queue_status, ['queued', 'processing'], true)) { $rows[] = 'Payload modificato: possibili modifiche a metakey, tassonomie, contenuto normalizzato o featured image.'; } return array_values(array_unique(array_filter($rows))); } protected function render_bridge_badge_popup($label, $color, $badge_class = '', $rows = [], $post_id = 0) { $class = 'sv-bridge-badge ' . sanitize_html_class((string)$badge_class); $style = 'background:' . esc_attr($color) . ';'; if (empty($rows)) { echo '' . esc_html($label) . ''; return; } echo '
'; echo '' . esc_html($label) . ''; echo '
'; echo '
Modifiche in coda
'; echo ''; if ($post_id && current_user_can('edit_post', $post_id)) { $queue_status = (string)get_post_meta((int)$post_id, self::META_QUEUE_STATUS, true); if (in_array($queue_status, ['queued', 'processing', 'waiting_image', 'content_mismatch', 'image_failed', 'image_missing', 'error'], true)) { $url = wp_nonce_url(admin_url('admin-post.php?action=sv_bridge_resend_post&post_id=' . (int)$post_id), 'sv_bridge_resend_post_' . (int)$post_id); echo '

Forza coda BE → FE

'; } } echo '
'; echo '
'; } public function render_admin_column($column, $post_id) { if ($column !== 'sv_bridge_delivery') return; list($label, $color) = $this->get_badge_state($post_id); $queue_status = (string) get_post_meta($post_id, self::META_QUEUE_STATUS, true); $delivered_at = (string) get_post_meta($post_id, self::META_DELIVERED_AT, true); $remote_id = (string) get_post_meta($post_id, self::META_REMOTE_POST_ID, true); $remote_sync = (string) get_post_meta($post_id, self::META_REMOTE_LAST_SYNC, true); $verified = get_post_meta($post_id, self::META_REMOTE_CONTENT_VERIFIED, true); $remote_legacy = (string) get_post_meta($post_id, self::META_REMOTE_LEGACY_LINKED_AT, true); $remote_type = (string) get_post_meta($post_id, self::META_REMOTE_POST_TYPE, true); $is_attachment = (int) get_post_meta($post_id, self::META_REMOTE_IS_ATTACHMENT, true); $diag = (string) get_post_meta($post_id, self::META_LAST_DIAG, true); $badge_class = 'neutral'; if ($is_attachment || $queue_status === 'content_mismatch' || $verified === '0' || in_array($queue_status, ['error', 'image_failed', 'image_missing'], true)) { $badge_class = 'err'; } elseif (in_array($queue_status, ['queued', 'processing', 'waiting_image'], true)) { $badge_class = 'warn'; } elseif ($remote_legacy) { $badge_class = 'legacy'; } elseif ($queue_status === 'sent' || $verified === '1') { $badge_class = 'ok'; } elseif ($queue_status) { $badge_class = 'info'; } echo '
'; $popup_rows = []; $last_update_from_fe = (string) get_post_meta($post_id, self::META_LAST_UPDATE_FROM_FE_AT, true); if (in_array($queue_status, ['queued', 'processing', 'waiting_image', 'content_mismatch', 'image_failed', 'image_missing', 'error', 'synced_from_frontend'], true) || $last_update_from_fe) { $popup_rows = $this->get_backend_pending_change_rows($post_id); } $this->render_bridge_badge_popup($label, $color, $badge_class, $popup_rows, $post_id); $this->render_remote_publication_badge($post_id); echo '
'; if ($remote_id) { echo '
FE #' . esc_html($remote_id) . '
'; } if ($remote_type) { echo '
Tipo: ' . esc_html($remote_type); if ($is_attachment) echo ' ATTACHMENT'; echo '
'; } if ($remote_sync) { echo '
Sync: ' . esc_html($remote_sync) . '
'; } elseif ($delivered_at) { echo '
Consegnato: ' . esc_html($delivered_at) . '
'; } if ($verified !== '') { echo '
Content: '; echo ((int) $verified === 1) ? 'OK' : 'NO'; echo '
'; } if ($remote_legacy) { echo '
Legacy: OK
'; } if (!$remote_id && $diag) { echo '
' . esc_html(wp_trim_words($diag, 12, '...')) . '
'; } echo '
'; echo '
'; } public function render_metabox($post) { $last_try = get_post_meta($post->ID, self::META_LAST_TRY, true); $http_code = get_post_meta($post->ID, self::META_LAST_RESPONSE_CODE, true); $error = get_post_meta($post->ID, self::META_LAST_ERROR, true); $response = get_post_meta($post->ID, self::META_LAST_RESPONSE_BODY, true); $diag = get_post_meta($post->ID, self::META_LAST_DIAG, true); $img_fail = get_post_meta($post->ID, self::META_FEATURED_FAIL, true); $img_msg = get_post_meta($post->ID, self::META_FEATURED_FAIL_MSG, true); $queue_status = get_post_meta($post->ID, self::META_QUEUE_STATUS, true); $delivered_at = get_post_meta($post->ID, self::META_DELIVERED_AT, true); $remote_id = get_post_meta($post->ID, self::META_REMOTE_POST_ID, true); $remote_status = get_post_meta($post->ID, self::META_REMOTE_STATUS, true); $remote_title = get_post_meta($post->ID, self::META_REMOTE_TITLE, true); $remote_last_sync = get_post_meta($post->ID, self::META_REMOTE_LAST_SYNC, true); $remote_featured_ok = get_post_meta($post->ID, self::META_REMOTE_FEATURED_OK, true); $remote_legacy = get_post_meta($post->ID, self::META_REMOTE_LEGACY_LINKED_AT, true); $remote_verified = get_post_meta($post->ID, self::META_REMOTE_LAST_VERIFIED_AT, true); $remote_content_verified = get_post_meta($post->ID, self::META_REMOTE_CONTENT_VERIFIED, true); $remote_verification_msg = get_post_meta($post->ID, self::META_REMOTE_VERIFICATION_MSG, true); $remote_content_hash = get_post_meta($post->ID, self::META_REMOTE_CONTENT_HASH, true); $remote_expected_hash = get_post_meta($post->ID, self::META_REMOTE_EXPECTED_CONTENT_HASH, true); $remote_post_type = get_post_meta($post->ID, self::META_REMOTE_POST_TYPE, true); $remote_is_attachment = get_post_meta($post->ID, self::META_REMOTE_IS_ATTACHMENT, true); $last_update_from_fe = get_post_meta($post->ID, self::META_LAST_UPDATE_FROM_FE_AT, true); $last_fe_sync_status = get_post_meta($post->ID, self::META_LAST_FE_SYNC_STATUS, true); $last_fe_post_id = get_post_meta($post->ID, self::META_LAST_FE_POST_ID, true); $endpoint = $this->endpoint(); $secret_present = $this->secret() ? 'Sì' : 'No'; list($badge, $color) = $this->get_badge_state($post->ID); echo '

Stato: ' . esc_html($badge) . '

'; echo '

Pubblicazione reale FE:
'; $this->render_remote_publication_badge($post->ID); echo '

'; echo '

Queue: ' . esc_html($queue_status ?: '-') . '

'; if ($last_update_from_fe) { echo '

Modifica ricevuta dal FE:
' . esc_html($last_update_from_fe) . '
Stato FE→BE: ' . esc_html($last_fe_sync_status ?: '-') . ' · ID FE: ' . esc_html($last_fe_post_id ?: '-') . '

'; } echo '

Consegnato al frontend:
' . esc_html($delivered_at ?: '-') . '

'; echo '
'; echo '

Conferma reale FE:
'; echo 'ID FE: ' . esc_html($remote_id ?: '-') . '
'; echo 'Tipo FE: ' . esc_html($remote_post_type ?: '-') . '' . ((int)$remote_is_attachment ? ' ATTACHMENT' : '') . '
'; echo 'Stato FE: ' . esc_html($remote_status ?: '-') . '
'; echo 'Ultimo sync FE: ' . esc_html($remote_last_sync ?: '-') . '
'; echo 'Verificato BE: ' . esc_html($remote_verified ?: '-') . '
'; echo 'Featured FE: ' . esc_html($remote_featured_ok !== '' ? ((int)$remote_featured_ok ? 'OK' : 'NO') : '-') . '
'; echo 'Verifica modifica content: ' . esc_html($remote_content_verified !== '' ? ((int)$remote_content_verified ? 'OK' : 'NO') : '-') . ''; echo '

'; echo '

Prova reale modifica:
' . esc_html($remote_verification_msg ?: '-') . '

'; echo '

Hash content BE/FE:
BE: ' . esc_html($remote_expected_hash ?: '-') . '
FE: ' . esc_html($remote_content_hash ?: '-') . '

'; if ($remote_legacy) { echo '

Articolo vecchio agganciato al FE
' . esc_html($remote_legacy) . '

'; } if ($remote_title) { echo '

Titolo FE ricevuto:
' . esc_html($remote_title) . '

'; } echo '

Endpoint FE:
' . esc_html($endpoint ?: 'NON DEFINITO') . '

'; echo '

Secret presente: ' . esc_html($secret_present) . '

'; echo '

Ultimo tentativo:
' . esc_html($last_try ?: 'Mai') . '

'; echo '

HTTP code:
' . esc_html($http_code ?: '-') . '

'; echo '

Errore tecnico:
' . esc_html($error ?: 'Nessuno') . '

'; echo '

Diagnosi:
' . nl2br(esc_html($diag ?: 'Nessuna diagnosi disponibile')) . '

'; echo '

Ultima risposta FE:
' . esc_html($response ?: '-') . '

'; if ($img_fail) { echo '

Problema immagine in evidenza:
' . esc_html($img_msg ?: 'Immagine non sincronizzata') . '

'; } $url = wp_nonce_url(admin_url('admin-post.php?action=sv_bridge_resend_post&post_id=' . (int)$post->ID), 'sv_bridge_resend_post_' . (int)$post->ID); echo '

Metti in coda / reinvia ora

'; $check_url = wp_nonce_url(admin_url('admin-post.php?action=sv_bridge_check_fe_post&post_id=' . (int)$post->ID), 'sv_bridge_check_fe_post_' . (int)$post->ID); echo '

Controlla stato FE / ripara coda

'; echo '

Il sistema evita invii duplicati quando il payload non è cambiato.

'; } public function handle_manual_resend() { if (!current_user_can('edit_posts')) wp_die('Permessi insufficienti'); $post_id = isset($_GET['post_id']) ? (int)$_GET['post_id'] : 0; check_admin_referer('sv_bridge_resend_post_' . $post_id); if (!$post_id || !get_post($post_id)) { wp_safe_redirect(admin_url('edit.php')); exit; } $post = get_post($post_id); if ($post && $this->maybe_reconcile_with_frontend($post_id, $post->post_type)) { $redirect = get_edit_post_link($post_id, 'url'); if (!$redirect) $redirect = admin_url('post.php?post=' . $post_id . '&action=edit'); $redirect = add_query_arg('sv_bridge_resend', 'verified', $redirect); wp_safe_redirect($redirect); exit; } delete_post_meta($post_id, self::META_LAST_SENT_HASH); $this->queue_post($post_id, true); $this->add_article_log($post_id, 'BE→FE', 'Forzatura manuale coda dal popup/metabox', ['utente' => wp_get_current_user()->user_login]); $redirect = get_edit_post_link($post_id, 'url'); if (!$redirect) $redirect = admin_url('post.php?post=' . $post_id . '&action=edit'); $redirect = add_query_arg('sv_bridge_resend', 'queued', $redirect); wp_safe_redirect($redirect); exit; } } if (!wp_next_scheduled('sv_bridge_be_process_queue') && function_exists('wp_get_schedules') && !isset(wp_get_schedules()['minute'])) { add_filter('cron_schedules', function($schedules) { $schedules['minute'] = ['interval' => 60, 'display' => 'Every Minute']; return $schedules; }); } new SV_Bridge_BE_Sender_Optimized();