<?php if ( ! defined( 'ABSPATH' ) ) { exit; } final class PTSC_State { const CONTROL_META_KEY = '_ptsc_control'; const CONTROL_META_VALUE = '1'; const LEGACY_META_KEY = '_ptsc_control_legacy'; const CONTROL_OPTION = 'ptsc_control_post_id'; const CONTROL_TITLE = 'PunjabiTime Social Claude Control'; const CONTROL_SLUG = 'pt-social-claude-control'; const SCHEMA = 3; const MAX_ITEMS = 80; const HISTORY_META_KEY = '_ptsc_social_history_v1'; const DATA_VERSION_OPTION = 'ptsc_data_version'; // Backward-compatibility contract: keep RUN_PROMPT and the hidden control title/slug unchanged for the working Claude Skill. const RUN_PROMPT = '/punjabitime-social-publisher Post all Ready/Retry items using exact plugin-prepared text. Use visible LinkedIn and X agents in parallel. LinkedIn: image first. X: text only. Do not rewrite, research, duplicate-check, or repost completed items. Sync once; short report.'; public static function init() { add_action( 'init', [ __CLASS__, 'ensure_control_integrity' ], 3 ); add_action( 'init', [ __CLASS__, 'maybe_upgrade' ], 4 ); add_action( 'save_post', [ __CLASS__, 'sync_history_on_control_save' ], 20, 3 ); add_filter( 'wp_revisions_to_keep', [ __CLASS__, 'disable_control_revisions' ], 10, 2 ); add_action( 'pre_get_posts', [ __CLASS__, 'hide_control_from_admin_post_list' ] ); add_action( 'transition_post_status', [ __CLASS__, 'auto_add_on_publish' ], 20, 3 ); } // Auto-queues a story for LinkedIn + X the moment it is published, so today's/yesterday's // published articles land in the queue without a manual "Add" click. Falls back to X-only // when there is no featured image yet (LinkedIn requires one). Never touches the hidden // control post or non-"post" content types. public static function auto_add_on_publish( $new_status, $old_status, $post ) { if ( 'publish' !== $new_status || 'publish' === $old_status ) { return; } if ( ! $post instanceof WP_Post || 'post' !== $post->post_type ) { return; } if ( self::is_control_post( $post->ID ) ) { return; } $result = self::add_to_queue( $post->ID, [ 'linkedin', 'x' ] ); if ( is_wp_error( $result ) && 'ptsc_linkedin_image_missing' === $result->get_error_code() ) { self::add_to_queue( $post->ID, [ 'x' ] ); } } public static function activate() { self::ensure_control_integrity(); self::sync_history_from_state( self::get_state() ); delete_option( 'ptsc_chatgpt_helper_token_hash' ); update_option( self::DATA_VERSION_OPTION, PTSC_VERSION, false ); } public static function maybe_upgrade() { if ( (string) get_option( self::DATA_VERSION_OPTION, '' ) === (string) PTSC_VERSION ) { return; } self::sync_history_from_state( self::get_state() ); // Keep the lean Claude-first contract and permanent completion history across v4 upgrades. delete_option( 'ptsc_chatgpt_helper_token_hash' ); update_option( self::DATA_VERSION_OPTION, PTSC_VERSION, false ); } public static function has_completed_history( $post_id ) { $history = get_post_meta( (int) $post_id, self::HISTORY_META_KEY, true ); return is_array( $history ) && ! empty( $history['complete'] ); } public static function completed_history( $post_id ) { $history = get_post_meta( (int) $post_id, self::HISTORY_META_KEY, true ); return is_array( $history ) ? $history : []; } public static function sync_history_on_control_save( $post_id, $post, $update ) { if ( ! $post instanceof WP_Post || ! self::is_control_post( $post_id ) ) { return; } $state = self::decode_state( (string) $post->post_excerpt, (int) $post_id ); self::sync_history_from_state( $state ); } public static function sync_history_from_state( array $state ) { foreach ( (array) ( $state['items'] ?? [] ) as $item ) { if ( ! is_array( $item ) || empty( $item['post_id'] ) || ! self::item_complete( $item ) ) { continue; } $article_id = (int) $item['post_id']; if ( 'post' !== get_post_type( $article_id ) ) { continue; } $existing = get_post_meta( $article_id, self::HISTORY_META_KEY, true ); if ( ! is_array( $existing ) ) { $existing = []; } $posted_times = []; $platforms = []; foreach ( [ 'linkedin', 'x' ] as $platform ) { $work = self::normalise_work( (array) ( $item['work'][ $platform ] ?? [] ) ); $platforms[ $platform ] = [ 'status' => $work['status'], 'posted_at' => $work['posted_at'], ]; if ( ! empty( $work['posted_at'] ) ) { $posted_times[] = (string) $work['posted_at']; } } $completed_at = $posted_times ? max( $posted_times ) : current_time( 'c' ); update_post_meta( $article_id, self::HISTORY_META_KEY, [ 'complete' => true, 'first_completed_at' => (string) ( $existing['first_completed_at'] ?? $completed_at ), 'last_confirmed_at' => current_time( 'c' ), 'control_rev' => (int) ( $state['rev'] ?? 0 ), 'manual_confirmation' => ! empty( $existing['manual_confirmation'] ), 'source' => (string) ( $existing['source'] ?? 'ai_sync' ), 'confirmed_by' => (int) ( $existing['confirmed_by'] ?? 0 ), 'platforms' => $platforms, ] ); } } public static function disable_control_revisions( $num, $post ) { if ( $post instanceof WP_Post && self::is_control_post( $post->ID ) ) { return 0; } return $num; } public static function hide_control_from_admin_post_list( $query ) { if ( ! is_admin() || ! $query->is_main_query() ) { return; } global $pagenow; if ( 'edit.php' !== $pagenow || 'post' !== $query->get( 'post_type', 'post' ) ) { return; } $control_id = self::control_post_id( false ); if ( ! $control_id ) { return; } $not_in = array_map( 'intval', (array) $query->get( 'post__not_in', [] ) ); $not_in[] = $control_id; $query->set( 'post__not_in', array_values( array_unique( $not_in ) ) ); } public static function is_control_post( $post_id ) { return self::CONTROL_META_VALUE === (string) get_post_meta( (int) $post_id, self::CONTROL_META_KEY, true ); } private static function marked_control_ids() { return array_map( 'intval', get_posts( [ 'post_type' => 'post', 'post_status' => [ 'draft', 'pending', 'private' ], 'posts_per_page' => 20, 'meta_key' => self::CONTROL_META_KEY, 'meta_value' => self::CONTROL_META_VALUE, 'fields' => 'ids', 'orderby' => 'ID', 'order' => 'ASC', 'no_found_rows' => true, 'suppress_filters' => true, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, ] ) ); } public static function control_post_id( $create = true ) { $saved = absint( get_option( self::CONTROL_OPTION, 0 ) ); if ( $saved && self::is_control_post( $saved ) ) { $post = get_post( $saved ); if ( $post && 'trash' !== $post->post_status ) { return $saved; } } $ids = self::marked_control_ids(); if ( $ids ) { $id = (int) min( $ids ); // v2.0.0 also used the oldest marked control post. update_option( self::CONTROL_OPTION, $id, false ); return $id; } if ( ! $create ) { return 0; } $post_id = wp_insert_post( [ 'post_type' => 'post', 'post_status' => 'draft', 'post_title' => self::CONTROL_TITLE, 'post_name' => self::CONTROL_SLUG, 'post_content' => 'Managed automatically by PunjabiTime Social. Do not edit this post manually.', 'post_excerpt' => '', 'comment_status' => 'closed', 'ping_status' => 'closed', ], true ); if ( is_wp_error( $post_id ) ) { return 0; } update_post_meta( $post_id, self::CONTROL_META_KEY, self::CONTROL_META_VALUE ); update_option( self::CONTROL_OPTION, (int) $post_id, false ); self::write_state_without_revision( (int) $post_id, self::default_state( (int) $post_id ) ); return (int) $post_id; } public static function ensure_control_integrity() { static $running = false; if ( $running ) { return; } $running = true; $canonical = self::control_post_id( true ); if ( ! $canonical ) { $running = false; return; } // Make the bridge search deterministic: exactly one Draft keeps the exact control title. foreach ( self::marked_control_ids() as $id ) { if ( (int) $id === (int) $canonical ) { continue; } $post = get_post( $id ); if ( ! $post ) { continue; } update_post_meta( $id, self::LEGACY_META_KEY, '1' ); delete_post_meta( $id, self::CONTROL_META_KEY ); wp_update_post( [ 'ID' => $id, 'post_title' => self::CONTROL_TITLE . ' Legacy #' . $id, 'post_name' => self::CONTROL_SLUG . '-legacy-' . $id, ] ); } update_post_meta( $canonical, self::CONTROL_META_KEY, self::CONTROL_META_VALUE ); delete_post_meta( $canonical, self::LEGACY_META_KEY ); update_option( self::CONTROL_OPTION, (int) $canonical, false ); $post = get_post( $canonical ); if ( $post && ( self::CONTROL_TITLE !== $post->post_title || 'draft' !== $post->post_status ) ) { wp_update_post( [ 'ID' => $canonical, 'post_title' => self::CONTROL_TITLE, 'post_name' => self::CONTROL_SLUG, 'post_status' => 'draft', ] ); $post = get_post( $canonical ); } $raw = $post ? (string) $post->post_excerpt : ''; $state = self::decode_state( $raw, $canonical ); $json = self::encode_state( $state ); if ( $raw !== $json ) { self::write_state_without_revision( $canonical, $state ); } $running = false; } public static function default_state( $control_id = 0 ) { return [ 'schema' => self::SCHEMA, 'plugin_version' => PTSC_VERSION, 'control_post_id' => (int) $control_id, 'canonical' => true, 'rev' => 1, 'updated_at' => current_time( 'c' ), 'site_timezone' => wp_timezone_string() ?: 'UTC', 'run_command' => self::RUN_PROMPT, // legacy Claude key; do not remove 'run_commands' => [ 'claude' => self::RUN_PROMPT ], 'ai_handoff' => 'punjabitime-ai-bridge', 'batch_order' => [ 'linkedin', 'x' ], 'items' => [], 'last_run' => null, ]; } public static function encode_state( array $state ) { return wp_json_encode( $state, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); } private static function clean_text( $value ) { return trim( html_entity_decode( wp_strip_all_tags( (string) $value ), ENT_QUOTES | ENT_HTML5, get_bloginfo( 'charset' ) ?: 'UTF-8' ) ); } private static function normalise_status( $status ) { $status = sanitize_key( (string) $status ); $aliases = [ 'pending' => 'ready', 'failed' => 'retry', 'skip' => 'off' ]; if ( isset( $aliases[ $status ] ) ) { $status = $aliases[ $status ]; } return in_array( $status, [ 'ready', 'posted', 'retry', 'check', 'off' ], true ) ? $status : 'off'; } private static function normalise_work( array $work ) { $status = self::normalise_status( $work['status'] ?? 'off' ); return [ 'action' => in_array( $status, [ 'ready', 'retry' ], true ) ? 'publish' : 'skip', 'status' => $status, 'attempts' => max( 0, (int) ( $work['attempts'] ?? 0 ) ), 'posted_at' => ! empty( $work['posted_at'] ) ? (string) $work['posted_at'] : null, 'last_error' => ! empty( $work['last_error'] ) ? self::clean_text( $work['last_error'] ) : null, ]; } private static function migrate_item( array $item ) { $work_source = isset( $item['work'] ) && is_array( $item['work'] ) ? $item['work'] : (array) ( $item['platforms'] ?? [] ); $item['work'] = []; foreach ( [ 'linkedin', 'x' ] as $platform ) { $item['work'][ $platform ] = self::normalise_work( (array) ( $work_source[ $platform ] ?? [ 'status' => 'off' ] ) ); } unset( $item['platforms'] ); if ( empty( $item['meta_description'] ) && isset( $item['excerpt'] ) ) { $item['meta_description'] = self::clean_text( $item['excerpt'] ); $item['meta_source'] = 'legacy'; } unset( $item['excerpt'] ); if ( ! isset( $item['featured_image_name'] ) ) { $url = (string) ( $item['featured_image_url'] ?? '' ); $item['featured_image_name'] = $url ? (string) wp_basename( wp_parse_url( $url, PHP_URL_PATH ) ?: '' ) : ''; } $item['payload'] = self::build_payload( $item ); return $item; } public static function decode_state( $raw, $control_id = 0 ) { $state = json_decode( (string) $raw, true ); if ( ! is_array( $state ) ) { $state = self::default_state( $control_id ); } $normal = self::default_state( $control_id ); $normal['rev'] = max( 1, (int) ( $state['rev'] ?? 1 ) ); $normal['updated_at'] = (string) ( $state['updated_at'] ?? current_time( 'c' ) ); $normal['last_run'] = is_array( $state['last_run'] ?? null ) ? $state['last_run'] : null; foreach ( (array) ( $state['items'] ?? [] ) as $item ) { if ( ! is_array( $item ) || empty( $item['post_id'] ) ) { continue; } $normal['items'][] = self::migrate_item( $item ); } return $normal; } private static function write_state_without_revision( $id, array $state ) { $state['schema'] = self::SCHEMA; $state['plugin_version'] = PTSC_VERSION; $state['control_post_id'] = (int) $id; $state['canonical'] = true; $state['run_command'] = self::RUN_PROMPT; // keep current Claude Skill compatible $state['run_commands'] = [ 'claude' => self::RUN_PROMPT ]; $state['ai_handoff'] = 'punjabitime-ai-bridge'; $state['batch_order'] = [ 'linkedin', 'x' ]; $state['site_timezone'] = wp_timezone_string() ?: 'UTC'; $result = wp_update_post( [ 'ID' => (int) $id, 'post_excerpt' => self::encode_state( $state ) ], true ); return is_wp_error( $result ) ? $result : true; } public static function get_state() { self::ensure_control_integrity(); $id = self::control_post_id( true ); if ( ! $id ) { return self::default_state(); } $post = get_post( $id ); return $post ? self::decode_state( $post->post_excerpt, $id ) : self::default_state( $id ); } /** * Compact AI read contract. * * The generic AI Bridge content tool exposes the control-post excerpt as one * nested JSON string. This method keeps the canonical storage unchanged but * returns only the active social work as native structured data. */ public static function ai_queue_snapshot() { $state = self::get_state(); $items = []; foreach ( (array) ( $state['items'] ?? [] ) as $item ) { if ( ! is_array( $item ) || empty( $item['post_id'] ) ) { continue; } $post_id = (int) $item['post_id']; if ( self::has_completed_history( $post_id ) || self::item_complete( $item ) ) { continue; } $item = self::migrate_item( $item ); $has_pending = false; $work = []; foreach ( [ 'linkedin', 'x' ] as $platform ) { $current = self::normalise_work( (array) ( $item['work'][ $platform ] ?? [] ) ); $work[ $platform ] = [ 'action' => $current['action'], 'status' => $current['status'], ]; if ( 'publish' === $current['action'] && in_array( $current['status'], [ 'ready', 'retry' ], true ) ) { $has_pending = true; } } if ( ! $has_pending ) { continue; } $items[] = [ 'post_id' => $post_id, 'work' => $work, 'payload' => (array) ( $item['payload'] ?? self::build_payload( $item ) ), ]; } return [ 'contract' => 'pt_social_compact_v1', 'plugin_version' => PTSC_VERSION, 'schema' => self::SCHEMA, 'control_post_id' => (int) ( $state['control_post_id'] ?? self::control_post_id( true ) ), 'rev' => (int) ( $state['rev'] ?? 1 ), 'run_id' => wp_generate_uuid4(), 'items' => $items, 'item_count' => count( $items ), ]; } private static function save_state_if_revision( array $state, $expected_rev ) { self::ensure_control_integrity(); $id = self::control_post_id( true ); if ( ! $id ) { return new WP_Error( 'ptsc_control_missing', 'Could not find the PunjabiTime Social control post.' ); } $post = get_post( $id ); if ( ! $post ) { return new WP_Error( 'ptsc_control_missing', 'Could not read the PunjabiTime Social control post.' ); } $current = self::decode_state( $post->post_excerpt, $id ); $current_rev = (int) ( $current['rev'] ?? 1 ); if ( $current_rev !== (int) $expected_rev ) { return new WP_Error( 'ptsc_rev_conflict', 'PunjabiTime Social changed after this AI run started. No status update was written.', [ 'expected_rev' => (int) $expected_rev, 'current_rev' => $current_rev ] ); } $state = self::decode_state( self::encode_state( $state ), $id ); $state['rev'] = $current_rev + 1; $state['updated_at'] = current_time( 'c' ); self::prune_state( $state ); $written = self::write_state_without_revision( $id, $state ); if ( is_wp_error( $written ) ) { return $written; } return [ 'control_post_id' => (int) $id, 'rev' => (int) $state['rev'], 'updated_at' => (string) $state['updated_at'], ]; } private static function ai_history_record_for_item( array $item, $new_rev, $now ) { $post_id = (int) ( $item['post_id'] ?? 0 ); $existing = get_post_meta( $post_id, self::HISTORY_META_KEY, true ); if ( ! is_array( $existing ) ) { $existing = []; } $platforms = []; $posted_times = []; foreach ( [ 'linkedin', 'x' ] as $platform ) { $work = self::normalise_work( (array) ( $item['work'][ $platform ] ?? [] ) ); $platforms[ $platform ] = [ 'status' => $work['status'], 'posted_at' => $work['posted_at'], ]; if ( ! empty( $work['posted_at'] ) ) { $posted_times[] = (string) $work['posted_at']; } } $completed_at = $posted_times ? max( $posted_times ) : (string) $now; return [ 'complete' => true, 'first_completed_at' => (string) ( $existing['first_completed_at'] ?? $completed_at ), 'last_confirmed_at' => (string) $now, 'control_rev' => (int) $new_rev, 'manual_confirmation' => false, 'source' => 'ai_sync_compact', 'confirmed_by' => 0, 'platforms' => $platforms, ]; } private static function rollback_history_backups( array $backups ) { foreach ( $backups as $post_id => $backup ) { if ( ! empty( $backup['exists'] ) ) { update_post_meta( (int) $post_id, self::HISTORY_META_KEY, $backup['value'] ); } else { delete_post_meta( (int) $post_id, self::HISTORY_META_KEY ); } } } /** * Compact, idempotent AI result sync. * * Claude sends only post/platform result deltas. WordPress owns the merge, * revision guard, attempts, durable completion history and queue pruning. */ public static function ai_sync_results( $control_post_id, $expected_rev, $run_id, array $results ) { $control_post_id = absint( $control_post_id ); $expected_rev = max( 1, (int) $expected_rev ); $run_id = sanitize_text_field( (string) $run_id ); $canonical_id = self::control_post_id( true ); if ( ! $canonical_id || $control_post_id !== (int) $canonical_id ) { return new WP_Error( 'ptsc_control_mismatch', 'The supplied PunjabiTime Social control post is not canonical.' ); } if ( ! preg_match( '/^[a-f0-9-]{36}$/i', $run_id ) ) { return new WP_Error( 'ptsc_bad_run_id', 'run_id must be the UUID returned by pt_social_queue.' ); } if ( ! $results || count( $results ) > self::MAX_ITEMS * 2 ) { return new WP_Error( 'ptsc_bad_results', 'Provide between 1 and 160 social result items.' ); } $normalised = []; $seen = []; foreach ( $results as $raw ) { if ( ! is_array( $raw ) ) { return new WP_Error( 'ptsc_bad_result', 'Each result must be an object.' ); } $post_id = absint( $raw['post_id'] ?? 0 ); $platform = sanitize_key( (string) ( $raw['platform'] ?? '' ) ); $status = sanitize_key( (string) ( $raw['status'] ?? '' ) ); $error = isset( $raw['error'] ) ? self::clean_text( $raw['error'] ) : ''; if ( ! $post_id || ! in_array( $platform, [ 'linkedin', 'x' ], true ) || ! in_array( $status, [ 'posted', 'retry', 'check' ], true ) ) { return new WP_Error( 'ptsc_bad_result', 'Each result needs a valid post_id, platform and status.' ); } if ( function_exists( 'mb_substr' ) ) { $error = mb_substr( $error, 0, 300, 'UTF-8' ); } else { $error = substr( $error, 0, 300 ); } $key = $post_id . ':' . $platform; if ( isset( $seen[ $key ] ) ) { return new WP_Error( 'ptsc_duplicate_result', 'A platform result was supplied more than once for the same article.', [ 'post_id' => $post_id, 'platform' => $platform ] ); } $seen[ $key ] = true; $normalised[] = [ 'post_id' => $post_id, 'platform' => $platform, 'status' => $status, 'error' => $error, ]; } usort( $normalised, static function( $a, $b ) { $post_cmp = (int) $a['post_id'] <=> (int) $b['post_id']; if ( 0 !== $post_cmp ) { return $post_cmp; } return strcmp( (string) $a['platform'], (string) $b['platform'] ); } ); $result_hash = hash( 'sha256', (string) wp_json_encode( $normalised, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ) ); $state = self::get_state(); $last_run = is_array( $state['last_run'] ?? null ) ? $state['last_run'] : []; if ( $run_id === (string) ( $last_run['run_id'] ?? '' ) ) { if ( hash_equals( (string) ( $last_run['result_hash'] ?? '' ), $result_hash ) ) { return [ 'ok' => true, 'idempotent_replay' => true, 'control_post_id' => (int) $canonical_id, 'rev' => (int) ( $state['rev'] ?? 1 ), 'summary' => $last_run['counts'] ?? [], ]; } return new WP_Error( 'ptsc_run_id_reused', 'This run_id was already used with different results. No update was written.' ); } $current_rev = (int) ( $state['rev'] ?? 1 ); if ( $current_rev !== $expected_rev ) { return new WP_Error( 'ptsc_rev_conflict', 'PunjabiTime Social changed after this AI run started. No status update was written.', [ 'expected_rev' => $expected_rev, 'current_rev' => $current_rev ] ); } $indexes = []; foreach ( (array) ( $state['items'] ?? [] ) as $index => $item ) { $post_id = (int) ( $item['post_id'] ?? 0 ); if ( $post_id ) { $indexes[ $post_id ] = $index; } } // Validate the entire delta set first so a bad result can never cause a partial merge. foreach ( $normalised as $result ) { $post_id = (int) $result['post_id']; if ( ! isset( $indexes[ $post_id ] ) ) { return new WP_Error( 'ptsc_item_missing', 'A result refers to an article that is no longer in the active queue.', [ 'post_id' => $post_id ] ); } $item = self::migrate_item( $state['items'][ $indexes[ $post_id ] ] ); $work = self::normalise_work( (array) ( $item['work'][ $result['platform'] ] ?? [] ) ); if ( 'publish' !== $work['action'] || ! in_array( $work['status'], [ 'ready', 'retry' ], true ) ) { return new WP_Error( 'ptsc_action_not_pending', 'A result refers to a platform action that is no longer pending.', [ 'post_id' => $post_id, 'platform' => $result['platform'], 'status' => $work['status'] ] ); } } $now = current_time( 'c' ); $counts = [ 'attempted' => count( $normalised ), 'linkedin' => [ 'posted' => 0, 'retry' => 0, 'check' => 0 ], 'x' => [ 'posted' => 0, 'retry' => 0, 'check' => 0 ], 'complete' => 0, ]; $touched_posts = []; foreach ( $normalised as $result ) { $post_id = (int) $result['post_id']; $platform = (string) $result['platform']; $index = $indexes[ $post_id ]; $item = self::migrate_item( $state['items'][ $index ] ); $work = self::normalise_work( (array) ( $item['work'][ $platform ] ?? [] ) ); $work['attempts'] = (int) $work['attempts'] + 1; if ( 'posted' === $result['status'] ) { $work['status'] = 'posted'; $work['posted_at'] = $now; $work['last_error'] = null; } elseif ( 'retry' === $result['status'] ) { $work['status'] = 'retry'; $work['posted_at'] = null; $work['last_error'] = '' !== $result['error'] ? $result['error'] : 'Publishing failed; retry required.'; } else { $work['status'] = 'check'; $work['posted_at'] = null; $work['last_error'] = 'Post clicked; result ambiguous. Verify manually before retry.'; } $item['work'][ $platform ] = self::normalise_work( $work ); $state['items'][ $index ] = $item; $touched_posts[ $post_id ] = true; $counts[ $platform ][ $result['status'] ]++; } $new_rev = $current_rev + 1; $history_backups = []; $complete_ids = []; foreach ( array_keys( $touched_posts ) as $post_id ) { $index = $indexes[ $post_id ]; $item = self::migrate_item( $state['items'][ $index ] ); if ( ! self::item_complete( $item ) ) { continue; } $history_backups[ $post_id ] = [ 'exists' => metadata_exists( 'post', $post_id, self::HISTORY_META_KEY ), 'value' => get_post_meta( $post_id, self::HISTORY_META_KEY, true ), ]; update_post_meta( $post_id, self::HISTORY_META_KEY, self::ai_history_record_for_item( $item, $new_rev, $now ) ); if ( ! self::has_completed_history( $post_id ) ) { self::rollback_history_backups( $history_backups ); return new WP_Error( 'ptsc_history_write_failed', 'Could not save permanent social completion history. Queue state was not changed.', [ 'post_id' => $post_id ] ); } $complete_ids[ $post_id ] = true; $counts['complete']++; } // Keep the canonical queue lean: completed records live in durable article history. $state['items'] = array_values( array_filter( (array) $state['items'], static function( $item ) use ( $complete_ids ) { $post_id = (int) ( $item['post_id'] ?? 0 ); if ( isset( $complete_ids[ $post_id ] ) ) { return false; } return ! ( PTSC_State::item_complete( (array) $item ) && PTSC_State::has_completed_history( $post_id ) ); } ) ); $retry_total = (int) $counts['linkedin']['retry'] + (int) $counts['x']['retry']; $check_total = (int) $counts['linkedin']['check'] + (int) $counts['x']['check']; $summary_text = sprintf( 'LinkedIn %d posted · X %d posted · Retry %d · Check %d', (int) $counts['linkedin']['posted'], (int) $counts['x']['posted'], $retry_total, $check_total ); $state['last_run'] = [ 'run_id' => $run_id, 'finished_at' => $now, 'summary' => $summary_text, 'counts' => $counts, 'result_hash' => $result_hash, 'source' => 'ai_bridge_compact', ]; $saved = self::save_state_if_revision( $state, $expected_rev ); if ( is_wp_error( $saved ) ) { self::rollback_history_backups( $history_backups ); return $saved; } return [ 'ok' => true, 'idempotent_replay' => false, 'control_post_id' => (int) $canonical_id, 'rev' => (int) $saved['rev'], 'updated_at' => (string) $saved['updated_at'], 'completed_pruned' => count( $complete_ids ), 'summary' => $counts, ]; } public static function save_state( array $state ) { self::ensure_control_integrity(); $id = self::control_post_id( true ); if ( ! $id ) { return new WP_Error( 'ptsc_control_missing', 'Could not create the PunjabiTime Social control post.' ); } $state = self::decode_state( self::encode_state( $state ), $id ); $state['rev'] = max( 1, (int) ( $state['rev'] ?? 0 ) + 1 ); $state['updated_at'] = current_time( 'c' ); self::prune_state( $state ); return self::write_state_without_revision( $id, $state ); } private static function prune_state( array &$state ) { $items = array_values( (array) ( $state['items'] ?? [] ) ); if ( count( $items ) > self::MAX_ITEMS ) { usort( $items, static function( $a, $b ) { $a_complete = PTSC_State::item_complete( $a ); $b_complete = PTSC_State::item_complete( $b ); if ( $a_complete !== $b_complete ) { return $a_complete ? 1 : -1; } return strcmp( (string) ( $b['added_at'] ?? '' ), (string) ( $a['added_at'] ?? '' ) ); } ); $items = array_slice( $items, 0, self::MAX_ITEMS ); } $state['items'] = $items; } public static function published_for_day( $day = 'today' ) { $tz = wp_timezone(); $now = new DateTimeImmutable( 'now', $tz ); $start = $now->setTime( 0, 0, 0 ); if ( 'yesterday' === $day ) { $start = $start->modify( '-1 day' ); } $end = $start->modify( '+1 day' ); $q = new WP_Query( [ 'post_type' => 'post', 'post_status' => 'publish', 'posts_per_page' => 100, 'orderby' => 'date', 'order' => 'DESC', 'date_query' => [ [ 'after' => $start->format( 'Y-m-d H:i:s' ), 'before' => $end->format( 'Y-m-d H:i:s' ), 'inclusive' => true, 'column' => 'post_date', ] ], 'no_found_rows' => true, 'ignore_sticky_posts' => true, ] ); return [ 'posts' => $q->posts, 'date' => $start ]; } public static function post_snapshot( WP_Post $post ) { $thumb_id = (int) get_post_thumbnail_id( $post->ID ); $thumb_url = $thumb_id ? wp_get_attachment_url( $thumb_id ) : ''; $meta = self::meta_description_for_post( $post->ID ); $snapshot = [ 'post_id' => (int) $post->ID, 'title' => self::clean_text( get_the_title( $post ) ), 'meta_description' => $meta['text'], 'meta_source' => $meta['source'], 'url' => (string) get_permalink( $post ), 'published_at' => (string) get_post_time( 'c', false, $post ), 'slug' => (string) $post->post_name, 'featured_image_id' => $thumb_id, 'featured_image_url' => $thumb_url ?: null, 'featured_image_name' => $thumb_url ? (string) wp_basename( wp_parse_url( $thumb_url, PHP_URL_PATH ) ?: '' ) : '', ]; $snapshot['payload'] = self::build_payload( $snapshot ); return $snapshot; } private static function meta_description_for_post( $post_id ) { $known_keys = [ '_ptseo_meta_description', 'ptseo_meta_description', '_ptseo_description', 'ptseo_description', '_punjabitime_seo_description', 'punjabitime_seo_description', '_punjabitime_meta_description', 'punjabitime_meta_description', '_yoast_wpseo_metadesc', 'rank_math_description', '_aioseo_description', ]; foreach ( $known_keys as $key ) { $value = get_post_meta( $post_id, $key, true ); if ( is_scalar( $value ) && '' !== trim( wp_strip_all_tags( (string) $value ) ) ) { return [ 'text' => self::clean_text( $value ), 'source' => $key ]; } } $all_meta = get_post_meta( $post_id ); foreach ( (array) $all_meta as $key => $values ) { $key_l = strtolower( (string) $key ); $looks_seo = false !== strpos( $key_l, 'seo' ) || false !== strpos( $key_l, 'ptseo' ) || false !== strpos( $key_l, 'punjabitime' ); $looks_desc = false !== strpos( $key_l, 'description' ) || false !== strpos( $key_l, 'metadesc' ); if ( ! $looks_seo || ! $looks_desc ) { continue; } $value = is_array( $values ) ? reset( $values ) : $values; if ( is_scalar( $value ) && '' !== trim( wp_strip_all_tags( (string) $value ) ) ) { return [ 'text' => self::clean_text( $value ), 'source' => (string) $key ]; } } $excerpt = get_post_field( 'post_excerpt', $post_id ); if ( '' !== trim( wp_strip_all_tags( (string) $excerpt ) ) ) { return [ 'text' => self::clean_text( $excerpt ), 'source' => 'excerpt' ]; } return [ 'text' => '', 'source' => 'none' ]; } private static function hashtag_token( $value ) { $value = self::clean_text( $value ); if ( '' === $value ) { return ''; } $parts = preg_split( '/[^\p{L}\p{N}]+/u', $value, -1, PREG_SPLIT_NO_EMPTY ); if ( ! $parts ) { return ''; } $out = ''; foreach ( $parts as $part ) { if ( function_exists( 'mb_convert_case' ) && preg_match( '/^[A-Za-z]/', $part ) ) { $part = mb_convert_case( $part, MB_CASE_TITLE, 'UTF-8' ); } elseif ( preg_match( '/^[A-Za-z]/', $part ) ) { $part = ucfirst( strtolower( $part ) ); } $out .= $part; } return $out; } private static function hashtags_for_snapshot( array $snapshot ) { $tokens = [ 'PunjabiTime' ]; $post_id = (int) ( $snapshot['post_id'] ?? 0 ); if ( $post_id ) { $tags = wp_get_post_tags( $post_id, [ 'fields' => 'names' ] ); if ( ! is_wp_error( $tags ) ) { foreach ( (array) $tags as $tag ) { $tokens[] = self::hashtag_token( $tag ); } } $cats = wp_get_post_categories( $post_id, [ 'fields' => 'names' ] ); if ( ! is_wp_error( $cats ) ) { foreach ( (array) $cats as $cat ) { if ( 0 === strcasecmp( trim( (string) $cat ), 'Uncategorized' ) ) { continue; } $tokens[] = self::hashtag_token( $cat ); } } } $stop = [ 'the','and','for','with','from','into','after','before','over','under','about','will','has','have','had', 'was','were','are','is','its','his','her','their','this','that','these','those','news','latest','said','says', 'new','amid','more','than','who','what','when','where','why','how','of','to','in','on','at','by','as','a','an', 'be','been','being','or','not','up','out','off','vs','via' ]; $slug = trim( (string) ( $snapshot['slug'] ?? '' ) ); if ( '' === $slug && $post_id ) { $slug = (string) get_post_field( 'post_name', $post_id ); } foreach ( preg_split( '/[-_]+/', strtolower( $slug ), -1, PREG_SPLIT_NO_EMPTY ) ?: [] as $word ) { if ( strlen( $word ) < 3 || in_array( $word, $stop, true ) || ctype_digit( $word ) ) { continue; } $tokens[] = self::hashtag_token( $word ); } if ( count( array_filter( $tokens ) ) < 5 ) { $source = trim( (string) ( $snapshot['title'] ?? '' ) . ' ' . (string) ( $snapshot['meta_description'] ?? '' ) ); foreach ( preg_split( '/[^\p{L}\p{N}]+/u', $source, -1, PREG_SPLIT_NO_EMPTY ) ?: [] as $word ) { $lower = function_exists( 'mb_strtolower' ) ? mb_strtolower( $word, 'UTF-8' ) : strtolower( $word ); if ( ( function_exists( 'mb_strlen' ) ? mb_strlen( $word, 'UTF-8' ) : strlen( $word ) ) < 3 || in_array( $lower, $stop, true ) || ctype_digit( $word ) ) { continue; } $tokens[] = self::hashtag_token( $word ); if ( count( $tokens ) >= 10 ) { break; } } } $hashtags = []; $seen = []; foreach ( $tokens as $token ) { $token = trim( (string) $token ); if ( '' === $token ) { continue; } $key = function_exists( 'mb_strtolower' ) ? mb_strtolower( $token, 'UTF-8' ) : strtolower( $token ); if ( isset( $seen[ $key ] ) ) { continue; } $seen[ $key ] = true; $hashtags[] = '#' . $token; if ( 5 === count( $hashtags ) ) { break; } } $fallbacks = [ '#BreakingNews', '#WorldNews', '#NewsUpdate', '#PunjabNews' ]; foreach ( $fallbacks as $fallback ) { if ( 5 === count( $hashtags ) ) { break; } $key = strtolower( ltrim( $fallback, '#' ) ); if ( isset( $seen[ $key ] ) ) { continue; } $seen[ $key ] = true; $hashtags[] = $fallback; } return array_slice( $hashtags, 0, 5 ); } private static function build_payload( array $snapshot ) { $title = trim( (string) ( $snapshot['title'] ?? '' ) ); $meta = trim( (string) ( $snapshot['meta_description'] ?? '' ) ); $url = trim( (string) ( $snapshot['url'] ?? '' ) ); $hashtags = self::hashtags_for_snapshot( $snapshot ); $tag_line = implode( ' ', $hashtags ); // LinkedIn receives one complete, professional news post. The AI publisher only pastes it once. $linkedin_parts = []; if ( '' !== $title ) { $linkedin_parts[] = $title; } if ( '' !== $meta ) { $linkedin_parts[] = $meta; } if ( '' !== $url ) { $linkedin_parts[] = 'Read the full story: ' . $url; } if ( '' !== $tag_line ) { $linkedin_parts[] = $tag_line; } // X stays deliberately lean for speed. $x_parts = array_values( array_filter( [ $title, $url, $tag_line ], static fn( $v ) => '' !== trim( (string) $v ) ) ); return [ 'linkedin' => [ 'text' => implode( "\n\n", $linkedin_parts ), 'hashtags' => $hashtags, 'image_url' => $snapshot['featured_image_url'] ?? null, 'image_filename' => (string) ( $snapshot['featured_image_name'] ?? '' ), 'image_required' => true, 'text_locked' => true, 'rewrite_allowed' => false, 'paste_once' => true, 'post_style' => 'professional_news', ], 'x' => [ 'text' => implode( "\n", $x_parts ), 'hashtags' => $hashtags, 'image_required' => false, 'text_locked' => true, 'rewrite_allowed' => false, 'paste_once' => true, ], ]; } private static function refresh_item_snapshot( array $item ) { $post = get_post( (int) ( $item['post_id'] ?? 0 ) ); if ( ! $post || 'post' !== $post->post_type || 'publish' !== $post->post_status ) { return self::migrate_item( $item ); } $snapshot = self::post_snapshot( $post ); foreach ( $snapshot as $key => $value ) { $item[ $key ] = $value; } return self::migrate_item( $item ); } public static function queue_item_for_post( array $state, $post_id ) { foreach ( (array) ( $state['items'] ?? [] ) as $item ) { if ( (int) ( $item['post_id'] ?? 0 ) === (int) $post_id ) { return self::migrate_item( $item ); } } return null; } public static function add_to_queue( $post_id, array $platforms ) { $post = get_post( (int) $post_id ); if ( ! $post || 'post' !== $post->post_type || 'publish' !== $post->post_status ) { return new WP_Error( 'ptsc_bad_post', 'Only published WordPress posts can be added.' ); } if ( self::has_completed_history( $post_id ) ) { return new WP_Error( 'ptsc_already_complete', 'This article is already fully published and permanently recorded.' ); } $selected = array_values( array_intersect( [ 'linkedin', 'x' ], array_map( 'sanitize_key', $platforms ) ) ); if ( ! $selected ) { return new WP_Error( 'ptsc_no_platform', 'Select LinkedIn, X, or both.' ); } $snapshot = self::post_snapshot( $post ); if ( in_array( 'linkedin', $selected, true ) && empty( $snapshot['featured_image_url'] ) ) { return new WP_Error( 'ptsc_linkedin_image_missing', 'LinkedIn requires this article\'s featured image. Add a featured image first or select only X.' ); } $state = self::get_state(); $index = null; foreach ( $state['items'] as $i => $item ) { if ( (int) ( $item['post_id'] ?? 0 ) === (int) $post_id ) { $index = $i; break; } } if ( null === $index ) { $item = $snapshot; $item['added_at'] = current_time( 'c' ); $item['work'] = []; foreach ( [ 'linkedin', 'x' ] as $platform ) { $item['work'][ $platform ] = self::normalise_work( [ 'status' => in_array( $platform, $selected, true ) ? 'ready' : 'off' ] ); } array_unshift( $state['items'], $item ); } else { $item = self::refresh_item_snapshot( $state['items'][ $index ] ); foreach ( [ 'linkedin', 'x' ] as $platform ) { $current = self::normalise_work( (array) ( $item['work'][ $platform ] ?? [] ) ); if ( 'posted' === $current['status'] ) { $item['work'][ $platform ] = $current; continue; } if ( in_array( $platform, $selected, true ) ) { if ( 'off' === $current['status'] ) { $current['status'] = 'ready'; } // check remains held until the user explicitly presses Retry or Mark Posted. } else { $current['status'] = 'off'; $current['last_error'] = null; } $item['work'][ $platform ] = self::normalise_work( $current ); } $state['items'][ $index ] = $item; } return self::save_state( $state ); } public static function remove_from_queue( $post_id ) { $state = self::get_state(); $before = count( $state['items'] ); $state['items'] = array_values( array_filter( $state['items'], static fn( $item ) => (int) ( $item['post_id'] ?? 0 ) !== (int) $post_id ) ); if ( count( $state['items'] ) === $before ) { return true; } return self::save_state( $state ); } public static function retry_action( $post_id, $platform ) { $platform = sanitize_key( $platform ); if ( ! in_array( $platform, [ 'linkedin', 'x' ], true ) ) { return new WP_Error( 'ptsc_bad_platform', 'Invalid platform.' ); } $state = self::get_state(); foreach ( $state['items'] as &$item ) { if ( (int) ( $item['post_id'] ?? 0 ) !== (int) $post_id ) { continue; } $item = self::refresh_item_snapshot( $item ); if ( 'linkedin' === $platform && empty( $item['featured_image_url'] ) ) { return new WP_Error( 'ptsc_linkedin_image_missing', 'LinkedIn requires a featured image before retry.' ); } $current = self::normalise_work( (array) ( $item['work'][ $platform ] ?? [] ) ); if ( 'posted' === $current['status'] ) { return true; } $current['status'] = 'ready'; $current['last_error'] = null; $current['posted_at'] = null; $item['work'][ $platform ] = self::normalise_work( $current ); unset( $item ); return self::save_state( $state ); } unset( $item ); return new WP_Error( 'ptsc_item_missing', 'Queue item not found.' ); } public static function mark_posted( $post_id, $platform ) { $platform = sanitize_key( $platform ); if ( ! in_array( $platform, [ 'linkedin', 'x' ], true ) ) { return new WP_Error( 'ptsc_bad_platform', 'Invalid platform.' ); } $state = self::get_state(); foreach ( $state['items'] as &$item ) { if ( (int) ( $item['post_id'] ?? 0 ) !== (int) $post_id ) { continue; } $current = self::normalise_work( (array) ( $item['work'][ $platform ] ?? [] ) ); $current['status'] = 'posted'; $current['posted_at'] = current_time( 'c' ); $current['last_error'] = null; $item['work'][ $platform ] = self::normalise_work( $current ); unset( $item ); return self::save_state( $state ); } unset( $item ); return new WP_Error( 'ptsc_item_missing', 'Queue item not found.' ); } /** * Manual newsroom override: confirm one or more articles as fully published on LinkedIn and X. * Batch mode saves the canonical queue once and then writes durable article history. */ public static function mark_stories_published( array $post_ids ) { $post_ids = array_values( array_unique( array_filter( array_map( 'absint', $post_ids ) ) ) ); if ( ! $post_ids ) { return new WP_Error( 'ptsc_no_posts', 'Select at least one article.' ); } if ( count( $post_ids ) > self::MAX_ITEMS ) { return new WP_Error( 'ptsc_too_many_posts', 'Too many articles selected.' ); } $state = self::get_state(); $now = current_time( 'c' ); $history_updates = []; foreach ( $post_ids as $post_id ) { $post = get_post( $post_id ); if ( ! $post || 'post' !== $post->post_type || 'publish' !== $post->post_status ) { return new WP_Error( 'ptsc_bad_post', 'Only published WordPress posts can be marked Published.' ); } $index = null; foreach ( $state['items'] as $i => $state_item ) { if ( (int) ( $state_item['post_id'] ?? 0 ) === (int) $post_id ) { $index = $i; break; } } if ( null === $index ) { $item = self::post_snapshot( $post ); $item['added_at'] = $now; $item['work'] = []; } else { $item = self::refresh_item_snapshot( $state['items'][ $index ] ); } foreach ( [ 'linkedin', 'x' ] as $platform ) { $current = self::normalise_work( (array) ( $item['work'][ $platform ] ?? [] ) ); $current['status'] = 'posted'; $current['posted_at'] = $now; $current['last_error'] = null; $item['work'][ $platform ] = self::normalise_work( $current ); } if ( null === $index ) { array_unshift( $state['items'], $item ); } else { $state['items'][ $index ] = $item; } $existing = get_post_meta( $post_id, self::HISTORY_META_KEY, true ); if ( ! is_array( $existing ) ) { $existing = []; } $history_updates[ $post_id ] = [ 'complete' => true, 'first_completed_at' => (string) ( $existing['first_completed_at'] ?? $now ), 'last_confirmed_at' => $now, 'manual_confirmation' => true, 'source' => 'manual_dashboard', 'confirmed_by' => get_current_user_id(), 'platforms' => [ 'linkedin' => [ 'status' => 'posted', 'posted_at' => $now ], 'x' => [ 'status' => 'posted', 'posted_at' => $now ], ], ]; } $saved = self::save_state( $state ); if ( is_wp_error( $saved ) ) { return $saved; } foreach ( $history_updates as $post_id => $history ) { update_post_meta( (int) $post_id, self::HISTORY_META_KEY, $history ); } return true; } public static function mark_story_published( $post_id ) { return self::mark_stories_published( [ $post_id ] ); } /** * Undo a completed record and return the article to the normal unqueued working list. * This deliberately removes the queue item too, so the user can choose platforms again. */ public static function restore_story( $post_id ) { $post_id = absint( $post_id ); $post = get_post( $post_id ); if ( ! $post || 'post' !== $post->post_type || 'publish' !== $post->post_status ) { return new WP_Error( 'ptsc_bad_post', 'Only published WordPress posts can be restored.' ); } delete_post_meta( $post_id, self::HISTORY_META_KEY ); $state = self::get_state(); $before = count( $state['items'] ); $state['items'] = array_values( array_filter( $state['items'], static fn( $item ) => (int) ( $item['post_id'] ?? 0 ) !== (int) $post_id ) ); if ( count( $state['items'] ) !== $before ) { $saved = self::save_state( $state ); if ( is_wp_error( $saved ) ) { return $saved; } } return true; } public static function clear_complete() { $state = self::get_state(); $state['items'] = array_values( array_filter( $state['items'], static fn( $item ) => ! PTSC_State::item_complete( $item ) ) ); return self::save_state( $state ); } public static function item_complete( array $item ) { $has_posted = false; foreach ( [ 'linkedin', 'x' ] as $platform ) { $status = self::normalise_status( $item['work'][ $platform ]['status'] ?? 'off' ); if ( in_array( $status, [ 'ready', 'retry', 'check' ], true ) ) { return false; } if ( 'posted' === $status ) { $has_posted = true; } } return $has_posted; } public static function stats( array $state ) { $stats = [ 'queue_items' => 0, 'ready_actions' => 0, 'attention' => 0, 'complete_items' => 0, 'posted_actions' => 0, ]; foreach ( (array) ( $state['items'] ?? [] ) as $item ) { if ( self::item_complete( $item ) ) { $stats['complete_items']++; } else { $stats['queue_items']++; } foreach ( [ 'linkedin', 'x' ] as $platform ) { $status = self::normalise_status( $item['work'][ $platform ]['status'] ?? 'off' ); if ( in_array( $status, [ 'ready', 'retry' ], true ) ) { $stats['ready_actions']++; } if ( 'check' === $status ) { $stats['attention']++; } if ( 'posted' === $status ) { $stats['posted_actions']++; } } } return $stats; } }