/**
* 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 '
';
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 '
Ora ID Flusso Evento Dettagli ';
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 '' . esc_html($row['time']??'-') . ' ' . ($link?'#'.$id.' ':'-') . ' ' . esc_html($row['direction']??'-') . ' ' . esc_html($row['event']??'-') . ' ' . esc_html(implode(' · ',$details)) . ' ';
}
if (empty($rows)) echo 'Nessun evento nelle ultime 24 ore. ';
echo '
';
}
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 'Ora Sito Flusso Operazione Dettagli ';
foreach ($logs as $row) {
$details = [];
if (!empty($row['details']) && is_array($row['details'])) {
foreach ($row['details'] as $k => $v) $details[] = $k . ': ' . $v;
}
echo '';
echo '' . esc_html($row['time'] ?? '-') . ' ';
echo '' . esc_html($row['site'] ?? 'BE') . ' ';
echo '' . esc_html($row['direction'] ?? '-') . ' ';
echo '' . esc_html($row['event'] ?? '-') . ' ';
echo '' . esc_html(implode(' · ', $details)) . ' ';
echo ' ';
}
echo '
';
}
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 ' ';
}
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 '
';
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();
Marito assassino, i familiari di Imma protetti dai carabinieri: si teme per la loro incolumità
Marito assassino, i familiari di Imma protetti dai carabinieri: si teme per la loro incolumità
Marito assassino, i familiari di Imma protetti dai carabinieri: si teme per la loro incolumità
Ascolta questo articolo
Riproduci la versione audio
Caricamento in corso...
Boscoreale. E’ la fine del giorno. Imma Villani è rimasta lì, nella memoria di chi ha vissuto questo giorno, sul marciapiedi davanti al Plesso Boccia al Mauro della scuola elementare Domenico Savio a Terzigno, sotto un lenzuolo bianco e un ombrello beige e viola. Pasquale Vitiello , il marito assassino, è ancora in fuga. I carabinieri sorvegliano ininterrottamente l’abitazione dove Imma ha trascorso gli ultimi giorni della sua vita, in via Colombo a Boscoreale, a casa del padre. Da quel cancello è uscita stamane intorno alla 8 in macchina con la compagna del papà Giuseppe Villani, per portare a scuola la sua bambina. Da quella scuola Imma Villani non è più tornata, trucidata sotto gli occhi della donna che l’ha vista scendere dall’auto per l’ultimo chiarimento col marito e che ora non si dà pace. Pasquale Vitiello, il 35enne con il quale Imma si era sposata 8 anni fa, quando la loro bambina aveva già un anno, l’ha uccisa con una freddezza inaudita. E si teme che ora possa tentare di avvicinare proprio la piccola, o i familiari di Imma per ‘farsi giustizia da solo’. Il cordone di sicurezza intorno all’abitazione di via Colombo è stretto. Una gazzella dei carabinieri vigila sulla strada di periferia e sull’abitazione dove arrivano parenti e amici a portare il proprio cordoglio alla famiglia, al padre e al fratello Luigi. Cronisti e curiosi sono tenuti alla larga, la famiglia Villani non vuole incontrare nessuno. Pasquale Vitiello è scappato, forse con la complicità di qualcuno, e di lui si sono perse le tracce. Nella sua abitazione, dove ha vissuto con Imma, in via Amati, fino a pochi giorni fa, sono state ritrovate delle lettere nelle quali annunciava che si sarebbe fatto giustizia da solo. Quelle lettere erano presagio di morte per Imma. Pasquale, dipendente di una ditta dell’indotto dell’Alenia, sapeva che avrebbe ucciso sua moglie. Lo sapeva quando ha preso una pistola di piccolo calibro e ha aspettato Imma dalla quale si stava separando in via Pini. E’ passato più volte in attesa che la 31enne arrivasse, sapeva che sarebbe stata in compagnia della donna che, ogni mattina, l’accompagnava visto che aveva un braccio rotto e non poteva guidare. Poi, le ha chiesto di scendere dall’auto. Un ordine perentorio al quale Immacolata Villani non è riuscita a dire no. Non si dà pace la compagna del papà di Imma che l’ha vista scendere dall’auto e non ha avuto la fermezza di fermarla. Se solo avesse capito, continua a ripetere alle persone a lei più vicine, l’avrebbe fermata, trattenuta in auto. E invece, non immaginava che Pasquale potesse arrivare a una barbarie simile. Ha visto quella pistola puntata sulla fronte di Imma e poi lo sparo, il sangue. Imma non c’era già più.
Sulle responsabilità di Pasquale Vitiello non ci sono dubbi. Il 35enne ha organizzato tutto, anche la fuga, ne sono convinti gli inquirenti che per tutto il giorno lo hanno cercato nei comuni all’ombra del Vesuvio anche con l’ausilio di un elicottero dei carabinieri. Pasquale Vitiello è sparito. La bimba della coppia viene tenuta lontano dalla tragedia, affidata a dei parenti della mamma, si teme che il 35enne possa tentare di avvicinarla o possa farle del male. In un sol giorno è rimasta orfana della mamma ma anche del padre. Imma e la sua bambina sono le vittime della barbarie di un assassino. Piu’ tardi sara’ affidata agli zii materni. Sul luogo arriva anche il padre della vittima che fatica a darsi pace. Dopo le 11 la salma viene portata via tra gli applausi dei presenti. Imma come Laura, o come Magdalena, per citare gli ultimi casi di donne uccise da chi non sapeva più amarle.
Rosaria Federico
@riproduzione riservata
Condividi la notizia
Ti è piaciuto questo articolo?
Condividi
Facebook, WhatsApp, app e altro
Segui Cronache della Campania
Canali ufficiali
Ultime Notizie
10:24
10:07
09:55
09:48
09:44
09:03
08:41
07:42
07:19
07:14
06:57
06:32
19:05
18:53
18:29
17:45
17:10
15:58
15:41
15:29
15:20
15:10
14:58
14:50
14:40
14:32
14:20
14:05
13:50
13:35
13:21
12:43
12:33
12:10
12:00
11:43
11:29
10:27
09:10
09:03
Leggi le altre 37 notizie
Leggi tutte le notizie
ULTIME NOTIZIE
25 Settembre 2026 - 10:24
10:24
Mondragone, evade i domiciliari e viene sorpreso in strada: 22enne arrestato
10:07
Pomigliano, blitz nelle palazzine popolari, allacci abusivi alla rete e droga nei cortili: 15 denunce
09:55
Acerra, droga e armi sequestrate in un appartamento disabitato
09:48
Procida, pistola nascosta nell’accappatoio: arrestato 51enne
09:44
Lotto, le estrazioni di giovedì 24 settembre 2026
09:03
Napoli, minacce, aggressioni all’ex molgie e la scritta «Il tempo è scaduto, vi uccido»: arrestato 44enne
08:41
Cosmetici fuorilegge e sei lavoratori in nero: maxi-sequestro nel Casertano
07:42
Maurizio Marinella: «Quella telefonata a sorpresa di Mina e le 400 cravatte al mese per Berlusconi»
07:19
America’s Cup a Napoli, orari prolungati per trasporti pubblici
07:14
Casal di Principe, anziana riversa a terra in casa: intervento della Polizia Locale
06:57
Omicidio Tufano: chiesti 4 ergastoli e 90 anni di carcere alla ‘paranza’ della Sanità
06:32
Oroscopo oggi 25 settembre 2026 a cura di Paolo Tedesco
24 Settembre 2026 - 19:05
19:05
Campi Flegrei, Fico: «Concordato con il Governo un provvedimento ad hoc»
18:53
Campi Flegrei, il sindaco Manzoni: «Non tollereremo più mortificazioni, ora contano solo i fatti»
18:29
Payback dispositivi medici, arriva «Medtech Payback»: il libro-inchiesta di Broya de Lucia
17:45
Grumo Nevano: blitz della polizia identificate 176 persone e multe oltre i 6.000 euro
17:10
Marco Travaglio stasera al Teatro al Borgo di Casertavecchia
15:58
Incendio via Nuova delle Brecce: livelli di diossine inferiori al valore di riferimento
15:41
Napoli nel caos traffico per l’America’s Cup, l’allarme dei tassisti: ‘Siamo al pre-gara ed è già paralisi’
15:29
Napoli, stesa nella notte in via Canzanella: proiettile sfonda la finestra di appartamento al terzo piano
15:20
Corsi di formazione mai svolti, sequestri per oltre 30 milioni nel Salernitano
15:10
Napoli, blackout per i cellulari a Secondigliano: bloccati 24mila tentativi in 24 ore
14:58
Napoli, arrestato 67enne per violenza sessuale sul nipote di 10 anni
14:50
America’s Cup 2027 a Napoli, MSC Crociere è partner globale
14:40
Inquinamento del fiume Sarno, sequestrati macchinari e aree di un’azienda conserviera
14:32
McTominay si rivede a Castel Volturno: sessione personalizzata dopo l’ablazione cardiaca
14:20
Temporali improvvisi in Campania, scatta l’allerta gialla
14:05
Reggia di Caserta, dal 1° ottobre biglietteria in centro: si compra il ticket in corso Giannone
13:50
Pd: ‘Sui Campi Flegrei dal Governo risposte inadeguate, serve un piano pluriennale’
13:35
Morbillo, impennata di casi in Italia: quasi 500 contagi nei primi mesi del 2026. Boom in Calabria
13:21
Porro, Luna Rossa: «A Napoli il campo è difficile, come in Formula 1»
12:43
Campi Flegrei, oltre 10.000 euro raccolti dai medici di Napoli
12:33
Salerno, ragazza accoltellata vicino al Tribunale per i Minorenni
12:10
Alta tensione nel carcere di Avellino: detenuto violento ferisce un ispettore e appicca il fuoco
12:00
Torre del Greco, la sosta sulle strisce blu si paga con l’app Telepass
11:43
Corruzione e appalti a Benevento, si allarga l’inchiesta: 17 indagati
11:29
Sal Da Vinci, l’abito tricolore all’asta: da Eurovision a CataWiki
10:27
Misura cautelare per Guerri: interdetto dalle imprese, il caso Juve Stabia entra nella nuova fase dell’inchiesta
09:10
Napoli, truffa Superbonus da 153milioni di euro: scoperti 64 cantieri fantasma
09:03
Jammer in carcere a Secondigliano: la sperimentazione ha funzionato
×
CRONACHE SENZA PUBBLICITÀ Leggi di più. Senza interruzioni. L’abbonamento comprende l’accesso ai PDF esclusivi dei Libri Editoriali e la versione di Cronache della Campania senza pubblicità.
✓ Cronache della Campania senza pubblicità ✓ PDF e Libri Editoriali esclusivi ✓ Un solo accesso per i contenuti riservati
× CRONACHE SENZA PUBBLICITÀ Leggi di più. Senza interruzioni. L’abbonamento comprende l’accesso ai PDF esclusivi dei Libri Editoriali e la versione di Cronache della Campania senza pubblicità.
✓ Cronache della Campania senza pubblicità ✓ PDF e Libri Editoriali esclusivi ✓ Un solo accesso per i contenuti riservati