'published', 'needs_review' => 'needs_review', 'quota_exceeded' => 'quota_exceeded', ); public static function register(): void { // After meta is saved: the block editor writes the sidebar's meta in the // same request, and the gates below must see the editor's choice. add_action( 'wp_after_insert_post', array( self::class, 'on_save' ), 10, 2 ); // Args are ($post_id) only — Action Scheduler matches actions by exact // args, and the attempt number used to be part of them, so a re-save // while a retry was pending (different attempt) never found it and // enqueued a second ingest. The counter now lives in post meta instead. add_action( 'readerr_ingest', array( self::class, 'ingest' ), 10, 1 ); add_action( 'readerr_poll', array( self::class, 'poll' ), 10, 2 ); // Task 6: _readerr_enabled decides whether Readerr_Render::hidden() // shows an existing player at all — flipping it changes an already // cached page's HTML even though the article's own text (and so its // fingerprint) never changes. All three meta hooks funnel to the same // handler because the row might not exist yet (added_post_meta), // might already hold a value (updated_post_meta), or might be cleared // back to unset (deleted_post_meta) — and because the block editor's // REST save goes through the very same update_post_meta() core uses // for every other write, this catches that path too. add_action( 'added_post_meta', array( self::class, 'purge_on_enabled_change' ), 10, 4 ); add_action( 'updated_post_meta', array( self::class, 'purge_on_enabled_change' ), 10, 4 ); add_action( 'deleted_post_meta', array( self::class, 'purge_on_enabled_change' ), 10, 4 ); // Retention: a post that stops being public takes its audio with it — // the file must not stay reachable on the CDN (docs/todo.md). add_action( 'transition_post_status', array( self::class, 'on_transition' ), 10, 3 ); // wp_delete_post( $id, true ) on a published post skips the trash and // never transitions the status. add_action( 'before_delete_post', array( self::class, 'on_delete' ), 10, 1 ); add_action( 'readerr_delete', array( self::class, 'delete' ), 10, 2 ); // 0.7.6: posts that were password-protected before this version still // have audio on the CDN and text at the service until their next save. add_action( 'init', array( self::class, 'schedule_sweep' ), 20 ); add_action( 'readerr_sweep_protected', array( self::class, 'sweep_protected' ) ); // Audio from before 0.5.0 has no stored voice. The moment the post's // voice is about to change is the last moment the old one is known. add_filter( 'add_post_metadata', array( self::class, 'remember_audio_voice' ), 10, 3 ); add_filter( 'update_post_metadata', array( self::class, 'remember_audio_voice' ), 10, 3 ); add_filter( 'delete_post_metadata', array( self::class, 'remember_audio_voice' ), 10, 3 ); } /** * Runs before a write to the post's voice: ready audio without a stored * voice gets the current one, so keeps_audio() sees the change. Leaves the * write itself alone. * * @param null|bool $check Short-circuit value from earlier filters, passed through. */ public static function remember_audio_voice( $check, int $post_id, string $meta_key ) { if ( Readerr_Meta::VOICE === $meta_key && ! metadata_exists( 'post', $post_id, Readerr_Meta::AUDIO_VOICE ) && self::has_ready_audio( $post_id ) ) { update_post_meta( $post_id, Readerr_Meta::AUDIO_VOICE, (string) get_post_meta( $post_id, Readerr_Meta::VOICE, true ) ); } return $check; } /** * @param int|int[] $meta_id Unused: a single id for added/updated_post_meta, * an array of deleted ids for deleted_post_meta. */ public static function purge_on_enabled_change( $meta_id, int $post_id, string $meta_key, $meta_value ): void { if ( Readerr_Meta::ENABLED !== $meta_key || $post_id <= 0 ) { // M2 (final review): delete_post_meta_by_key() fires // deleted_post_meta with $post_id = 0 (no single post to purge). return; } // M2 (final review): the block editor's first REST save of a still- // unpublished draft also fires added_post_meta, and third-party cache // plugins (WP Rocket's rocket_clean_post()) run through this same // meta hook chain too — neither has a cached page to purge yet. Reuse // the same status test as the ingest gate (qualifies()) so "purges" // and "would ever be sent" agree on what counts as published. if ( ! self::status_qualifies( (string) get_post_status( $post_id ) ) ) { return; } Readerr_Cache::purge_post( $post_id ); } public static function on_save( int $post_id, WP_Post $post ): void { if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) { return; } // The editor's choice for this one save: read and gone before any // gate, or a choice made on a save that sent nothing (too short, // switched off, not configured) would outlive it and decide a later // save from WP-CLI or a search-replace plugin. $choice = (string) get_post_meta( $post_id, Readerr_Meta::ON_SAVE, true ); // metadata_exists() reads the cache get_post_meta() just filled; // delete_post_meta() alone queries on every save of every post type. if ( metadata_exists( 'post', $post_id, Readerr_Meta::ON_SAVE ) ) { delete_post_meta( $post_id, Readerr_Meta::ON_SAVE ); } // A password-protected post is private by choice: it is never sent, and // audio it already has goes the way of a post that left `publish`. There // is no status transition when only the password is set, so it is caught // here (retract() does nothing for a post the service never saw). if ( 'post' === $post->post_type && self::is_protected( $post ) ) { self::retract( $post_id ); return; } if ( ! self::qualifies( $post ) ) { return; } // Not configured: nothing could be sent, so neither queue nor claim // "queued" — the editor would see a status that never moves. if ( null === Readerr_Client::from_settings() ) { return; } // R6: a held article may have lost its text at the service (30 days); // an unchanged save still goes out, and the service decides. $held = 'needs_review' === get_post_meta( $post_id, Readerr_Meta::STATUS, true ); if ( ! $held && self::fingerprint( $post ) === get_post_meta( $post_id, Readerr_Meta::HASH, true ) ) { return; } // The hash is left alone: it stays the fingerprint of the text the // audio was made from, so a later "regenerate" with no further edit // still goes out, and audio_is_stale() needs nothing else. if ( self::keeps_audio( $post_id, $choice ) ) { return; } if ( ! as_has_scheduled_action( 'readerr_ingest', array( $post_id ), self::GROUP ) ) { as_enqueue_async_action( 'readerr_ingest', array( $post_id ), self::GROUP ); } update_post_meta( $post_id, Readerr_Meta::STATUS, 'queued' ); } /** * Whether a changed post keeps the audio it has. Only ready audio can be * kept: a post without a file, or queued/held/failed/over the limit, is * sent as before, or an editor would save and see nothing happen. A new * voice is always sent. Otherwise the editor's choice for this save * decides, and without one the portal's on_update setting. */ private static function keeps_audio( int $post_id, string $choice ): bool { if ( ! self::has_ready_audio( $post_id ) ) { return false; } // No stored voice: audio from before 0.5.0 whose voice has not changed // since — remember_audio_voice() stores the old one before any change. if ( metadata_exists( 'post', $post_id, Readerr_Meta::AUDIO_VOICE ) && get_post_meta( $post_id, Readerr_Meta::AUDIO_VOICE, true ) !== (string) get_post_meta( $post_id, Readerr_Meta::VOICE, true ) ) { return false; } return 'keep' === ( '' !== $choice ? $choice : Readerr_Settings::get( 'on_update' ) ); } private static function has_ready_audio( int $post_id ): bool { return 'published' === get_post_meta( $post_id, Readerr_Meta::STATUS, true ) && '' !== Readerr_Meta::sanitize_path( get_post_meta( $post_id, Readerr_Meta::PATH, true ) ); } /** * Ready audio made from a text (or voice) the post no longer has — the * editor says so. Without a hash nothing says what the audio was made * from, so nothing is claimed. */ public static function audio_is_stale( WP_Post $post ): bool { $hash = (string) get_post_meta( $post->ID, Readerr_Meta::HASH, true ); return self::has_ready_audio( $post->ID ) && '' !== $hash && self::fingerprint( $post ) !== $hash; } public static function enabled( int $post_id ): bool { $choice = get_post_meta( $post_id, Readerr_Meta::ENABLED, true ); return 'yes' === $choice || ( 'no' !== $choice && (bool) Readerr_Settings::get( 'generate_by_default' ) ); } /** The first two gates of §12: a qualifying post status, switched on, long enough. */ private static function qualifies( WP_Post $post ): bool { return 'post' === $post->post_type && ! self::is_protected( $post ) && self::status_qualifies( $post->post_status ) && self::enabled( $post->ID ) && self::text_length( $post ) >= (int) Readerr_Settings::get( 'min_length' ); } /** Core's own test for "needs a password" - a password-protected post is never sent or shown. */ private static function is_protected( WP_Post $post ): bool { return '' !== (string) $post->post_password; } /** * `publish` always qualifies; `future` only with the portal's * generate_scheduled switch on (docs/todo.md, "Audio dla wpisów * zaplanowanych (future)") — off by default, so upgrading the plugin * never adds quota usage on its own. The future → publish transition * itself changes neither title, content nor voice, so fingerprint() is * unchanged and on_save()'s hash check alone stops a second ingest — * nothing here needs to know about that transition specifically. */ private static function status_qualifies( string $status ): bool { if ( 'publish' === $status ) { return true; } return 'future' === $status && (bool) Readerr_Settings::get( 'generate_scheduled' ); } /** * What decides "changed" (§12's hash): title, content and the post's voice * (§4). Pass $voice when it was already read, so the hash describes exactly * the snapshot that was sent. */ public static function fingerprint( WP_Post $post, ?string $voice = null ): string { $voice ??= (string) get_post_meta( $post->ID, Readerr_Meta::VOICE, true ); return hash( 'sha256', $post->post_title . "\0" . self::strip_player_block( $post->post_content ) . "\0" . $voice ); } /** * The readerr/player block only marks where the player stands — its * render_callback contributes no text of its own, and the service ignores * the comment entirely (see content_html() and * service/tests/test_extract.py). Stripped here too, together with the * blank line Gutenberg's own block separator puts around it, so inserting, * moving or removing the block is never seen as a content change — unlike * [readerr_player], whose raw shortcode text is a documented, accepted * cost (docs/zalozenia.md #43/#44). */ private static function strip_player_block( string $content ): string { if ( false === strpos( $content, '` after the editor // adds a custom CSS class. Docs/zalozenia.md #50 assumed the exact // bare form; the optional `{...}` here matches both. $stripped = preg_replace( '/\n*\n*/', "\n\n", $content ); return trim( $stripped, "\n" ); } private static function text_length( WP_Post $post ): int { return mb_strlen( trim( wp_strip_all_tags( strip_shortcodes( $post->post_content ) ) ) ); } public static function ingest( int $post_id ): void { // R10: the previous article must be gone at the service first, or the // pending delete would remove what this ingest creates. if ( self::actions_for( 'readerr_delete', $post_id, array( ActionScheduler_Store::STATUS_PENDING, ActionScheduler_Store::STATUS_RUNNING ) ) ) { // Pending only: as_has_scheduled_action() would count this very run. $pending = as_get_scheduled_actions( array( 'hook' => 'readerr_ingest', 'args' => array( $post_id ), 'group' => self::GROUP, 'status' => ActionScheduler_Store::STATUS_PENDING, 'per_page' => 1, ), 'ids' ); if ( ! $pending ) { as_schedule_single_action( time() + self::DELETE_RETRY_S, 'readerr_ingest', array( $post_id ), self::GROUP ); } return; } $attempt = self::ingest_attempt( $post_id ); $post = get_post( $post_id ); // The gates are re-checked here: the post may have been unpublished, // switched off or shortened since it was queued. if ( ! $post || ! self::qualifies( $post ) ) { delete_post_meta( $post_id, Readerr_Meta::INGEST_ATTEMPT ); return; } $client = Readerr_Client::from_settings(); if ( ! $client ) { self::give_up( $post_id ); return; } // One snapshot: what is sent and what the stored hash describes both // come from this single $post read from the database. content_html() // runs no filters and touches no global state, so nothing can rewrite // it out from under the fingerprint — unlike the old the_content // render, which ran with the post as the global one and let a // third-party filter mutate that object in memory. $voice = (string) get_post_meta( $post_id, Readerr_Meta::VOICE, true ); $sent = self::fingerprint( $post, $voice ); $payload = array( 'external_id' => (string) $post_id, 'lang' => (string) Readerr_Settings::get( 'lang' ), 'title' => get_the_title( $post ), 'content_html' => self::content_html( $post ), 'url' => (string) get_permalink( $post ), ); if ( '' !== $voice ) { $payload['voice'] = $voice; } $response = $client->ingest( $payload ); // Final review, I2: the post may have been trashed or unpublished while // the request was out. Its scheduled delete removes the article; writing // meta or a poll now would bring the audio back. clean_post_cache( $post_id ); $post = get_post( $post_id ); if ( ! $post || ! self::qualifies( $post ) ) { return; } $status = $response['status']; if ( 200 === $status ) { delete_post_meta( $post_id, Readerr_Meta::INGEST_ATTEMPT ); // A new version of the post starts its own review window, if it gets one. delete_post_meta( $post_id, Readerr_Meta::REVIEW_SINCE ); update_post_meta( $post_id, Readerr_Meta::HASH, $sent ); update_post_meta( $post_id, Readerr_Meta::AUDIO_VOICE, $voice ); update_post_meta( $post_id, Readerr_Meta::STATUS, 'queued' ); update_post_meta( $post_id, Readerr_Meta::QUEUED_AT, (string) time() ); as_schedule_single_action( time() + self::POLL_FIRST_S, 'readerr_poll', array( $post_id, 1 ), self::GROUP ); self::requeue_if_changed( $post_id, $sent ); } elseif ( 409 === $status ) { // §13.2: the site publishes normally, just without audio. No hash, so // the next save after the limit resets tries again. delete_post_meta( $post_id, Readerr_Meta::INGEST_ATTEMPT ); update_post_meta( $post_id, Readerr_Meta::STATUS, 'quota_exceeded' ); } elseif ( 413 === $status ) { // The service makes audio for texts up to MAX_TEXT_CHARS only. No // hash, so the next save of a shortened post sends it again; audio // already made for an earlier version stays. delete_post_meta( $post_id, Readerr_Meta::INGEST_ATTEMPT ); update_post_meta( $post_id, Readerr_Meta::STATUS, 'text_too_long' ); } elseif ( ( 0 === $status || 429 === $status || $status >= 500 ) && $attempt < self::INGEST_MAX_ATTEMPTS ) { update_post_meta( $post_id, Readerr_Meta::INGEST_ATTEMPT, $attempt + 1 ); as_schedule_single_action( time() + self::INGEST_RETRY_S * $attempt, 'readerr_ingest', array( $post_id ), self::GROUP ); update_post_meta( $post_id, Readerr_Meta::STATUS, 'queued' ); } else { // A rejected key or payload will not fix itself by waiting. self::give_up( $post_id ); } } /** * §6.1: raw post_content, not the reader's rendered the_content — a * theme's or another plugin's the_content filters (share buttons, * "read also" boxes, ad slots) must never reach the service. No * do_blocks(): a dynamic block (e.g. "latest posts") stays a comment and * never turns into text. strip_shortcodes() drops only shortcodes * registered on the portal, together with their content — including our * own [readerr_player]. wpautop() runs only for classic-editor content, * exactly as WordPress core decides whether to apply it to the_content * (do_blocks() skips it once has_blocks() is true). */ private static function content_html( WP_Post $post ): string { $content = strip_shortcodes( $post->post_content ); if ( ! has_blocks( $post->post_content ) ) { $content = wpautop( $content ); } return $content; } /** * A save that lands while this ingest runs finds it in progress — * as_has_scheduled_action() counts running actions too — and queues * nothing. If the post moved on from the snapshot just sent, queue it again. */ private static function requeue_if_changed( int $post_id, string $sent ): void { clean_post_cache( $post_id ); $post = get_post( $post_id ); if ( ! $post || self::fingerprint( $post ) === $sent ) { return; } $pending = as_get_scheduled_actions( array( 'hook' => 'readerr_ingest', 'args' => array( $post_id ), 'group' => self::GROUP, 'status' => ActionScheduler_Store::STATUS_PENDING, 'per_page' => 1, ), 'ids' ); if ( ! $pending ) { as_enqueue_async_action( 'readerr_ingest', array( $post_id ), self::GROUP ); } } /** * A terminal error. The hash goes too: the service re-queues an article in * `error` when it is sent again, and without a hash the next save sends it. */ private static function give_up( int $post_id ): void { delete_post_meta( $post_id, Readerr_Meta::INGEST_ATTEMPT ); delete_post_meta( $post_id, Readerr_Meta::REVIEW_SINCE ); delete_post_meta( $post_id, Readerr_Meta::HASH ); update_post_meta( $post_id, Readerr_Meta::STATUS, 'error' ); } /** The retry counter lives in post meta now — see the note in register(). */ private static function ingest_attempt( int $post_id ): int { $raw = get_post_meta( $post_id, Readerr_Meta::INGEST_ATTEMPT, true ); return '' === $raw ? 1 : max( 1, (int) $raw ); } public static function poll( int $post_id, int $attempt = 1 ): void { $client = Readerr_Client::from_settings(); if ( ! $client ) { self::give_up( $post_id ); return; } $response = $client->article( (string) $post_id ); $body = 200 === $response['status'] ? $response['body'] : null; if ( is_array( $body ) ) { $purged = false; $duration = $body['audio']['duration_ms'] ?? null; $has_duration = is_int( $duration ) && $duration > 0; if ( $has_duration ) { // Spec §17.2 pt 2: the card shows the length before metadata loads. update_post_meta( $post_id, Readerr_Meta::DURATION_MS, (string) $duration ); } $path = $body['audio']['path'] ?? null; if ( is_string( $path ) ) { $clean = Readerr_Meta::sanitize_path( $path ); if ( '' !== $clean ) { $previous = get_post_meta( $post_id, Readerr_Meta::PATH, true ); update_post_meta( $post_id, Readerr_Meta::PATH, $clean ); if ( $clean !== $previous ) { // A new file at a new path is a new