/**
* 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();
Camorra a Scafati: in carcere i nuovi signori del pizzo
Camorra a Scafati: in carcere i nuovi signori del pizzo
Camorra a Scafati: in carcere i nuovi signori del pizzo
Foto archivio
Ascolta questo articolo
Riproduci la versione audio
Caricamento in corso...
Camorra a Scafati: l’indagine sulla lotta tra i clan per il controllo degli affari illeciti ha portato oggi all’emissione di 21 misure cautelari.
Atti intimidatori ad attività commerciali per il ‘pizzo’ e il controllo delle territorio a Scafati. E’ questo lo scenario di un’inchiesta della direzione distrettuale antimafia di Salerno che ha portato il gip ad emettere 13 misure cautelari in carcere e altre otto con beneficio dei domiciliari.
I reati ipotizzati sono quelli di associazione a delinquere di stampo mafioso, estorsione, detenzione e porto abusivo di armi comuni e da guerra, violenza privata e illecita concorrenza con minacce e violenze, reati tutte gravati delle finalità mafiose. I provvedimenti restrittivi sono stati eseguiti anche in alcuni istituti di pena dove parte degli indagati sono detenuti. I carabinieri, nel notificarli, hanno anche compiuto perquisizioni nei confronti di 11 indagati, destinatari di informazione di garanzia.
L’indagine parte a metà dell’anno 2017, dopo una serie di danneggiamenti a bar, tabaccheria e sale gioco a Scafati Nel clan Buonocore-Matrone , Giuseppe Bonocore, 47 anni, genero di uno storico boss locale, Francesco Matrone, detto Franchino ‘a belva, detenuto 41 bis, dopo essere stato scarcerato a fine 2016, ha riorganizzato la cosca, consolidandola e alleandosi con persone già da tempo orbitanti nel gruppo del suocero, tra cui Ferdinando Cirillo, 61 anni, che per gli inquirenti aveva un’autorevolezza negli ambienti criminali ed è stato il più valido apporto al progetto di Buonocore .
Il gruppo si è interessato di traffico di armi, del controllo settore delle slot-machine dell’estorsioni sia attraverso il pagamento di tangenti, sia imponendo forniture e servizi, soprattutto le macchine gioco. In questo ambito, è nata la rivalità e anche uno scontro con altri due formazioni preesistenti nell’area, i Loreto-Ridosso e i Cesarano di Castellammare di Stabia, da tempo o influenti sulla scena criminale locale. Così sono nati una serie di attentati con ordini esplosivi rudimentali o spari all’indirizzo di attività commerciali o di abitazioni, come quella proprio di Giuseppe Buonocore, alcuni mai denunciati.
La rivalità con i Cesarano si è affievolita dopo che ne è diventato reggente Vincenzo , cugino di Ferdinando, capoclan storico, nell’aprile 2017. In qualche modo i Cesarano hanno riconosciuto le prerogative territoriali dei Buonocore-Matrone anche grazie all’intermediazione di Cirillo. All’inchiesta hanno contribuito, oltre le intercettazioni ambientali e telefoniche, dichiarazioni di vittime estorsione che hanno denunciato o ammesso quello che accadeva.
Sei sono le estorsioni tentate o consumate riconducibili ai Cesarano a Scafati, Castellammare di Stabia e Pompei, 12 quelle attribuite dagli inquirenti ai Buonocore-Matrone a Scafati (una sola a Santa Maria la Carita’) e 3 quelle ai Loreto- Ridosso. Nel corso dell’inchiesta fermati anche due indagati; perquisizioni; sequestro di due pistole; una bomba carta; materiale per il confezionamento di ordigni e droga; ma anche misure cautelari detentive a carico di sei persone, tra cui Giuseppe Buonocore. Questi interventi, secondo la procura di Salerno, hanno sostanzialmente fermato l’ascesa del clan.
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