Status key to translated label. */ public static function get_statuses() { return array( 'planned' => __( 'Planned', 'saidweekly' ), 'queued' => __( 'Queued', 'saidweekly' ), 'drafted' => __( 'Drafted', 'saidweekly' ), 'scheduled' => __( 'Scheduled', 'saidweekly' ), 'published' => __( 'Published', 'saidweekly' ), 'private' => __( 'Private', 'saidweekly' ), ); } /** * Register the swcc_topic post type. * * `show_in_rest` is false deliberately. This is an internal queue screen, not an * authoring surface, so the classic editor gives us a plain meta box, nonce and * save_post path with no block editor JavaScript. * * @return void */ private static function register_post_type() { $labels = array( 'name' => _x( 'Topics', 'post type general name', 'saidweekly' ), 'singular_name' => _x( 'Topic', 'post type singular name', 'saidweekly' ), 'menu_name' => _x( 'Content Calendar', 'admin menu', 'saidweekly' ), 'add_new' => __( 'Add Topic', 'saidweekly' ), 'add_new_item' => __( 'Add New Topic', 'saidweekly' ), 'edit_item' => __( 'Edit Topic', 'saidweekly' ), 'new_item' => __( 'New Topic', 'saidweekly' ), 'view_item' => __( 'View Topic', 'saidweekly' ), 'search_items' => __( 'Search Topics', 'saidweekly' ), 'not_found' => __( 'No topics found.', 'saidweekly' ), 'not_found_in_trash' => __( 'No topics found in Trash.', 'saidweekly' ), 'all_items' => __( 'All Topics', 'saidweekly' ), ); register_post_type( self::POST_TYPE, array( 'labels' => $labels, 'public' => false, 'publicly_queryable' => false, 'show_ui' => true, 'show_in_menu' => true, 'show_in_rest' => false, 'menu_position' => 20, 'menu_icon' => 'dashicons-calendar-alt', 'capability_type' => array( 'swcc_topic', 'swcc_topics' ), 'capabilities' => self::capabilities(), 'map_meta_cap' => false, 'hierarchical' => false, 'has_archive' => false, 'rewrite' => false, 'query_var' => false, // No 'author' support: the topic's own post_author records who entered it, which is // provenance rather than something to edit, and is shown as a line on the editor. // Keeping the support would put a second dropdown labelled "Author" in Bulk Edit // beside "Article author" - one changing who typed the topic in, the other who the // article is credited to - which is a foot-gun, not a feature. 'supports' => array( 'title' ), ) ); } /** * Limit editorial-calendar access to site administrators. * * A topic can enqueue billable generation and cause content to be delivered, so ordinary * author/editor capabilities are intentionally insufficient. * * @return array */ private static function capabilities() { return array( 'edit_post' => 'manage_options', 'read_post' => 'manage_options', 'delete_post' => 'manage_options', 'edit_posts' => 'manage_options', 'edit_others_posts' => 'manage_options', 'publish_posts' => 'manage_options', 'read_private_posts' => 'manage_options', 'delete_posts' => 'manage_options', 'delete_private_posts' => 'manage_options', 'delete_published_posts' => 'manage_options', 'delete_others_posts' => 'manage_options', 'edit_private_posts' => 'manage_options', 'edit_published_posts' => 'manage_options', 'create_posts' => 'manage_options', ); } /** * Register post meta with sanitization and auth callbacks. * * Priority order uses the core `menu_order` column rather than meta: it is already * an indexed integer on wp_posts and sorts without a meta join. * * @return void */ private static function register_meta() { $definitions = array( self::META_KEYWORD => array( 'string', 'sanitize_text_field', '' ), self::META_SECONDARY => array( 'string', 'sanitize_text_field', '' ), self::META_ANGLE => array( 'string', array( __CLASS__, 'sanitize_multiline' ), '' ), self::META_OWNER_TAKE => array( 'string', array( __CLASS__, 'sanitize_multiline' ), '' ), self::META_SCHEDULED => array( 'string', array( __CLASS__, 'sanitize_date' ), '' ), self::META_STATUS => array( 'string', array( __CLASS__, 'sanitize_status' ), 'planned' ), self::META_CATEGORY => array( 'integer', array( __CLASS__, 'sanitize_category' ), 0 ), self::META_GENERATED => array( 'integer', 'absint', 0 ), self::META_JOB_ID => array( 'string', 'sanitize_text_field', '' ), self::META_OFFERED_JOB => array( 'string', 'sanitize_text_field', '' ), self::META_AUTHOR => array( 'integer', 'absint', 0 ), SWCC_Rest_Job_Failed::META_LAST_ERROR => array( 'string', 'sanitize_text_field', '' ), self::META_REVISION => array( 'integer', array( __CLASS__, 'sanitize_revision' ), 1 ), self::META_JOB_REVISION => array( 'integer', 'absint', 0 ), self::META_LOCKED => array( 'integer', array( __CLASS__, 'sanitize_flag' ), 0 ), self::META_ORIGIN => array( 'string', array( __CLASS__, 'sanitize_origin' ), 'manual' ), self::META_HOLD => array( 'string', array( __CLASS__, 'sanitize_hold' ), '' ), self::META_HOLD_DETAIL => array( 'string', 'sanitize_text_field', '' ), self::META_SUPERSEDES => array( 'string', 'sanitize_text_field', '' ), self::META_STALE_POSTS => array( 'string', array( __CLASS__, 'sanitize_id_list' ), '' ), // P4 S1: why an AI-written topic was proposed, and the research it rests on. SWCC_Calendar::META_RATIONALE => array( 'string', array( __CLASS__, 'sanitize_multiline' ), '' ), SWCC_Calendar::META_SOURCE_URLS => array( 'string', array( __CLASS__, 'sanitize_multiline' ), '' ), SWCC_Calendar::META_CALENDAR_JOB => array( 'string', 'sanitize_text_field', '' ), ); foreach ( $definitions as $key => $definition ) { list( $type, $sanitize_callback, $default ) = $definition; register_post_meta( self::POST_TYPE, $key, array( 'type' => $type, 'single' => true, 'default' => $default, 'show_in_rest' => false, 'sanitize_callback' => $sanitize_callback, 'auth_callback' => array( __CLASS__, 'auth_meta' ), ) ); } } /** * Authorize reads and writes of protected meta. * * @param bool $allowed Whether the user can act on the meta key. * @param string $meta_key Meta key being checked. * @param int $object_id Post ID. * @return bool */ public static function auth_meta( $allowed, $meta_key, $object_id ) { unset( $allowed, $meta_key ); return current_user_can( 'edit_post', (int) $object_id ); } /** * Topic origins, keyed by stored value. * * @return array */ public static function get_origins() { return array( 'manual' => __( 'Written by hand', 'saidweekly' ), 'import' => __( 'Imported', 'saidweekly' ), 'ai' => __( 'Generated calendar', 'saidweekly' ), ); } /** * Hold reasons, keyed by stored value, each mapped to the sentence shown to the operator. * * A closed vocabulary shared by the list table and the editor. S1 sets `topic_changed`; S3 * `stale`; S4 `missed`, `no_image` and `publish_failed`; P4 the three safety holds. The * sentences say what happens next, not just what went wrong. * * @return array */ public static function get_holds() { return array( 'topic_changed' => __( 'The topic changed after this article was written. It will not publish automatically until you edit the existing article or regenerate it.', 'saidweekly' ), 'stale' => __( 'This article was written for an earlier version of the topic and has been kept as an unlinked draft. It will not publish automatically.', 'saidweekly' ), 'missed' => __( 'Its publication date passed while the site could not publish it. It will not publish automatically; publish it by hand when you are ready.', 'saidweekly' ), 'no_image' => __( 'No suitable photograph was found. In automatic mode an article without a featured image is held; add one and publish when you are ready.', 'saidweekly' ), 'publish_failed' => __( 'WordPress could not publish this article when its time came. Publish it by hand, or check the site for a plugin conflict.', 'saidweekly' ), 'unverified_claim' => __( 'The article makes a claim about the business that was not in your Branding. Check it before publishing.', 'saidweekly' ), 'duplicate' => __( 'The article closely repeats one already on the site. Review it before publishing.', 'saidweekly' ), 'broken_link' => __( 'A link in the article did not resolve. Fix or remove it before publishing.', 'saidweekly' ), 'unverified_quote' => __( 'The article puts words in quotation marks that are not one of the owner quotes in your Branding or this topic. Check them before publishing.', 'saidweekly' ), ); } /** * Constrain a revision to a positive integer. * * @param mixed $value Raw value. * @return int */ public static function sanitize_revision( $value ) { return max( 1, (int) $value ); } /** * Constrain a flag to 0 or 1. * * @param mixed $value Raw value. * @return int */ public static function sanitize_flag( $value ) { return empty( $value ) ? 0 : 1; } /** * Constrain an origin to the closed list. * * @param mixed $value Raw value. * @return string */ public static function sanitize_origin( $value ) { $value = is_string( $value ) ? $value : ''; return array_key_exists( $value, self::get_origins() ) ? $value : 'manual'; } /** * Constrain a hold to the closed list or ''. * * @param mixed $value Raw value. * @return string */ public static function sanitize_hold( $value ) { $value = is_string( $value ) ? $value : ''; return array_key_exists( $value, self::get_holds() ) ? $value : ''; } /** * The topic's current revision. * * @param int $topic_id Topic ID. * @return int */ public static function get_revision( $topic_id ) { return self::sanitize_revision( get_post_meta( (int) $topic_id, self::META_REVISION, true ) ); } /** * The stored hold, or ''. * * @param int $topic_id Topic ID. * @return string */ public static function get_hold( $topic_id ) { return self::sanitize_hold( get_post_meta( (int) $topic_id, self::META_HOLD, true ) ); } /** * The sentence for the topic's hold, or '' when there is none. * * @param int $topic_id Topic ID. * @return string */ public static function get_hold_sentence( $topic_id ) { $hold = self::get_hold( $topic_id ); $holds = self::get_holds(); if ( '' === $hold ) { return ''; } $detail = (string) get_post_meta( (int) $topic_id, self::META_HOLD_DETAIL, true ); return '' === $detail ? $holds[ $hold ] : $holds[ $hold ] . ' ' . sprintf( /* translators: %s: the flagged sentence or URL. */ __( 'Specifically: ā€œ%sā€', 'saidweekly' ), $detail ); } /** * Set a hold. The newest reason wins; there is one slot on purpose, because the operator * needs one sentence and one action, not a list. * * @param int $topic_id Topic ID. * @param string $hold A key from get_holds(). * @param string $detail What it is about, quoted to the operator (P4 S4); '' for nothing. * @return void */ public static function set_hold( $topic_id, $hold, $detail = '' ) { update_post_meta( (int) $topic_id, self::META_HOLD, self::sanitize_hold( $hold ) ); update_post_meta( (int) $topic_id, self::META_HOLD_DETAIL, mb_substr( sanitize_text_field( (string) $detail ), 0, 300 ) ); } /** * Clear the hold. * * @param int $topic_id Topic ID. * @return void */ public static function clear_hold( $topic_id ) { update_post_meta( (int) $topic_id, self::META_HOLD, '' ); update_post_meta( (int) $topic_id, self::META_HOLD_DETAIL, '' ); } /** * Whether the operator has locked the topic. * * @param int $topic_id Topic ID. * @return bool */ public static function is_locked( $topic_id ) { return 1 === self::sanitize_flag( get_post_meta( (int) $topic_id, self::META_LOCKED, true ) ); } /** * Lock or unlock. * * @param int $topic_id Topic ID. * @param bool $locked Whether to lock. * @return void */ public static function set_locked( $topic_id, $locked ) { update_post_meta( (int) $topic_id, self::META_LOCKED, $locked ? 1 : 0 ); } /** * The topic's origin. * * @param int $topic_id Topic ID. * @return string */ public static function get_origin( $topic_id ) { return self::sanitize_origin( get_post_meta( (int) $topic_id, self::META_ORIGIN, true ) ); } /** * The meta fields whose change makes a new revision, with their sanitizers. * * The title is a post column rather than meta and is compared by apply_content_change() * from the value its caller remembered. * * @return array */ private static function revision_fields() { return array( self::META_KEYWORD => 'sanitize_text_field', self::META_SECONDARY => 'sanitize_text_field', self::META_ANGLE => array( __CLASS__, 'sanitize_multiline' ), // The owner's words shape the article like the angle does, so a change is a revision. self::META_OWNER_TAKE => array( __CLASS__, 'sanitize_multiline' ), self::META_SCHEDULED => array( __CLASS__, 'sanitize_date' ), ); } /** * Write the article-defining fields, bumping the revision when any of them changed. * * The one writer for these fields, so the meta box, Quick Edit and the importer cannot * disagree about what counts as a change (P3 S1). Compares each supplied value against the * stored one after sanitizing both, so re-saving an unchanged form is not a revision. * * A change on a topic whose article already exists also sets `hold=topic_changed`: * business.md says a changed topic never lets mismatched content publish, and this is the * moment the mismatch is created. A hand edit of an AI-written topic makes it `manual` and * locks it (D-4). Neither happens on a save that changed nothing, and neither happens when * `$bump` is false - the creation path, where every field goes from empty to its first value * and that is revision 1, not 2. * * @param int $topic_id Topic ID. * @param array $fields Meta key => new value, any subset of the four metas. * @param string|null $old_title The title before this save, when the caller has it * (the post row is already updated by the time * save_post fires). Null means unknown or unchanged. * @param bool $bump False on creation: write the fields, count nothing. * @return bool Whether the revision was bumped. */ public static function apply_content_change( $topic_id, array $fields, $old_title = null, $bump = true ) { $topic_id = (int) $topic_id; $changed = false; // A queued topic is fixed (1.0.4, the owner's rule): the article being written is the // article for THIS topic, so nothing that defines it changes until the draft lands or the // job is released. Refused here, the one writer, so Quick Edit, the schedule rule and the // meta box cannot disagree; each shows the operator why in its own place. if ( $bump && self::is_fixed( $topic_id ) ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- deliberate diagnostic logging per CLAUDE.md. error_log( sprintf( '[swcc] topic change refused topic=%d reason=fixed_while_queued', $topic_id ) ); return false; } foreach ( self::revision_fields() as $key => $sanitizer ) { if ( ! array_key_exists( $key, $fields ) ) { continue; } $new = (string) call_user_func( $sanitizer, (string) $fields[ $key ] ); $old = (string) call_user_func( $sanitizer, (string) get_post_meta( $topic_id, $key, true ) ); // Written unconditionally, including when equal or empty: sorting the list table by // a meta key uses an INNER JOIN, so a topic missing the key would drop out. update_post_meta( $topic_id, $key, $new ); if ( $new !== $old ) { $changed = true; } } if ( null !== $old_title ) { $post = get_post( $topic_id ); if ( $post instanceof WP_Post && trim( (string) $old_title ) !== trim( $post->post_title ) ) { $changed = true; } } if ( ! $changed || ! $bump ) { return false; } update_post_meta( $topic_id, self::META_REVISION, self::get_revision( $topic_id ) + 1 ); if ( (int) get_post_meta( $topic_id, self::META_GENERATED, true ) > 0 ) { self::set_hold( $topic_id, 'topic_changed' ); } if ( 'ai' === self::get_origin( $topic_id ) ) { update_post_meta( $topic_id, self::META_ORIGIN, 'manual' ); self::set_locked( $topic_id, true ); } return true; } /** * Constrain a comma-separated list of post IDs. * * @param mixed $value Raw value. * @return string */ public static function sanitize_id_list( $value ) { $ids = array_filter( array_map( 'absint', explode( ',', (string) $value ) ) ); return implode( ',', array_values( array_unique( $ids ) ) ); } /** * Whether a regeneration is in flight for this topic. * * @param int $topic_id Topic ID. * @return bool */ public static function is_regenerating( $topic_id ) { return '' !== (string) get_post_meta( (int) $topic_id, self::META_SUPERSEDES, true ); } /** * Begin a regeneration: a new job for a topic that already has an article (P3 S3). * * Idempotent: a regeneration already in flight returns its job. The new job supersedes the * delivered one, records the current revision, and clears a `topic_changed` hold, because * regenerating is one of the two actions that resolve it. The old article stays linked * until the new one lands; create-draft decides whether to replace it in place or add the * new article beside it (D-5). * * @param int $topic_id Topic ID. * @return string The regeneration job ID, or '' when the topic has no article to regenerate. */ public static function start_regeneration( $topic_id ) { $topic_id = (int) $topic_id; if ( (int) get_post_meta( $topic_id, self::META_GENERATED, true ) <= 0 ) { return ''; } if ( self::is_regenerating( $topic_id ) ) { return (string) get_post_meta( $topic_id, self::META_JOB_ID, true ); } $old = (string) get_post_meta( $topic_id, self::META_JOB_ID, true ); $job_id = self::mint_job_id(); // '-' rather than '' when the delivered article carries no job ID (a post delivered // before job IDs existed), so is_regenerating() still reads true. update_post_meta( $topic_id, self::META_SUPERSEDES, '' === $old ? '-' : $old ); update_post_meta( $topic_id, self::META_JOB_ID, $job_id ); update_post_meta( $topic_id, self::META_OFFERED_JOB, '' ); update_post_meta( $topic_id, self::META_JOB_REVISION, self::get_revision( $topic_id ) ); if ( 'topic_changed' === self::get_hold( $topic_id ) ) { self::clear_hold( $topic_id ); } return $job_id; } /** * End a regeneration, keeping whichever job ID the caller has already linked. * * @param int $topic_id Topic ID. * @return void */ public static function finish_regeneration( $topic_id ) { update_post_meta( (int) $topic_id, self::META_SUPERSEDES, '' ); } /** * Undo start_regeneration() when the job could not be enqueued: the delivered job ID is * restored so the topic reads exactly as before. * * @param int $topic_id Topic ID. * @return void */ public static function abort_regeneration( $topic_id ) { $topic_id = (int) $topic_id; $old = (string) get_post_meta( $topic_id, self::META_SUPERSEDES, true ); update_post_meta( $topic_id, self::META_JOB_ID, '-' === $old ? '' : $old ); update_post_meta( $topic_id, self::META_SUPERSEDES, '' ); } /** * Remember a stale result kept beside the topic (D-5). * * @param int $topic_id Topic ID. * @param int $post_id The unlinked draft. * @return void */ public static function record_stale_post( $topic_id, $post_id ) { $existing = (string) get_post_meta( (int) $topic_id, self::META_STALE_POSTS, true ); update_post_meta( (int) $topic_id, self::META_STALE_POSTS, self::sanitize_id_list( $existing . ',' . (int) $post_id ) ); } /** * The stale results kept beside the topic. * * @param int $topic_id Topic ID. * @return int[] */ public static function get_stale_post_ids( $topic_id ) { $list = self::sanitize_id_list( get_post_meta( (int) $topic_id, self::META_STALE_POSTS, true ) ); return '' === $list ? array() : array_map( 'intval', explode( ',', $list ) ); } /** * Constrain a value to a known workflow status. * * @param mixed $value Raw status value. * @return string A valid status key, defaulting to `planned`. */ public static function sanitize_status( $value ) { $value = sanitize_key( (string) $value ); return array_key_exists( $value, self::get_statuses() ) ? $value : 'planned'; } /** * Validate a Y-m-d date string. * * @param mixed $value Raw date value. * @return string A Y-m-d date, or an empty string if the input was not a real date. */ public static function sanitize_date( $value ) { $value = trim( sanitize_text_field( (string) $value ) ); if ( '' === $value ) { return ''; } // The leading `!` zeroes unspecified fields so the round-trip comparison below // rejects inputs like "2026-02-31" that PHP would otherwise roll forward. $date = DateTimeImmutable::createFromFormat( '!Y-m-d', $value ); if ( false === $date || $date->format( 'Y-m-d' ) !== $value ) { return ''; } return $value; } /** * Validate that a value is a live term in the core `category` taxonomy. * * @param mixed $value Raw term ID. * @return int A valid category term ID, or 0. */ public static function sanitize_category( $value ) { $term_id = absint( $value ); if ( 0 === $term_id ) { return 0; } $term = get_term( $term_id, 'category' ); if ( ! $term instanceof WP_Term ) { return 0; } return $term_id; } /** * Sanitize a multi-line freeform field, preserving newlines. * * @param mixed $value Raw textarea value. * @return string */ public static function sanitize_multiline( $value ) { return sanitize_textarea_field( (string) $value ); } /** * Resolve the category to assign a generated post to. * * Re-validates at generation time because a category may have been deleted since the * topic was written; falls back to the site default rather than assigning a dead ID. * * @param int $topic_id Topic post ID. * @return int Category term ID. */ public static function resolve_category( $topic_id ) { $term_id = self::sanitize_category( get_post_meta( (int) $topic_id, self::META_CATEGORY, true ) ); if ( 0 !== $term_id ) { return $term_id; } return (int) get_option( 'default_category' ); } /** * Record that a topic has produced a post. * * Single writer for both fields so that status and generated post ID cannot disagree. * * @param int $topic_id Topic post ID. * @param int $post_id ID of the generated post. * @param string $job_id Service job ID, if one is available. * @return void */ public static function mark_drafted( $topic_id, $post_id, $job_id = '' ) { $topic_id = (int) $topic_id; $post_id = (int) $post_id; update_post_meta( $topic_id, self::META_GENERATED, $post_id ); if ( '' !== $job_id ) { update_post_meta( $topic_id, self::META_JOB_ID, sanitize_text_field( $job_id ) ); } // Derived rather than hardcoded to 'drafted', so that if create-draft ever hands us an // already-published post the status follows it. This is what actually delivers the // "status and generated post ID cannot disagree" property claimed above. Callers must // write META_GENERATED before syncing - during wp_insert_post() of the generated post // the reverse lookup finds nothing yet, so this call is what settles the state. SWCC_Topic_Status::sync( $topic_id ); } /** * Record that the service is running a job for this topic. * * @param int $topic_id Topic post ID. * @param string $job_id Job ID, minted here and confirmed by the service. * @return void */ public static function mark_queued( $topic_id, $job_id ) { $job_id = sanitize_text_field( (string) $job_id ); update_post_meta( (int) $topic_id, self::META_JOB_ID, $job_id ); update_post_meta( (int) $topic_id, self::META_OFFERED_JOB, $job_id ); SWCC_Topic_Status::sync( (int) $topic_id ); } /** * Whether a string is a job ID this plugin could have minted. * * @param mixed $job_id Candidate. * @return bool */ public static function is_valid_job_id( $job_id ) { return is_string( $job_id ) && 1 === preg_match( self::JOB_ID_PATTERN, $job_id ); } /** * Mint a fresh, opaque job ID. * * @return string */ public static function mint_job_id() { return 'job_' . bin2hex( random_bytes( 16 ) ); } /** * The job ID this topic's next generation will run under, minting one if none is pending. * * A topic whose job is already running (or has delivered) keeps that ID, so a repeated offer * or a repeated Generate Now click is the same job rather than a second billable one. * * @param int $topic_id Topic post ID. * @return string */ public static function offer_job( $topic_id ) { $topic_id = (int) $topic_id; $running = (string) get_post_meta( $topic_id, self::META_JOB_ID, true ); if ( self::is_valid_job_id( $running ) ) { return $running; } $offered = (string) get_post_meta( $topic_id, self::META_OFFERED_JOB, true ); if ( self::is_valid_job_id( $offered ) ) { return $offered; } $offered = self::mint_job_id(); update_post_meta( $topic_id, self::META_OFFERED_JOB, $offered ); // The revision this job is for. S2's context route compares it to the current one and // refuses a superseded job before anything is billed. update_post_meta( $topic_id, self::META_JOB_REVISION, self::get_revision( $topic_id ) ); return $offered; } /** * Whether a job ID is one this topic is waiting on - offered or running. * * @param int $topic_id Topic post ID. * @param string $job_id Job ID. * @return bool */ public static function job_is_current( $topic_id, $job_id ) { $topic_id = (int) $topic_id; return '' !== $job_id && ( (string) get_post_meta( $topic_id, self::META_JOB_ID, true ) === $job_id || (string) get_post_meta( $topic_id, self::META_OFFERED_JOB, true ) === $job_id ); } /** * Forget a topic's job, offered or running, so the next offer mints a fresh one. * * @param int $topic_id Topic post ID. * @return void */ public static function clear_job( $topic_id ) { update_post_meta( (int) $topic_id, self::META_JOB_ID, '' ); update_post_meta( (int) $topic_id, self::META_OFFERED_JOB, '' ); } /** * Find the topic that offered or is running a job. * * The service addresses everything by job ID now and never learns a topic's post ID, so this * reverse lookup is how every signed request from it finds its topic. * * @param string $job_id Job ID. * @return int Topic post ID, or 0 when no topic knows this job. */ public static function find_by_job_id( $job_id ) { if ( ! self::is_valid_job_id( $job_id ) ) { return 0; } $topic_ids = get_posts( array( 'post_type' => self::POST_TYPE, 'post_status' => array( 'publish', 'draft', 'pending', 'private', 'future', 'trash' ), 'posts_per_page' => 1, 'fields' => 'ids', 'no_found_rows' => true, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- reverse lookup by the job ID the service holds; bounded to one row. 'relation' => 'OR', array( 'key' => self::META_JOB_ID, 'value' => $job_id, ), array( 'key' => self::META_OFFERED_JOB, 'value' => $job_id, ), ), ) ); return empty( $topic_ids ) ? 0 : (int) $topic_ids[0]; } /** * The user who should be credited as the author of this topic's article. * * Falls back to the Branding default for topics created before this field existed, and for * any topic whose stored author has since been deleted or demoted. The fallback is resolved * on read rather than written back, so a topic never silently acquires an author the * operator did not choose. * * @param int $topic_id Topic post ID. * @return int User ID, or 0 on a site with no user who can publish. */ public static function get_author_id( $topic_id ) { $stored = absint( get_post_meta( (int) $topic_id, self::META_AUTHOR, true ) ); if ( $stored > 0 && user_can( $stored, 'publish_posts' ) ) { return $stored; } return SWCC_Settings::get_default_author_id(); } /** * Whether this topic's article author may be changed right now. * * A queued topic is refused: generation is in flight, the delivery that lands will set the * post's author from this topic, and a change made in the meantime applies to a post that * does not exist yet. Every other state is editable, including `drafted` on a trashed post - * trash is reversible, so the article is still the operator's to reassign. * * @param int $topic_id Topic post ID. * @return bool */ public static function author_is_editable( $topic_id ) { return ! self::is_fixed( $topic_id ); } /** * Whether the topic is fixed: a draft has been requested and has not yet arrived (1.0.4). * * Queued, in other words. The fields that define the article, the title, the date, the * category and the priority are read-only in every editor and refused by every writer until * the draft is delivered or the job is released; the author was already held this way. * * @param int $topic_id Topic ID. * @return bool */ public static function is_fixed( $topic_id ) { return 'queued' === self::sanitize_status( get_post_meta( (int) $topic_id, self::META_STATUS, true ) ); } /** * Hold a fixed topic's title and priority on any save that reaches the database (1.0.4). * * On `wp_insert_post_data`, from the plugin bootstrap rather than the admin, because the * editor is one of several ways a title changes: Quick Edit, the REST API and WP-CLI all * save a post, none of them through the meta box. The title is a post column, written by * core before save_post runs, so this filter puts the stored one back. A disabled form is a * convenience, never the control. * * @param array $data Sanitized post data headed for wp_posts. * @param array $postarr Raw post data. * @return array */ public static function keep_fixed_columns( $data, $postarr ) { if ( ! isset( $data['post_type'] ) || self::POST_TYPE !== $data['post_type'] || empty( $postarr['ID'] ) ) { return $data; } $existing = get_post( (int) $postarr['ID'] ); if ( ! $existing instanceof WP_Post || ! self::is_fixed( $existing->ID ) ) { return $data; } if ( isset( $data['post_title'] ) && wp_unslash( $data['post_title'] ) !== $existing->post_title ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- deliberate diagnostic logging per CLAUDE.md. error_log( sprintf( '[swcc] topic title change refused topic=%d reason=fixed_while_queued', $existing->ID ) ); $data['post_title'] = wp_slash( $existing->post_title ); } $data['menu_order'] = $existing->menu_order; return $data; } /** * Whether this topic carries everything generation needs. * * A topic is deliberately creatable without one - the importer accepts a title-only row, and * an operator sketching a calendar should not be forced to write keywords first. But an entry * missing this cannot be generated, and offering it to the service is worse than useless: the * service validated a whole dispatch response as one unit, so a single unready row enqueued * nothing for the entire site and counted towards dormancy. * * @param int $topic_id Topic post ID. * @return bool */ public static function is_ready_to_generate( $topic_id ) { return '' !== trim( (string) get_post_meta( (int) $topic_id, self::META_KEYWORD, true ) ); } /** * Set the intended article author, carrying the change onto the article when one exists. * * The article is the point of the setting, so an existing drafted, scheduled or published * post is reassigned too. A queued topic is refused rather than deferred, and an author who * cannot publish is refused rather than stored - a post owned by a user who cannot publish * it is a trap discovered at the worst moment. * * @param int $topic_id Topic post ID. * @param int $author_id User ID to assign. * @return true|WP_Error True when stored, WP_Error when refused. */ public static function set_author( $topic_id, $author_id ) { $topic_id = (int) $topic_id; $author_id = absint( $author_id ); if ( ! self::author_is_editable( $topic_id ) ) { return new WP_Error( 'swcc_author_locked', __( 'This topic is queued for generation; its author cannot be changed until the draft arrives.', 'saidweekly' ) ); } if ( $author_id <= 0 || ! user_can( $author_id, 'publish_posts' ) ) { return new WP_Error( 'swcc_author_invalid', __( 'That user cannot be an article author.', 'saidweekly' ) ); } update_post_meta( $topic_id, self::META_AUTHOR, $author_id ); $generated = (int) get_post_meta( $topic_id, self::META_GENERATED, true ); if ( $generated > 0 && get_post( $generated ) instanceof WP_Post ) { self::assign_post_author( $generated, $author_id ); } return true; } /** * Write one post's author column directly. * * One column, written directly, rather than wp_update_post(). This is not an optimization * and should not be "tidied" into the API call: wp_update_post() runs the full * wp_insert_post() pipeline, which re-evaluates status against the date and publishes a * `future` post whose date has already passed. A post sitting in `future` past its date is * exactly what a missed cron run leaves behind on the shared hosting this plugin targets - * so reassigning an author would silently publish an article nobody had approved. The same * call also clears the date of a floating draft unless edit_date is passed (MMBC-89). * Neither risk is worth taking to change an integer. * * @param int $post_id Post to reassign. * @param int $author_id User ID to assign. * @return void */ public static function assign_post_author( $post_id, $author_id ) { $post_id = (int) $post_id; $author_id = absint( $author_id ); if ( $author_id <= 0 || (int) get_post_field( 'post_author', $post_id ) === $author_id ) { return; } global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- see the DocBlock; the cache is cleared on the next line. $wpdb->update( $wpdb->posts, array( 'post_author' => $author_id ), array( 'ID' => $post_id ), array( '%d' ), array( '%d' ) ); clean_post_cache( $post_id ); } /** * Ensure every topic carries the meta keys the list table sorts on. * * Sorting by a meta key produces an INNER JOIN, so a topic missing the key would * silently vanish from the sorted list rather than sorting last. A registered meta * `default` does not help: it is returned on read, but no row exists for the join. * * Hooked to save_post so that topics created programmatically — an importer, WP-CLI — * get the keys too, not just those saved through the meta box. * * @param int $post_id Topic post ID. * @return void */ public static function ensure_defaults( $post_id ) { $post_id = (int) $post_id; if ( wp_is_post_revision( $post_id ) || 'auto-draft' === get_post_status( $post_id ) ) { return; } $defaults = array( self::META_SCHEDULED => '', self::META_STATUS => 'planned', self::META_GENERATED => 0, self::META_JOB_ID => '', self::META_OFFERED_JOB => '', // New topics, whether typed in or imported, inherit the Branding default. Seeded at // creation rather than resolved at read time so that changing the default later // affects new topics only - the same rule the brand snapshot follows, and the one // that stops an operator's per-topic choices being silently rewritten. self::META_AUTHOR => SWCC_Settings::get_default_author_id(), // P3 S1: a topic starts at revision 1, unlocked, written by hand, with no hold. // The importer overwrites the origin after this has run. self::META_REVISION => 1, self::META_JOB_REVISION => 0, self::META_LOCKED => 0, self::META_ORIGIN => 'manual', self::META_HOLD => '', ); foreach ( $defaults as $key => $default ) { // metadata_exists(), not get_post_meta(): since WP 5.5 a registered meta `default` // is returned on read, so `'' === get_post_meta()` can never be true for a key // whose default is anything other than ''. META_STATUS defaults to 'planned' and // META_GENERATED to 0, so neither row was ever created here - the docblock above // warns about exactly this hazard while the old check walked into it. That was // masked only because the meta box wrote the status row unconditionally; now that // status is derived and the meta box no longer writes it, this is the sole writer // of the initial value, and a missing row makes a topic permanently undispatchable // (SWCC_Topic_Queue's status clause is an equality compare, which cannot match). if ( ! metadata_exists( 'post', $post_id, $key ) ) { update_post_meta( $post_id, $key, $default ); } } } /** * Migrate topics created before the service-backed workflow was introduced. * * Existing queued topics become planned so Cloud Scheduler can discover them. Old skipped * topics remain non-actionable until an administrator explicitly schedules them again. * * @param int[] $topic_ids Optional explicit set. Empty migrates every topic, which is the * behavior SWCC_Schema::install() depends on. An explicit set * exists so dev fixtures can exercise this real migration against * only their own rows - passing no argument from a fixture rewrote * statuses across a live editorial calendar, and cleanup could not * undo it. * @return void */ public static function migrate_legacy_topics( array $topic_ids = array() ) { if ( empty( $topic_ids ) ) { $topic_ids = get_posts( array( 'post_type' => self::POST_TYPE, 'post_status' => array( 'publish', 'draft', 'pending', 'private', 'future', 'trash' ), 'posts_per_page' => -1, 'fields' => 'ids', 'no_found_rows' => true, 'update_post_meta_cache' => false, ) ); } foreach ( $topic_ids as $topic_id ) { self::migrate_topic( (int) $topic_id ); } } /** * Normalize one topic's legacy meta. * * @param int $topic_id Topic post ID. * @return void */ private static function migrate_topic( $topic_id ) { $status = (string) get_post_meta( $topic_id, self::META_STATUS, true ); if ( 'queued' === $status ) { update_post_meta( $topic_id, self::META_STATUS, 'planned' ); } elseif ( 'skipped' === $status ) { update_post_meta( $topic_id, self::META_STATUS, 'planned' ); update_post_meta( $topic_id, self::META_SCHEDULED, '' ); } elseif ( ! array_key_exists( $status, self::get_statuses() ) ) { update_post_meta( $topic_id, self::META_STATUS, 'planned' ); } // metadata_exists(), not an empty-string comparison: see ensure_defaults() for why a // registered meta `default` makes get_post_meta() useless for absence detection. if ( ! metadata_exists( 'post', $topic_id, self::META_GENERATED ) ) { update_post_meta( $topic_id, self::META_GENERATED, 0 ); } if ( ! metadata_exists( 'post', $topic_id, self::META_JOB_ID ) ) { update_post_meta( $topic_id, self::META_JOB_ID, '' ); } if ( ! metadata_exists( 'post', $topic_id, self::META_OFFERED_JOB ) ) { update_post_meta( $topic_id, self::META_OFFERED_JOB, '' ); } // Schema 8 (plugin 0.11.0, P3 S1): every existing topic was typed in or imported by a // person before any calendar was generated, so `manual` is the honest origin for all of // them; revision 1, unlocked, no hold. Rows only, never values that already exist. foreach ( array( self::META_REVISION => 1, self::META_JOB_REVISION => 0, self::META_LOCKED => 0, self::META_ORIGIN => 'manual', self::META_HOLD => '', ) as $key => $default ) { if ( ! metadata_exists( 'post', $topic_id, $key ) ) { update_post_meta( $topic_id, $key, $default ); } } // Schema 7 (plugin 0.9.0): job IDs are minted here in a new shape, and the service no // longer recognises the old `{site}-{entry}-{date}` ones. A topic still waiting on one of // those is waiting on a job that will never report back, so it is released here rather // than left `queued` until someone presses the button. A topic whose article already // arrived keeps its historical ID - nothing reads it again. $job_id = (string) get_post_meta( $topic_id, self::META_JOB_ID, true ); if ( '' !== $job_id && ! self::is_valid_job_id( $job_id ) && (int) get_post_meta( $topic_id, self::META_GENERATED, true ) <= 0 ) { self::clear_job( $topic_id ); update_post_meta( $topic_id, SWCC_Rest_Job_Failed::META_LAST_ERROR, __( 'Released by the 0.9.0 upgrade: the previous generation job used an identifier the service no longer recognises. It will be generated afresh when it is next due.', 'saidweekly' ) ); update_post_meta( $topic_id, self::META_STATUS, 'planned' ); } } }