'#2f48c9', 'ink' => '#1b1e2e', 'green' => '#146b47', 'red' => '#b3322b', 'yellow' => '#ffe14d', ); // The website's demo recording: no data-article, so the preview sends no beacon. private const PREVIEW_PATH = '/demo/news-f4-a8455be356b6.mp3'; private const PANEL_URL = 'https://readerr.app/app'; private const HELP_URL = 'https://readerr.app/help'; private const PRIVACY_URL = 'https://readerr.app/privacy'; private const HELP_BASE = 'https://readerr.app/help/'; private const LOGO = ''; public const DEFAULTS = array( 'lang' => 'pl', 'generate_by_default' => true, 'generate_scheduled' => false, 'min_length' => 1500, 'position' => 'before', 'paragraph' => 1, 'scheme' => 'light', 'on_update' => 'keep', 'colour' => 'blue', ); /** * docs/todo.md, "Adresy infrastruktury nieedytowalne na produkcji": these * three are our infrastructure, not a client choice. The portal option is * never consulted for them — only a wp-config.php constant can move them, * and the production value otherwise wins. Keeping the API key sendable to * an address a portal admin can redirect is exactly the leak §2.4 exists * to prevent. */ private const INFRA = array( 'service_url' => array( 'constant' => 'READERR_SERVICE_URL', // [Δ] The design doc (§3.2) names the production host but never // pins a scheme+domain literal; docs/zalozenia.md #52 records this // default as an assumption pending the owner's confirmation. 'production' => 'https://api.readerr.app', ), 'cdn_base' => array( 'constant' => 'READERR_CDN_BASE', 'production' => 'https://cdn.readerr.app', ), 'player_url' => array( 'constant' => 'READERR_PLAYER_URL', 'production' => 'https://cdn.readerr.app/player.js', ), ); public static function register(): void { add_action( 'admin_init', array( self::class, 'register_settings' ) ); add_action( 'admin_menu', array( self::class, 'add_page' ) ); add_action( 'admin_init', array( self::class, 'add_privacy_policy_content' ) ); add_action( 'admin_enqueue_scripts', array( self::class, 'enqueue_page_assets' ) ); // The activation hook misses a site restored from a backup or a plugin // copied in by hand; admin_init catches those without a query on every // front-end request. add_action( 'admin_init', array( self::class, 'activate' ) ); // Task 6: the player's position (before/after/after a paragraph/ // manual) sits in every article's cached HTML, so a changed setting // must drop every page a cache plugin holds, not just one. // update_option_{option} fires only when the saved value actually // changed — WordPress core skips it on a no-op save — so submitting // the form unchanged purges nothing. add_action( 'update_option_' . self::OPTIONS, array( self::class, 'purge_after_save' ) ); add_filter( 'plugin_action_links_' . plugin_basename( READERR_FILE ), array( self::class, 'action_links' ) ); } /** * "Settings" ahead of "Deactivate" on the Plugins screen. * * @param array $links * @return array */ public static function action_links( array $links ): array { $settings = '' . esc_html__( 'Settings', 'readerr' ) . ''; return array( 'settings' => $settings ) + $links; } /** * Suggested text for Settings > Privacy > Policy Guide: what the plugin, * the player and the listening statistics send, and to whom. */ public static function add_privacy_policy_content(): void { if ( ! function_exists( 'wp_add_privacy_policy_content' ) ) { return; } $text = '

' . 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 { ?>

quota(); // Spec §6.1: the settings screen refreshes access on every visit, // off the very call already made here for the quota line below — // no second request to the service. Readerr_Access::apply_response( $response ); $quota = 200 === $response['status'] ? $response['body'] : null; } $default_voice = $client ? Readerr_Editor::catalog( true )['default'] : null; $name = self::OPTIONS; ?>