connect = $connect; $this->connection = $connection; $this->stdin = $stdin; $this->interactive = $interactive ?? static function (): bool { return defined( 'STDIN' ) && function_exists( 'stream_isatty' ) && stream_isatty( STDIN ); }; } /** * Registers `wp showfm`. * * @param Cli $cli Commands. */ public static function register( Cli $cli ): void { \WP_CLI::add_command( 'showfm', $cli ); } /** * Connects this site to show.fm with a site key. * * Create the key in show.fm under Connected sites, then Add a site with WP-CLI. show.fm * checks that this site answers for the connection before it accepts the key, so the * site must be public, use https and have the plugin active. An existing connection is * kept until the new one succeeds. * * The key is a live credential. Give it in the SHOWFM_KEY environment variable, pipe it * in with `--key=-`, or run the command in a terminal without --key to be asked for it * with the input hidden. `--key=` still works, but the key then stays in your shell * history and shows in the process list. * * ## OPTIONS * * [--key=] * : The site key, or `-` to read it from standard input. Prefer SHOWFM_KEY or `-`. * * ## EXAMPLES * * # Uses SHOWFM_KEY if it is set, otherwise asks for the key (input hidden). * wp showfm connect * * # Reads the key from standard input, for example from a password manager. * pass show showfm/site-key | wp showfm connect --key=- * * @param string[] $args Positional arguments. * @param array $assoc_args Named arguments. */ public function connect( array $args, array $assoc_args ): void { $key = $this->read_key( $assoc_args ); if ( '' === $key ) { \WP_CLI::error( __( 'Give the site key from show.fm: set SHOWFM_KEY, pipe it in with --key=-, or run this in a terminal to be asked for it.', 'showfm' ) ); return; } $outcome = $this->connect->register_with_key( $key ); if ( Connect::STATUS_CONNECTED !== $outcome['status'] ) { $message = Connect::message( $outcome['error'], $outcome['retry_after'] ); if ( '' !== $outcome['reason'] ) { /* translators: %s: reason code from show.fm, for example "mismatch". */ $message .= ' ' . sprintf( __( '(show.fm reason: %s)', 'showfm' ), $outcome['reason'] ); } \WP_CLI::error( $message ); return; } if ( '' !== $outcome['error'] ) { \WP_CLI::warning( Connect::message( $outcome['error'] ) ); } \WP_CLI::success( sprintf( /* translators: %s: the site key with all but its last four characters hidden. */ __( 'Connected to show.fm with key %s.', 'showfm' ), $this->connection->masked_key() ) ); } /** * Shows the connection: state, masked key, expiry, last sync and last ping. * * ## EXAMPLES * * wp showfm status */ public function status(): void { $state = $this->connection->state(); $never = __( 'never', 'showfm' ); $labels = array( Connection::STATE_CONNECTED => __( 'connected', 'showfm' ), Connection::STATE_DISCONNECTED => __( 'not connected', 'showfm' ), Connection::STATE_RECONNECT_NEEDED => __( 'reconnect needed', 'showfm' ), ); if ( $this->connection->issued_elsewhere() ) { $labels[ Connection::STATE_RECONNECT_NEEDED ] = __( 'reconnect needed (connected to a different show.fm environment)', 'showfm' ); } /* translators: %s: connection state. */ \WP_CLI::line( sprintf( __( 'State: %s', 'showfm' ), $labels[ $state ] ?? $state ) ); if ( Connection::STATE_DISCONNECTED === $state ) { return; } $expires = $this->connection->expires_at(); $key = $this->connection->masked_key(); /* translators: %s: site id at show.fm. */ \WP_CLI::line( sprintf( __( 'Site ID: %s', 'showfm' ), (string) $this->connection->site_id() ) ); /* translators: %s: masked site key. */ \WP_CLI::line( sprintf( __( 'Key: %s', 'showfm' ), '' === $key ? __( 'unreadable', 'showfm' ) : $key ) ); /* translators: %s: date and time. */ \WP_CLI::line( sprintf( __( 'Key expires: %s', 'showfm' ), null === $expires ? __( 'unknown', 'showfm' ) : ( 0 === $expires ? $never : self::time( $expires ) ) ) ); /* translators: %s: date and time. */ \WP_CLI::line( sprintf( __( 'Last sync: %s', 'showfm' ), self::time_or( (int) get_option( Health::LAST_SYNC_OPTION, 0 ), $never ) ) ); /* translators: %s: date and time. */ \WP_CLI::line( sprintf( __( 'Last ping: %s', 'showfm' ), self::time_or( (int) get_option( Ping_Endpoint::LAST_PING_OPTION, 0 ), $never ) ) ); if ( Connect::verify_pending() ) { \WP_CLI::line( __( 'Reported in: not yet (retried by the daily health check)', 'showfm' ) ); } $waiting = Api_Client::rate_limit_remaining(); if ( $waiting > 0 ) { /* translators: %d: seconds. */ \WP_CLI::line( sprintf( __( 'Rate limited: waiting %d more seconds', 'showfm' ), $waiting ) ); } } /** * Revokes this site's key at show.fm, removes its credentials and stops its scheduled syncs. * * The revoke is best effort: if show.fm doesn't confirm it, the credentials are still removed * here and the command says where to revoke the key in show.fm. * * ## OPTIONS * * [--yes] * : Do not ask for confirmation. * * ## EXAMPLES * * wp showfm disconnect --yes * * @param string[] $args Positional arguments. * @param array $assoc_args Named arguments. */ public function disconnect( array $args, array $assoc_args ): void { // One read: the state confirmed here is the only state this command may disconnect. $pinned = $this->connection->pinned(); if ( Connection::STATE_DISCONNECTED === $pinned->state() ) { // An earlier disconnect that lost its lease after the swap: finish its clean-up. $finished = Connect::finish_teardown(); if ( null !== $finished ) { \WP_CLI::success( Connect::finished_message( $finished['revoke'] ) ); return; } \WP_CLI::success( __( 'This site is not connected to show.fm.', 'showfm' ) ); return; } \WP_CLI::confirm( __( 'Disconnect this site from show.fm? Posts already created stay.', 'showfm' ), $assoc_args ); $sites = Admin_Status::sites_url_for( $pinned ); $revoke = $this->connect->revoke( $pinned ); try { $done = $this->connect->disconnect( $pinned, 0, $revoke ); } catch ( Connection_Lost $lost ) { \WP_CLI::error( Connect::unfinished_message( $revoke, Connect::ERROR_LOST, true ) ); return; } catch ( Connection_Busy $busy ) { \WP_CLI::error( Connect::unfinished_message( $revoke, Connect::ERROR_BUSY, true ) ); return; } if ( ! $done ) { \WP_CLI::error( __( 'The connection changed while this command ran, so nothing was disconnected. Check it with wp showfm status, then run the command again.', 'showfm' ) ); return; } if ( Connect::REVOKE_FAILED === $revoke ) { \WP_CLI::warning( Connect::revoke_message( $revoke ) . ' ' . $sites ); \WP_CLI::success( __( 'Disconnected here.', 'showfm' ) ); return; } \WP_CLI::success( __( 'Disconnected.', 'showfm' ) . ' ' . Connect::revoke_message( $revoke ) ); } /** * Pull episode changes or show the sync cursor and pending reports. * * ## OPTIONS * * [] * : Use status to inspect sync without HTTP. * * [--dry-run] * : Preview one page (up to 20 changes) without applying or acknowledging those rows. * * [--from-start] * : Replay the feed from zero, keeping existing posts and edit protection. * * @param string[] $args Positional arguments. * @param array $assoc_args Flags. */ public function sync( array $args, array $assoc_args ): void { if ( array( 'status' ) === $args ) { $state = Sync::state(); foreach ( array( 'cursor', 'retry_at', 'error' ) as $key ) { \WP_CLI::line( $key . ': ' . $state[ $key ] ); } \WP_CLI::line( 'pending_reports: ' . count( $state['reports'] ) ); \WP_CLI::line( 'pending_artwork: ' . count( (array) get_option( Sync_Artwork::QUEUE, array() ) ) ); $status = $this->connection->sync_status(); \WP_CLI::line( 'apply_retry: ' . wp_json_encode( $status['retry'] ) ); \WP_CLI::line( 'skipped_rows: ' . wp_json_encode( $status['skipped'] ) ); $log = (array) get_option( Sync_Log::OPTION, array() ); \WP_CLI::line( 'recent_errors: ' . count( $log ) ); foreach ( $log as $entry ) { \WP_CLI::line( 'sync_error: ' . $entry['code'] . '; seq: ' . $entry['seq'] . '; at: ' . $entry['at'] ); } return; } if ( $args ) { \WP_CLI::error( __( 'Use wp showfm sync or wp showfm sync status.', 'showfm' ) ); return; } $result = ( new Sync() )->pull( isset( $assoc_args['dry-run'] ), isset( $assoc_args['from-start'] ) ); $message = sprintf( /* translators: 1: sync outcome, 2: rows handled, 3: cursor. */ __( 'Sync: %1$s; rows: %2$d; cursor: %3$d.', 'showfm' ), $result['status'], $result['rows'], $result['cursor'] ); if ( in_array( $result['status'], array( 'caught_up', 'continuing', 'dry_run', 'dry_run_limit' ), true ) ) { \WP_CLI::success( $message ); } else { \WP_CLI::error( $message ); } } /** * Clears the plugin's cache of show.fm data. * * That is the show and episode details players and lists render from, and the block * editor's lookups. Pages show the blocks' saved copies until the background refresh * fetches the data again. The rest of the object cache is untouched. Sites that never * cached anything are left as they are. * * ## OPTIONS * * * : flush * * [--network] * : On a multisite network, flush every site. * * ## EXAMPLES * * wp showfm cache flush * wp showfm cache flush --network * * @param string[] $args Positional arguments. * @param array $assoc_args Flags. */ public function cache( array $args, array $assoc_args ): void { if ( array( 'flush' ) !== $args ) { \WP_CLI::error( __( 'Use wp showfm cache flush.', 'showfm' ) ); return; } // With a persistent object cache the entries live there, not in the options table, so // the count of deleted rows says nothing: the version bump is what invalidates them. $external = wp_using_ext_object_cache(); if ( ! isset( $assoc_args['network'] ) ) { $removed = Plugin::cache()->flush(); if ( $external ) { /* translators: %d: the cache version. */ \WP_CLI::success( sprintf( __( 'Flushed the show.fm cache (cache version now %d).', 'showfm' ), Cache::version() ) ); return; } \WP_CLI::success( sprintf( /* translators: %d: number of stored entries. */ _n( 'Flushed the show.fm cache: removed %d stored entry.', 'Flushed the show.fm cache: removed %d stored entries.', $removed, 'showfm' ), $removed ) ); return; } if ( ! is_multisite() ) { \WP_CLI::error( __( 'This is not a multisite network. Run wp showfm cache flush without --network.', 'showfm' ) ); return; } $total = 0; $sites = get_sites( array( 'fields' => 'ids', 'number' => 0, ) ); foreach ( $sites as $site ) { switch_to_blog( (int) $site ); try { $removed = Plugin::cache()->flush(); $total += $removed; if ( $external ) { /* translators: 1: site URL, 2: the cache version. */ \WP_CLI::line( sprintf( __( '%1$s: cache version now %2$d.', 'showfm' ), home_url(), Cache::version() ) ); } else { /* translators: 1: site URL, 2: number of stored entries. */ \WP_CLI::line( sprintf( __( '%1$s: removed %2$d stored entries.', 'showfm' ), home_url(), $removed ) ); } } finally { restore_current_blog(); } } if ( $external ) { /* translators: %d: number of sites. */ \WP_CLI::success( sprintf( __( 'Flushed the show.fm cache on %d sites.', 'showfm' ), count( $sites ) ) ); return; } \WP_CLI::success( sprintf( /* translators: 1: number of sites, 2: number of stored entries. */ __( 'Flushed the show.fm cache on %1$d sites: removed %2$d stored entries.', 'showfm' ), count( $sites ), $total ) ); } /** * The key from --key=-, --key=VALUE, SHOWFM_KEY or a hidden prompt, in that order. * * @param array $assoc_args Named arguments; a bare --key is true. */ private function read_key( array $assoc_args ): string { if ( array_key_exists( 'key', $assoc_args ) ) { $value = is_string( $assoc_args['key'] ) ? $assoc_args['key'] : ''; if ( '-' === $value ) { return $this->read_stdin(); } \WP_CLI::warning( __( 'A key given as --key=VALUE stays in your shell history and shows in the process list. Use SHOWFM_KEY or --key=- next time.', 'showfm' ) ); return trim( $value ); } $env = getenv( self::KEY_ENV ); if ( is_string( $env ) && '' !== trim( $env ) ) { return trim( $env ); } if ( ( $this->interactive )() ) { return trim( (string) \cli\prompt( __( 'Site key from show.fm (input hidden)', 'showfm' ), '', ': ', true ) ); } return ''; } /** * The first line of standard input, trimmed. */ private function read_stdin(): string { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Reading standard input, not a file. $handle = fopen( $this->stdin, 'rb' ); if ( false === $handle ) { return ''; } $line = fgets( $handle, self::MAX_KEY_BYTES ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Pairs with the fopen above. fclose( $handle ); return false === $line ? '' : trim( $line ); } /** * A UTC date and time. * * @param int $timestamp Unix time. */ private static function time( int $timestamp ): string { return gmdate( 'Y-m-d H:i', $timestamp ) . ' UTC'; } /** * A UTC date and time, or a fallback for 0. * * @param int $timestamp Unix time, or 0. * @param string $fallback Text for 0. */ private static function time_or( int $timestamp, string $fallback ): string { return $timestamp > 0 ? self::time( $timestamp ) : $fallback; } }