' . esc_html__( 'Readerr makes audio versions of your posts with the Readerr service (readerr.app). This suggested text says what is sent and to whom. Check it against your own setup before you use it.', 'readerr' ) . '
'; $text .= '' . esc_html__( 'Suggested text:', 'readerr' ) . ' '; $text .= '' . esc_html__( 'Some articles on this site have an audio version made by the Readerr service. To make it, the title, text and address of the published article are sent to Readerr, which reads the text with an AI voice. The player shows that the audio is AI-generated.', 'readerr' ) . '
'; $text .= '' . esc_html__( 'When you open an article with a player, your browser loads the player from cdn.readerr.app, and the audio file from there when you press play. Readerr and its content network receive the usual request data, such as your IP address and browser details. The player sets no cookies and stores nothing in your browser.', 'readerr' ) . '
'; $text .= '' . esc_html__( 'When you press play, the player also sends anonymous listening events to beacon.readerr.app: that playing started and how far it got (25, 50, 75 and 95 percent), together with the audio file, the language, the article number and the address of this site. No identifier of you is sent or stored.', 'readerr' ) . '
'; $text .= '' . wp_kses( sprintf( /* translators: %s: link to the Readerr privacy policy */ __( 'More: %s.', 'readerr' ), '' . esc_html( self::PRIVACY_URL ) . '' ), array( 'a' => array( 'href' => true ) ) ) . '
'; wp_add_privacy_policy_content( 'Readerr', $text ); } /** Create the key option with autoload off, once; update_option keeps that flag. */ public static function activate(): void { if ( false === get_option( self::KEY_OPTION, false ) ) { add_option( self::KEY_OPTION, '', '', false ); } } public static function purge_after_save(): void { Readerr_Cache::purge_all(); } public static function get( string $name ) { if ( isset( self::INFRA[ $name ] ) ) { $constant = self::INFRA[ $name ]['constant']; return defined( $constant ) ? constant( $constant ) : self::INFRA[ $name ]['production']; } $options = get_option( self::OPTIONS, array() ); return is_array( $options ) && array_key_exists( $name, $options ) ? $options[ $name ] : self::DEFAULTS[ $name ]; } /** Whether $name (one of the INFRA keys) is overridden from wp-config.php. */ public static function infra_constant( string $name ): ?string { return isset( self::INFRA[ $name ], self::INFRA[ $name ]['constant'] ) && defined( self::INFRA[ $name ]['constant'] ) ? self::INFRA[ $name ]['constant'] : null; } /** * Trimmed on the way out too: WP-CLI's `option update` bypasses the * sanitizer, and a key piped from seed_client.py carries its newline into * the Authorization header. */ public static function api_key(): string { return trim( (string) get_option( self::KEY_OPTION, '' ) ); } public static function set_api_key( string $key ): void { self::activate(); update_option( self::KEY_OPTION, trim( $key ), false ); } public static function register_settings(): void { register_setting( 'readerr', self::OPTIONS, array( 'type' => 'array', 'sanitize_callback' => array( self::class, 'sanitize' ), 'show_in_rest' => false, 'default' => self::DEFAULTS, ) ); register_setting( 'readerr', self::KEY_OPTION, array( 'type' => 'string', 'sanitize_callback' => array( self::class, 'sanitize_key' ), 'show_in_rest' => false, ) ); } /** An empty field means "unchanged": the form never shows the stored key. */ public static function sanitize_key( $value ): string { $value = trim( (string) $value ); return '' === $value ? self::api_key() : sanitize_text_field( $value ); } /** service_url/cdn_base/player_url never land here — INFRA keys come only from get(). */ public static function sanitize( $input ): array { $input = is_array( $input ) ? $input : array(); $lang = $input['lang'] ?? self::get( 'lang' ); $position = $input['position'] ?? self::get( 'position' ); $scheme = $input['scheme'] ?? self::get( 'scheme' ); $update = $input['on_update'] ?? self::get( 'on_update' ); $colour = $input['colour'] ?? self::get( 'colour' ); // A crafted request can send an array here; isset() on an array key // of the wrong type would raise a fatal. $colour = is_scalar( $colour ) ? (string) $colour : ''; return array( 'lang' => in_array( $lang, self::LANGS, true ) ? $lang : self::get( 'lang' ), 'generate_by_default' => ! empty( $input['generate_by_default'] ), 'generate_scheduled' => ! empty( $input['generate_scheduled'] ), 'min_length' => max( 0, (int) ( $input['min_length'] ?? 0 ) ), 'position' => in_array( $position, self::POSITIONS, true ) ? $position : self::get( 'position' ), // There is no paragraph zero: "before the content" is its own position. 'paragraph' => max( 1, (int) ( $input['paragraph'] ?? self::get( 'paragraph' ) ) ), 'scheme' => in_array( $scheme, self::SCHEMES, true ) ? $scheme : self::get( 'scheme' ), 'on_update' => in_array( $update, self::ON_UPDATE, true ) ? $update : self::get( 'on_update' ), 'colour' => isset( self::COLOURS[ $colour ] ) ? $colour : self::get( 'colour' ), ); } public static function add_page(): void { add_options_page( 'Readerr', 'Readerr', 'manage_options', 'readerr', array( self::class, 'render_page' ) ); } /** The real player for the colour preview, plus the page's few styles and its one script. */ public static function enqueue_page_assets( string $hook ): void { if ( 'settings_page_readerr' !== $hook ) { return; } // Nothing is loaded from Readerr servers before an API key is saved: the // preview (and so the player script) waits for one. if ( '' !== self::api_key() ) { Readerr_Render::enqueue_player_script(); } wp_register_style( 'readerr-settings', false, array(), READERR_VERSION ); wp_enqueue_style( 'readerr-settings' ); wp_add_inline_style( 'readerr-settings', '.readerr-head{display:flex;flex-wrap:wrap;align-items:center;gap:12px;margin:16px 0;padding:14px 16px;background:#fff;border:1px solid #c3c4c7;border-radius:6px}' . '.readerr-head h1{margin:0;padding:0;line-height:0}.readerr-logo{height:22px;width:auto}' . '.readerr-head .readerr-sub{color:#646970;border-left:1px solid #c3c4c7;padding-left:12px}' . '.readerr-head .readerr-links{margin-left:auto;display:flex;gap:14px}' . '.readerr-notice{margin:0 0 16px;overflow-wrap:anywhere}' . '.readerr-swatches{display:flex;flex-wrap:wrap;gap:10px;border:0;margin:0;padding:0}' // Core styles .form-table td fieldset label as inline-block; this selector outranks it. . '.form-table .readerr-swatches .readerr-swatch{display:inline-flex;align-items:center;gap:8px;padding:6px 12px 6px 6px;border:1px solid #c3c4c7;border-radius:999px;background:#fff;cursor:pointer}' . '.readerr-swatch input{position:absolute;opacity:0;width:1px;height:1px}' . '.readerr-swatch .readerr-dot{display:inline-block;flex:none;width:24px;height:24px;border-radius:50%;box-shadow:inset 0 0 0 1px rgba(0,0,0,.18)}' . '.readerr-swatch:has(input:checked){border-color:#2271b1;box-shadow:0 0 0 1px #2271b1}' . '.readerr-swatch:has(input:focus-visible){outline:2px solid #2271b1;outline-offset:2px}' . '.readerr-preview{margin-top:14px;padding:16px;max-width:560px;border:1px dashed #c3c4c7;border-radius:8px;background:#fff}' . '.readerr-preview[data-scheme="dark"]{background:#121418;border-color:#121418;color:#e8eaee}' . '.readerr-preview .readerr-cap{margin:0 0 8px;font-size:12px;color:inherit;opacity:.75}' ); wp_register_script( 'readerr-settings', false, array(), READERR_VERSION, array( 'in_footer' => true ) ); wp_enqueue_script( 'readerr-settings' ); wp_add_inline_script( 'readerr-settings', // The voice sample (scripts/site_demo/render_voices.py): fetched only on click. 'document.addEventListener("click",function(e){var b=e.target.closest(".readerr-listen");if(!b)return;' . 'var a=b.readerrAudio||(b.readerrAudio=new Audio());if(!a.paused){a.pause();b.textContent=b.dataset.listen;return;}' . 'a.src=b.dataset.src;a.onended=function(){b.textContent=b.dataset.listen;};a.play().then(function(){b.textContent=b.dataset.stop;},function(){});});' . 'document.addEventListener("change",function(e){var b=document.querySelector(".readerr-preview"),p=b&&b.querySelector("readerr-player");if(!p)return;' . 'if(e.target.name==="' . self::OPTIONS . '[colour]")p.setAttribute("data-colour",e.target.value);' . 'if(e.target.id==="readerr-scheme"){p.setAttribute("data-scheme",e.target.value);b.setAttribute("data-scheme",e.target.value);}});' ); } /** A link to one help-centre topic, escaped here; opens in a new tab so the form is not lost. */ private static function render_help_link( string $slug, string $label ): void { ?>