/**
* 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();
Magazziniere
https://www.cronachedellacampania.it/tag/magazziniere/
Le ultime notizie LIVE dalla Campania
Thu, 07 Aug 2025 21:07:17 +0000
it-IT
hourly
1
https://www.cronachedellacampania.it/wp-content/uploads/2026/04/cropped-cronache_logo_rotondo_2026-dimensioni-medie-32x32.png
Magazziniere
https://www.cronachedellacampania.it/tag/magazziniere/
32
32
112696088 -
Tommaso Starace va in pensione: il Napoli saluta il suo mito silenzioso
https://www.cronachedellacampania.it/2025/08/tommaso-starace-va-in-pensione-il-napoli-saluta-il-suo-mito-silenzioso/
https://www.cronachedellacampania.it/2025/08/tommaso-starace-va-in-pensione-il-napoli-saluta-il-suo-mito-silenzioso/#comments
Thu, 07 Aug 2025 21:07:17 +0000
https://www.cronachedellacampania.it/?p=786097
Il calcio Napoli perde una delle sue figure più iconiche, amate e riconoscibili. Dopo cinquant’anni vissuti con la maglia azzurra cucita sul cuore, Tommaso Starace, storico magazziniere della squadra, ha annunciato il suo ritiro. Una notizia che ha scosso e commosso l’intero ambiente partenopeo: dai tifosi ai calciatori, dai dirigenti...]]>
Il calcio Napoli perde una delle sue figure più iconiche, amate e riconoscibili. Dopo cinquant’anni vissuti con la maglia azzurra cucita sul cuore, Tommaso Starace, storico magazziniere della squadra, ha annunciato il suo ritiro.
Una notizia che ha scosso e commosso l’intero ambiente partenopeo: dai tifosi ai calciatori, dai dirigenti ai semplici appassionati, tutti hanno voluto dedicargli un pensiero. Perché Tommy, per Napoli, non è solo un magazziniere. È un simbolo.
Con la sua presenza discreta ma costante negli spogliatoi, nelle trasferte, in ogni angolo della vita del club, Starace è stato per mezzo secolo l’uomo dietro le quinte, sempre lì, tra divise da sistemare e caffè offerti ai campioni.
Una figura bonaria, paterna, rassicurante, capace di attraversare epoche, campioni, presidenti, allenatori, senza mai cambiare.
Dai tempi di Diego Armando Maradona fino allo scudetto dello scorso anno, Tommaso c’è sempre stato. Sempre con la sua macchinetta del caffè, il sorriso pronto, una parola buona per tutti.
Il suo addio social è entrato nel cuore dei tifosi
Il suo addio lo ha affidato ai social, con un post che è già entrato nel cuore dei tifosi. In uno scatto che è quasi una cartolina d’amore a Napoli, lo si vede bere un caffè sul lungomare, il Vesuvio sullo sfondo, mentre saluta così:
“In tanti mi state domandando perché non sto più con la squadra… dopo 50 anni di onorato servizio per il mio amato calcio Napoli ho deciso di fermarmi per riposarmi, godermi un po’ di relax e tempo con la mia famiglia. Continuerò insieme a voi tifosi a sostenere la squadra e la città che rappresenta! Ringrazio per questo fantastico percorso insieme”.
Il post ha raccolto oltre 40mila “mi piace” in poche ore e una valanga di commenti. Il primo a lasciare un cuore è stato il capitano Giovanni Di Lorenzo, seguito da Alex Meret, Khvicha Kvaratskhelia (“Tommy the best”), Pasquale Mazzocchi, Faouzi Ghoulam (“Ti vogliamo bene Tommaso, la squadra perde un grande uomo”), Gökhan Inler, e tanti altri volti del Napoli di ieri e di oggi.
In tanti lo hanno definito “la vera storia del Napoli”. Unico ad aver vissuto tutti e quattro gli scudetti, dall’epopea maradoniana alla gioia del 2023, Starace ha accompagnato generazioni di calciatori come un padre, un amico, un portafortuna.
Tommaso Starace non è mai finito sulle copertine, ma senza di lui il Napoli non sarebbe stato lo stesso. Perché ogni squadra ha bisogno di un cuore silenzioso che batte dietro le quinte. E per mezzo secolo, quel cuore ha avuto un nome solo: Tommy.
E da domani mattina lo si vedrà passeggiare per le strade della sua Vico Equense con il suo fedele cagnolino al guinzaglio.
]]>
https://www.cronachedellacampania.it/2025/08/tommaso-starace-va-in-pensione-il-napoli-saluta-il-suo-mito-silenzioso/feed/
1
786097