settings = $settings; } /** * Every known screen for one plugin. * * @param string $plugin Plugin key, e.g. "eventin". * @return array List of {title, url, type}. Empty when it cannot be had. */ public function all( string $plugin ): array { $plugin = strtolower( preg_replace( '/[^a-z0-9_-]/i', '', $plugin ) ?? '' ); if ( '' === $plugin ) { return []; } $key = 'aisentic_admin_pages_' . $plugin; $kept = get_transient( $key ); if ( is_array( $kept ) ) { return $kept; } $pages = $this->fetch( $plugin ); // Cache times: failed fetch 5 minutes (as an empty list), // empty answer 1 hour, real list 1 day. if ( null === $pages ) { set_transient( $key, [], self::RETRY_AFTER ); return []; } set_transient( $key, $pages, empty( $pages ) ? HOUR_IN_SECONDS : self::KEEP_FOR ); return $pages; } /** * Only the screens a person can actually be taken to. * * One entry per address (pages and tabs only). Up to five setting names * on that page go in 'holds'. * Why 'holds': a page title like "Checkout Settings" does not say the * purchase timer is there, so the model picked "General Settings". * * @param string $plugin Plugin key. * @return array List of {title, url, holds: string[]}. */ public function destinations( string $plugin ): array { $seen = []; $holds = []; $all = $this->all( $plugin ); foreach ( $all as $page ) { $type = strtolower( (string) ( $page['type'] ?? 'page' ) ); $url = $this->shorten( (string) $page['url'] ); $title = trim( (string) ( $page['title'] ?? '' ) ); if ( '' === $url || '' === $title ) { continue; } if ( 'setting' === $type ) { if ( count( $holds[ $url ] ?? [] ) < self::HOLDS_PER_PAGE ) { $holds[ $url ][] = $title; } continue; } if ( ( 'page' !== $type && 'tab' !== $type ) || isset( $seen[ $url ] ) ) { continue; } $seen[ $url ] = [ 'title' => (string) $page['title'], 'url' => (string) $page['url'], ]; } foreach ( $seen as $url => &$page ) { $page['holds'] = $holds[ $url ] ?? []; } unset( $page ); return array_values( $seen ); } /** * Is this address one of the plugin's real screens? * * @param string $url Address from the model, absolute or relative. * @param string $plugin Plugin key. * @return bool False also when the list is unknown. Callers treat * "not known" as "not real" on purpose. */ public function knows( string $url, string $plugin ): bool { foreach ( $this->all( $plugin ) as $page ) { $known = (string) ( $page['url'] ?? '' ); if ( $this->same( $known, $url ) || $this->one_record_under( $known, $url ) ) { return true; } } return false; } /** * The names of the settings on one screen. * * These are the names the model sees next to that page in the prompt. * A sentence that uses one of them was not made up. * * @param string $url Address from the model, absolute or relative. * @param string $plugin Plugin key. * @return string[] Empty when the screen is unknown or lists no settings. */ public function holds_for( string $url, string $plugin ): array { foreach ( $this->destinations( $plugin ) as $page ) { if ( $this->same( (string) $page['url'], $url ) ) { return (array) $page['holds']; } } return []; } /** * Is this a link to one record sitting under a screen from the list? * * Example: "#/events/edit/520831/basic" is under "#/events". * The list has only pages, never one record, so an exact match rejects it. * * A deeper address passes only when both are true: * 1. It starts with a known address followed by "/". * 2. One of the extra parts is a number (a record id). * * Why the number: "#/events/archive" has no number, so it is a page name * that is not in the list, and is rejected. * * @param string $known One address from the list. * @param string $given One address from the model. * @return bool */ private function one_record_under( string $known, string $given ): bool { $known = $this->shorten( $known ); $given = $this->shorten( $given ); if ( '' === $known || ! str_starts_with( $given, $known . '/' ) ) { return false; } foreach ( explode( '/', substr( $given, strlen( $known ) + 1 ) ) as $part ) { if ( '' !== $part && ctype_digit( $part ) ) { return true; } } return false; } /** * Are these two addresses the same screen? * * The model may write "/wp-admin/admin.php?page=eventin#/events" or a full * URL. The list has "admin.php?page=eventin#/events". Both are shortened first. * * @param string $known One address from the list. * @param string $given One address from the model. * @return bool */ private function same( string $known, string $given ): bool { return '' !== $known && $this->shorten( $known ) === $this->shorten( $given ); } /** * Cut an address down to the part that identifies the screen. * * @param string $url Address in any of its shapes. * @return string */ private function shorten( string $url ): string { $url = trim( html_entity_decode( $url, ENT_QUOTES, 'UTF-8' ) ); $url = (string) preg_replace( '#^https?://[^/]+#i', '', $url ); $url = (string) preg_replace( '#^/?wp-admin/#i', '', $url ); return strtolower( ltrim( $url, '/' ) ); } /** * Ask the provider for the list. * * Uses the same provider client as the voice routes, so the endpoint, key * and HTTP error handling stay in one place. * * @param string $plugin Plugin key. * @return array|null The pages, or null when the provider could not answer. */ private function fetch( string $plugin ): ?array { $client = new class( $this->settings ) extends Provider_Audio_Client { /** * GET the page list, as a body string or an error. * * @param string $plugin Plugin key. * @return string|\WP_Error */ public function pages( string $plugin ) { $response = wp_remote_get( $this->endpoint() . '/api/v1/sitemap/pages?plugin=' . rawurlencode( $plugin ), [ 'timeout' => 15, 'headers' => [ 'Authorization' => 'Bearer ' . $this->api_key(), 'Accept' => 'application/json', ], ] ); return $this->unwrap( $response, __( 'admin pages', 'aisentic' ) ); } }; if ( ! $client->is_configured() ) { return []; } $body = $client->pages( $plugin ); if ( is_wp_error( $body ) ) { Log::warning( 'Admin_Pages: could not fetch the page list — ' . $body->get_error_message() ); return null; } $data = json_decode( (string) $body, true ); if ( ! is_array( $data ) || empty( $data['pages'] ) || ! is_array( $data['pages'] ) ) { return []; } $pages = []; foreach ( $data['pages'] as $page ) { $url = trim( (string) ( $page['url'] ?? '' ) ); if ( '' === $url ) { continue; } $pages[] = [ 'title' => trim( (string) ( $page['title'] ?? '' ) ), 'url' => $url, // "page" and "tab" are places to go. "setting" and "button" // are items on a page (109 of 165 Eventin entries), so // destinations() does not list them as pages. 'type' => trim( (string) ( $page['type'] ?? 'page' ) ), ]; } return $pages; } }