self::PAGE ); if ( '' !== $tab ) { $args['tab'] = $tab; } return add_query_arg( $args, admin_url( 'options-general.php' ) ); } /** * Adds the options page. */ public function add_page(): void { $this->hook = (string) add_options_page( __( 'Sharely.AI', 'sharelyai' ), __( 'Sharely.AI', 'sharelyai' ), 'manage_options', self::PAGE, array( $this, 'render' ) ); } /** * WordPress's id for this screen, once `add_page()` has run. * * Empty before then. It is not a constant: WordPress derives the id from * the parent menu, and that derivation is its business, not ours — which * is exactly why `enqueue()` compares against this rather than a literal. */ public function screen_id(): string { return $this->hook; } /** * Loads the stylesheet, on this screen only. * * @param string $hook The screen WordPress is about to render. */ public function enqueue( string $hook ): void { if ( '' === $this->hook || $hook !== $this->hook ) { return; } wp_enqueue_style( self::STYLE, PLUGIN_URL . 'assets/css/admin.css', array(), Assets::version( 'assets/css/admin.css' ) ); // The poller, and only while there is something to poll. A screen with // no sync running has nothing to ask about, and asking anyway would be // a request every few seconds for the life of the tab. if ( ! ( new Sync( $this->connection ) )->is_running() ) { return; } wp_enqueue_script( self::SCRIPT, PLUGIN_URL . 'assets/js/admin.js', array(), Assets::version( 'assets/js/admin.js' ), true ); wp_add_inline_script( self::SCRIPT, 'window.sharelyaiProgress = ' . wp_json_encode( array( 'url' => admin_url( 'admin-ajax.php' ), 'action' => Progress::ACTION, 'nonce' => wp_create_nonce( Progress::ACTION ), 'running' => true, ) ) . ';', 'before' ); } /** * Renders whichever tab is asked for. */ public function render(): void { if ( ! current_user_can( 'manage_options' ) ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- choosing a tab to read is not a change. $tab = isset( $_GET['tab'] ) && 'settings' === $_GET['tab'] ? 'settings' : 'status'; ?>

render_notice(); ?>
render_settings(); } else { ( new Status( $this->connection ) )->render(); } ?>

connection->post_types(); $retention = (int) $this->connection->get( 'stats_retention_days', 30 ); // Which workspace this is configuring, before the settings that // configure it. It used to come after, which left Save Changes stranded // between the form and a card it had nothing to do with. $this->render_connection_readout(); ?>

connection->is_connected() ) { return; } ?>

' . esc_html( $this->workspace_label() ) . '', esc_html( (string) $this->connection->get( 'key_last4' ) ) ); ?>

%s', esc_url( self::page_url() ), esc_html__( 'Status and disconnect are on the Status tab.', 'sharelyai' ) ); ?>

connection->get( 'workspace_name' ); return '' !== $name ? $name : $this->connection->workspace_id(); } /** * Saves the settings. */ public function handle_save(): void { $this->guard( 'sharelyai_save' ); // phpcs:disable WordPress.Security.NonceVerification.Missing -- guard() calls check_admin_referer() above; the sniff cannot see through it. // Sanitized on the next line, per value: sanitize_key() is what applies to // a post type name, and it cannot be applied to the array itself. $posted = isset( $_POST['post_types'] ) ? (array) wp_unslash( $_POST['post_types'] ) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $types = array_values( array_intersect( Connection::public_post_types(), array_map( 'sanitize_key', array_map( 'strval', $posted ) ) ) ); $retention = isset( $_POST['stats_retention_days'] ) ? (int) $_POST['stats_retention_days'] : 30; $enabled = isset( $_POST['stats_enabled'] ); $search = isset( $_POST['search_enabled'] ); $ask = isset( $_POST['ask_enabled'] ); // Private entries need the workspace to be filtering by role at all. // Letting this be ticked on a workspace with permissions off would // publish members-only content to every visitor. $private = isset( $_POST['sync_private'] ) && (bool) $this->connection->get( 'rbac_enabled' ); $ask_search = isset( $_POST['ask_on_search'] ); // phpcs:enable WordPress.Security.NonceVerification.Missing $this->connection->save( array( // An array, even an empty one: that is what "none, deliberately" // looks like, as against the null of never having been asked. 'post_types' => $types, 'search_enabled' => $search, 'sync_private' => $private, 'ask_enabled' => $ask, 'ask_on_search' => $ask_search, 'stats_enabled' => $enabled, 'stats_retention_days' => min( 365, max( 1, $retention ) ), ) ); $this->redirect_with( 'success', __( 'Settings saved.', 'sharelyai' ), 'settings' ); } /** * Starts a sync. */ public function handle_sync(): void { $this->guard( 'sharelyai_sync' ); $started = ( new Sync( $this->connection ) )->start_full(); if ( ! $started['ok'] ) { $this->redirect_with( 'error', $started['message'] ); } if ( 0 === $started['total'] ) { $this->redirect_with( 'error', __( 'Nothing to send: no published entry is in scope. Check the post types under Settings.', 'sharelyai' ) ); } $this->redirect_with( 'success', sprintf( /* translators: %s: number of entries. */ _n( 'Sending %s entry to Sharely. This page shows the progress.', 'Sending %s entries to Sharely. This page shows the progress.', $started['total'], 'sharelyai' ), number_format_i18n( $started['total'] ) ) ); } /** * Clears the credentials. Nothing in Sharely is touched. * * The local record of what was sent goes too: without credentials those * knowledge ids cannot be used for anything, and keeping them would make a * later reconnection believe the workspace already holds this site. */ public function handle_disconnect(): void { $this->guard( 'sharelyai_disconnect' ); // First, while the credentials are still here to authenticate with: // tell Sharely, so the site stops showing as connected there too. $announced = ( new Connect( $this->connection ) )->announce_disconnect(); ( new Queue() )->cancel_all(); ( new Sync( $this->connection ) )->forget_all(); ( new Stats( $this->connection ) )->clear(); // Before the connection goes, because both of these are keyed by the // workspace it names. A bearer and a set of role ids belong to one // workspace and are meaningless — or wrong — in the next. ( new Credentials( $this->connection ) )->forget(); ( new Roles( $this->connection ) )->forget(); $this->connection->forget(); $this->redirect_with( 'success', $announced ? __( 'Disconnected. Sharely has been told, and your workspace and its content are untouched.', 'sharelyai' ) : __( 'Disconnected here. Sharely could not be reached, so the site may still be listed there — remove it from Connected sites in Sharely. Your workspace and its content are untouched.', 'sharelyai' ) ); } /** * Forgets the recorded searches, keeping the connection. */ public function handle_stats_clear(): void { $this->guard( 'sharelyai_stats_clear' ); ( new Stats( $this->connection ) )->clear(); $this->redirect_with( 'success', __( 'Search statistics cleared.', 'sharelyai' ) ); } /** * Only an administrator, only from this screen. * * @param string $action Nonce action. */ private function guard( string $action ): void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You are not allowed to do that.', 'sharelyai' ), 403 ); } check_admin_referer( $action ); } /** * Back to the screen with a message. * * @param string $type success|error. * @param string $message What to say. * @param string $tab Which tab to return to. */ private function redirect_with( string $type, string $message, string $tab = '' ): void { set_transient( 'sharelyai_notice', array( 'type' => $type, 'message' => $message, ), 60 ); wp_safe_redirect( self::page_url( $tab ) ); exit; } }