<?php
/**
* ElasticPress utility functions
*
* @since 3.0
* @package elasticpress
*/
namespace ElasticPress\Utils;
use ElasticPress\IndexHelper;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Retrieve the EPIO subscription credentials.
*
* @since 2.5
* @return array
*/
function get_epio_credentials() {
if ( defined( 'EP_CREDENTIALS' ) && EP_CREDENTIALS ) {
$raw_credentials = explode( ':', EP_CREDENTIALS );
if ( is_array( $raw_credentials ) && 2 === count( $raw_credentials ) ) {
$credentials = array(
'username' => $raw_credentials[0],
'token' => $raw_credentials[1],
);
}
$credentials = sanitize_credentials( $credentials );
} elseif ( defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK && is_epio() ) {
$credentials = sanitize_credentials( get_site_option( 'ep_credentials', false ) );
} elseif ( is_epio() ) {
$credentials = sanitize_credentials( get_option( 'ep_credentials', false ) );
} else {
$credentials = [
'username' => '',
'token' => '',
];
}
if ( ! is_array( $credentials ) ) {
return [
'username' => '',
'token' => '',
];
}
return $credentials;
}
/**
* Get WP capability needed for a user to interact with ElasticPress in the admin
*
* @since 4.5.0, 5.1.0 added $context
* @param string $context Context for the capability. Defaults to empty string.
* @return string
*/
function get_capability( string $context = '' ): string {
/**
* Filter the WP capability needed to interact with ElasticPress in the admin
*
* Example:
* ```
* add_filter(
* 'ep_capability',
* function ( $cacapability, $context ) {
* return ( 'synonyms' === $context ) ?
* 'manage_elasticpress_synonyms' :
* $cacapability;
* },
* 10,
* 2
* );
* ```
*
* @since 4.5.0, 5.1.0 added $context
* @hook ep_capability
* @param {string} $capability Capability name. Defaults to `'manage_elasticpress'`
* @param {string} $context Additional context
* @return {string} New capability value
*/
return apply_filters( 'ep_capability', 'manage_elasticpress', $context );
}
/**
* Get WP capability needed for a user to interact with ElasticPress in the network admin
*
* @since 4.5.0, 5.1.0 added $context
* @param string $context Context for the capability. Defaults to empty string.
* @return string
*/
function get_network_capability( string $context = '' ): string {
/**
* Filter the WP capability needed to interact with ElasticPress in the network admin
*
* @since 4.5.0, 5.1.0 added $context
* @hook ep_network_capability
* @param {string} $capability Capability name. Defaults to `'manage_network_elasticpress'`
* @param {string} $context Additional context
* @return {string} New capability value
*/
return apply_filters( 'ep_network_capability', 'manage_network_elasticpress', $context );
}
/**
* Get mapped capabilities for post types
*
* @since 4.5.0, 5.1.0 added $context
* @param string $context Context for the capability. Defaults to empty string.
* @return array
*/
function get_post_map_capabilities( string $context = '' ): array {
$capability = get_capability( $context );
return [
'edit_post' => $capability,
'edit_posts' => $capability,
'edit_others_posts' => $capability,
'publish_posts' => $capability,
'read_post' => $capability,
'read_private_posts' => $capability,
'delete_post' => $capability,
];
}
/**
* Get shield credentials
*
* @since 3.0
* @return string|bool
*/
function get_shield_credentials() {
if ( defined( 'ES_SHIELD' ) && ES_SHIELD ) {
return ES_SHIELD;
} elseif ( is_epio() ) {
$credentials = get_epio_credentials();
return $credentials['username'] . ':' . $credentials['token'];
}
return false;
}
/**
* Retrieve the appropriate index prefix. Will default to EP_INDEX_PREFIX constant if it exists
* AKA Subscription ID.
*
* @since 2.5
* @return string|bool
*/
function get_index_prefix() {
if ( defined( 'EP_INDEX_PREFIX' ) && \EP_INDEX_PREFIX ) {
$prefix = \EP_INDEX_PREFIX;
} elseif ( is_epio() ) {
$credentials = get_epio_credentials();
$prefix = $credentials['username'];
if (
( ! defined( 'EP_IS_NETWORK' ) || ! EP_IS_NETWORK ) &&
( '-' !== substr( $prefix, - 1 ) )
) {
$prefix .= '-';
}
} else {
$prefix = '';
}
/**
* Filter index prefix. Defaults to nothing
*
* @since 2.5
* @hook ep_index_prefix
* @param {string} $prefix Current prefix
* @return {string} New prefix
*/
return apply_filters( 'ep_index_prefix', $prefix );
}
/**
* Check if the host is ElasticPress.io.
*
* @since 2.6
* @return bool
*/
function is_epio() {
return filter_var( preg_match( '#elasticpress\.io#i', get_host() ), FILTER_VALIDATE_BOOLEAN );
}
/**
* Determine if we should index a blog/site
*
* @param int $blog_id Blog/site id.
* @since 3.2
* @return boolean
*/
function is_site_indexable( $blog_id = null ) {
if ( ! is_multisite() ) {
return true;
}
$site = get_site( $blog_id );
$is_indexable = get_site_meta( $site['blog_id'], 'ep_indexable', true );
return 'no' !== $is_indexable && ! $site['deleted'] && ! $site['archived'] && ! $site['spam'];
}
/**
* Sanitize EPIO credentials prior to storing them.
*
* @param array $credentials Array containing username and token.
* @since 2.6
* @return array
*/
function sanitize_credentials( $credentials ) {
if ( ! is_array( $credentials ) ) {
return [
'username' => '',
'token' => '',
];
}
return [
'username' => ( isset( $credentials['username'] ) ) ? sanitize_text_field( $credentials['username'] ) : '',
'token' => ( isset( $credentials['token'] ) ) ? sanitize_text_field( $credentials['token'] ) : '',
];
}
/**
* Determine if ElasticPress is in the middle of an index
*
* @since 3.0
* @return boolean
*/
function is_indexing() {
/**
* Filter whether an index is occurring in dashboard or CLI
*
* @since 3.0
* @hook ep_is_indexing
* @param {bool} $indexing True for indexing
* @return {bool} New indexing value
*/
return apply_filters( 'ep_is_indexing', ! empty( IndexHelper::factory()->get_index_meta() ) );
}
/**
* Check if wpcli indexing is occurring
*
* @since 3.0
* @return boolean
*/
function is_indexing_wpcli() {
$index_meta = IndexHelper::factory()->get_index_meta();
/**
* Filter whether a CLI sync is occurring
*
* @since 3.0
* @hook ep_is_indexing_wpcli
* @param {bool} $indexing True for indexing
* @return {bool} New indexing value
*/
return apply_filters( 'ep_is_indexing_wpcli', ( ! empty( $index_meta ) && 'cli' === $index_meta['method'] ) );
}
/**
* Retrieve the appropriate host. Will default to EP_HOST constant if it exists
*
* @since 2.1
* @return string|bool
*/
function get_host() {
if ( defined( 'EP_HOST' ) && EP_HOST ) {
$host = EP_HOST;
} else {
$host = get_option( 'ep_host', false );
}
/**
* Filter ElasticPress host to use
*
* @since 2.1
* @hook ep_host
* @param {string} $host Current EP host
* @return {string} Host to use
*/
return apply_filters( 'ep_host', $host );
}
/**
* Get a site. Wraps get_site for formatting purposes
*
* @param int $site_id Site/blog id
* @since 3.2
* @return array
*/
function get_site( $site_id ) {
$site = \get_site( $site_id );
return [
'blog_id' => $site->blog_id,
'domain' => $site->domain,
'path' => $site->path,
'site_id' => $site->site_id,
'deleted' => $site->deleted,
'archived' => $site->archived,
'spam' => $site->spam,
];
}
/**
* Wrapper function for get_sites - allows us to have one central place for the `ep_indexable_sites` filter
*
* @param int $limit The maximum amount of sites retrieved, Use 0 to return all sites.
* @param bool $only_indexable Whether should be returned only indexable sites or not.
* @since 3.0, 4.7.0 added `$only_indexable`
* @return array
*/
function get_sites( $limit = 0, $only_indexable = false ) {
if ( ! is_multisite() ) {
return [];
}
$args = [
'limit' => $limit,
'number' => $limit,
];
if ( $only_indexable ) {
$args = array_merge(
$args,
[
'spam' => 0,
'deleted' => 0,
'archived' => 0,
'meta_query' => [
'relation' => 'OR',
[
'key' => 'ep_indexable',
'value' => 'no',
'compare' => '!=',
],
[
'key' => 'ep_indexable',
'compare' => 'NOT EXISTS',
],
],
]
);
}
/**
* Filter arguments to use to query for sites on network
*
* @since 2.1
* @hook ep_indexable_sites_args
* @param {array} $args Array of args to query sites with. See WP_Site_Query
* @return {array} New arguments
*/
$args = apply_filters( 'ep_indexable_sites_args', $args );
$site_objects = \get_sites( $args );
$sites = [];
foreach ( $site_objects as $site ) {
$sites[] = get_site( $site->blog_id );
}
/**
* Filter indexable sites
*
* @since 3.0
* @hook ep_indexable_sites
* @param {array} $sites Current sites. Instances of WP_Site
* @return {array} New array of sites
*/
return apply_filters( 'ep_indexable_sites', $sites );
}
/**
* Whether plugin is network activated
*
* Determines whether plugin is network activated or just on the local site.
*
* @since 3.0
* @param string $plugin the plugin base name.
* @return bool True if network activated or false.
*/
function is_network_activated( $plugin ) {
$plugins = get_site_option( 'active_sitewide_plugins' );
if ( is_multisite() && isset( $plugins[ $plugin ] ) ) {
return true;
}
return false;
}
/**
* Performant utility function for building a term tree.
*
* Tree will look like this:
* [
* WP_Term(
* name
* slug
* children ->[
* WP_Term()
* ]
* ),
* WP_Term()
* ]
*
* @param array $all_terms Pass get_terms() as this argument where terms are objects NOT arrays.
* @param string|bool $orderby Can be count|name|false. This is how each tree branch will be ordered.
* @param string $order Can be asc|desc. This is the direction ordering will occur.
* @param bool $flat If false, a tree will be returned e.g. an array of top level terms
* which children linked within each node. If true, the tree will be
* "flattened".
* @since 2.5
* @return array
*/
function get_term_tree( $all_terms, $orderby = 'count', $order = 'desc', $flat = false ) {
$terms_map = [];
$terms_tree = [];
$iteration_id = 0;
while ( true ) {
if ( empty( $all_terms ) ) {
break;
}
foreach ( $all_terms as $key => $term ) {
++$iteration_id;
if ( ! isset( $term->children ) ) {
$term->children = [];
}
if ( ! isset( $terms_map[ $term->term_id ] ) ) {
$terms_map[ $term->term_id ] = $term;
}
$parent_term = get_term( $term->parent, $term->taxonomy );
if ( empty( $term->parent ) || is_wp_error( $parent_term ) || ! $parent_term ) {
$term->level = 0;
if ( empty( $orderby ) ) {
$terms_tree[] = $term;
} elseif ( 'count' === $orderby ) {
/**
* We add this weird number to get past terms with the same count
*/
$terms_tree[ ( ( $term->count * 10000000 ) + $iteration_id ) ] = $term;
} elseif ( 'name' === $orderby ) {
$terms_tree[ strtolower( $term->name ) ] = $term;
}
unset( $all_terms[ $key ] );
} elseif ( ! empty( $terms_map[ $term->parent ] ) && isset( $terms_map[ $term->parent ]->level ) ) {
if ( empty( $orderby ) ) {
$terms_map[ $term->parent ]->children[] = $term;
} elseif ( 'count' === $orderby ) {
$terms_map[ $term->parent ]->children[ ( ( $term->count * 10000000 ) + $iteration_id ) ] = $term;
} elseif ( 'name' === $orderby ) {
$terms_map[ $term->parent ]->children[ $term->name ] = $term;
}
$parent_level = ( $terms_map[ $term->parent ]->level ) ? $terms_map[ $term->parent ]->level : 0;
$term->level = $parent_level + 1;
$term->parent_term = $terms_map[ $term->parent ];
unset( $all_terms[ $key ] );
}
}
}
if ( ! empty( $orderby ) ) {
if ( 'asc' === $order ) {
ksort( $terms_tree );
} else {
krsort( $terms_tree );
}
foreach ( $terms_map as $term ) {
if ( 'asc' === $order ) {
ksort( $term->children );
} else {
krsort( $term->children );
}
$term->children = array_values( $term->children );
}
$terms_tree = array_values( $terms_tree );
}
if ( $flat ) {
$flat_tree = [];
foreach ( $terms_tree as $term ) {
$flat_tree[] = $term;
$to_process = $term->children;
while ( ! empty( $to_process ) ) {
$term = array_shift( $to_process );
$flat_tree[] = $term;
if ( ! empty( $term->children ) ) {
$to_process = array_merge( $term->children, $to_process );
}
}
}
return $flat_tree;
}
return $terms_tree;
}
/**
* Returns the default language for ES mapping.
*
* @return string Default EP language.
*/
function get_language() {
$ep_language = get_option( 'ep_language' );
$ep_language = ! empty( $ep_language ) ? $ep_language : 'site-default';
/**
* Filter the default language to use at index time
*
* @since 3.1
* @param {string} The current language.
* @hook ep_default_language
* @return {string} New language
*/
return apply_filters( 'ep_default_language', $ep_language );
}
/**
* Returns the status of an ongoing index operation.
*
* Returns the status of an ongoing index operation in array with the following fields:
* indexing | boolean | True if index operation is ongoing or false
* method | string | 'cli', 'web' or 'none'
* items_indexed | integer | Total number of items indexed
* total_items | integer | Total number of items indexed or -1 if not yet determined
* slug | string | The slug of the indexable
*
* @since 3.5.2
* @return array|boolean
*/
function get_indexing_status() {
$index_status = false;
$index_meta = IndexHelper::factory()->get_index_meta();
if ( ! empty( $index_meta ) ) {
$index_status = $index_meta;
$index_status['indexing'] = true;
if ( ! empty( $index_meta['current_sync_item'] ) ) {
$index_status['items_indexed'] = $index_meta['current_sync_item']['synced'];
$index_status['url'] = $index_meta['current_sync_item']['url'] ?? ''; // Global indexables won't have a url.
$index_status['total_items'] = $index_meta['current_sync_item']['total'];
$index_status['slug'] = $index_meta['current_sync_item']['indexable'];
}
// Change method name for retrocompatibility.
// `dashboard` is used mainly because hooks names depend on that.
if ( ! empty( $index_status['method'] ) && 'dashboard' === $index_status['method'] ) {
$index_status['method'] = 'web';
}
if ( ! empty( $index_status['method'] ) && 'web' === $index_status['method'] ) {
$should_interrupt_sync = filter_var(
get_transient( 'ep_sync_interrupted' ),
FILTER_VALIDATE_BOOLEAN
);
$index_status['should_interrupt_sync'] = $should_interrupt_sync;
}
}
return $index_status;
}
/**
* Use the correct update option function depending on the context (multisite or not)
*
* @since 3.6.0
* @param string $option Name of the option to update.
* @param mixed $value Option value.
* @param mixed $autoload Whether to load the option when WordPress starts up.
* @return bool
*/
function update_option( $option, $value, $autoload = null ) {
if ( defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK ) {
return \update_site_option( $option, $value );
}
return \update_option( $option, $value, $autoload );
}
/**
* Use the correct get option function depending on the context (multisite or not)
*
* @since 3.6.0
* @param string $option Name of the option to get.
* @param mixed $default_value Default value.
* @return mixed
*/
function get_option( $option, $default_value = false ) {
if ( defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK ) {
return \get_site_option( $option, $default_value );
}
return \get_option( $option, $default_value );
}
/**
* Use the correct delete option function depending on the context (multisite or not)
*
* @since 3.6.0
* @param string $option Name of the option to delete.
* @return bool
*/
function delete_option( $option ) {
if ( defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK ) {
return \delete_site_option( $option );
}
return \delete_option( $option );
}
/**
* Check if queries for the current request are going to be integrated with
* ElasticPress.
*
* Public requests and REST API requests are integrated by default, but admin
* requests will only be integrated in if the `ep_admin_wp_query_integration`
* filter returns `true`, and and admin-ajax.php requests will only be
* integrated if the `ep_ajax_wp_query_integration` filter returns `true`.
*
* If specific types of requests are passed, true will only be returned if the
* current request also matches one of the passed types.
*
* This function is used by features to determine whether they should hook into
* the current request.
*
* @param string $context Slug of the feature that is performing the check.
* Passed to the `ep_is_integrated_request` filter.
* @param string[] $types Which types of request to check. Any of 'admin',
* 'ajax', 'public', and 'rest'. Defaults to all
* types.
* @return bool Whether the current request supports ElasticPress integration
* and is of a given type.
*
* @since 3.6.0
*/
function is_integrated_request( $context, $types = [] ) {
if ( empty( $types ) ) {
$types = [ 'admin', 'ajax', 'public', 'rest' ];
}
$is_admin_request = is_admin();
$is_ajax_request = wp_doing_ajax();
$is_rest_request = defined( 'REST_REQUEST' ) && REST_REQUEST;
$is_integrated_admin_request = false;
$is_integrated_ajax_request = false;
$is_integrated_public_request = false;
$is_integrated_rest_request = false;
if ( $is_admin_request && ! $is_ajax_request && in_array( 'admin', $types, true ) ) {
/**
* Filter whether to integrate with admin queries.
*
* @hook ep_admin_wp_query_integration
* @param bool $integrate True to integrate.
* @return bool New value.
*/
$is_integrated_admin_request = apply_filters( 'ep_admin_wp_query_integration', false );
}
if ( $is_ajax_request && in_array( 'ajax', $types, true ) ) {
/**
* Filter to integrate with admin ajax queries.
*
* @hook ep_ajax_wp_query_integration
* @param bool $integrate True to integrate.
* @return bool New value.
*/
$is_integrated_ajax_request = apply_filters( 'ep_ajax_wp_query_integration', false );
}
if ( $is_rest_request && in_array( 'rest', $types, true ) ) {
$is_integrated_rest_request = true;
}
if ( ! $is_admin_request && ! $is_ajax_request && ! $is_rest_request && in_array( 'public', $types, true ) ) {
$is_integrated_public_request = true;
}
/**
* Is the current request any of the supported requests.
*/
$is_integrated = (
$is_integrated_admin_request ||
$is_integrated_ajax_request ||
$is_integrated_public_request ||
$is_integrated_rest_request
);
/**
* Filter whether the queries for the current request should be integrated.
*
* @hook ep_is_integrated_request
* @param bool $is_integrated Whether queries for the request will be
* integrated.
* @param string $context Context for the original check. Usually the
* slug of the feature doing the check.
* @param array $types Which requests types are being checked.
* @return bool Whether queries for the request will be integrated.
*
* @since 3.6.2
*/
return apply_filters( 'ep_is_integrated_request', $is_integrated, $context, $types );
}
/**
* Get asset info from extracted asset files
*
* @param string $slug Asset slug as defined in build/webpack configuration
* @param string $attribute Optional attribute to get. Can be version or dependencies
* @return string|array
*/
function get_asset_info( $slug, $attribute = null ) {
if ( file_exists( EP_PATH . 'dist/js/' . $slug . '.asset.php' ) ) {
$asset = require EP_PATH . 'dist/js/' . $slug . '.asset.php';
} elseif ( file_exists( EP_PATH . 'dist/css/' . $slug . '.asset.php' ) ) {
$asset = require EP_PATH . 'dist/css/' . $slug . '.asset.php';
} else {
return null;
}
if ( ! empty( $attribute ) && isset( $asset[ $attribute ] ) ) {
return $asset[ $attribute ];
}
return $asset;
}
/**
* Return the Sync Page URL.
*
* @since 4.4.0
* @param boolean|string $do_sync Whether the link should or should not start a resync. Pass a string to store the reason of the resync.
* @return string
*/
function get_sync_url( $do_sync = false ): string {
$page = 'admin.php?page=elasticpress-sync';
if ( $do_sync ) {
$page .= '&do_sync';
if ( is_string( $do_sync ) ) {
$page .= '=' . rawurlencode( $do_sync );
}
$page .= '&ep_sync_nonce=' . wp_create_nonce( 'ep_sync_nonce' );
}
return ( defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK ) ?
network_admin_url( $page ) :
admin_url( $page );
}
/**
* Check if the `do_sync` parameter is set and the nonce is valid.
*
* @since 5.1.2
* @return boolean
*/
function isset_do_sync_parameter(): bool {
return isset( $_GET['do_sync'] ) && ! empty( $_GET['ep_sync_nonce'] ) && wp_verify_nonce( sanitize_key( $_GET['ep_sync_nonce'] ), 'ep_sync_nonce' );
}
/**
* Generate a common prefix to be used while generating a request ID.
*
* Uses the return of `get_index_prefix()` by default.
*
* @since 4.5.0
* @return string
*/
function get_request_id_base() {
/**
* Filter the base of requests IDs. Uses the return of `get_index_prefix()` by default.
*
* @hook ep_request_id_base
* @since 4.5.0
* @param {string} $request_id_base Request ID base
* @return {string} New Request ID base
*/
return apply_filters( 'ep_request_id_base', str_replace( '-', '', get_index_prefix() ) );
}
/**
* Generate a Request ID.
*
* The function concatenates the indices prefix to a random UUID4.
*
* @since 4.5.0
* @return string
*/
function generate_request_id(): string {
$uuid = str_replace( '-', '', wp_generate_uuid4() );
/**
* Filter the ID generated to identify a request.
*
* @hook ep_request_id
* @since 4.5.0
* @param {string} $request_id Request ID. By default formed by the indices prefix and a random UUID4.
* @return {string} New Request ID
*/
return apply_filters( 'ep_request_id', get_request_id_base() . $uuid );
}
/**
* Given an Elasticsearch response, try to find an error message.
*
* @since 4.6.0
* @param mixed $response The Elasticsearch response
* @return string
*/
function get_elasticsearch_error_reason( $response ): string {
if ( is_string( $response ) ) {
return $response;
}
if ( ! is_array( $response ) ) {
return var_export( $response, true ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions
}
if ( ! empty( $response['reason'] ) ) {
return (string) $response['reason'];
}
if ( ! empty( $response['result']['error'] ) && ! empty( $response['result']['error']['root_cause'][0]['reason'] ) ) {
return (string) $response['result']['error']['root_cause'][0]['reason'];
}
if ( ! empty( $response['result']['errors'] ) && ! empty( $response['result']['items'] ) ) {
$error = '';
foreach ( $response['result']['items'] as $item ) {
if ( ! empty( $item['index']['error']['reason'] ) ) {
$error = $item['index']['error']['reason'];
break;
}
}
return $error;
}
return '';
}
/**
* Use the correct set_transient option function depending on the context (multisite or not)
*
* @since 4.7.0
* @param string $transient Transient name. Expected to not be SQL-escaped.
* Must be 172 characters or fewer in length.
* @param mixed $value Transient value. Must be serializable if non-scalar.
* Expected to not be SQL-escaped.
* @param int $expiration Optional. Time until expiration in seconds. Default 0 (no expiration).
* @return bool True if the value was set, false otherwise.
*/
function set_transient( $transient, $value, $expiration = 0 ) {
if ( defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK ) {
return \set_site_transient( $transient, $value, $expiration );
}
return \set_transient( $transient, $value, $expiration );
}
/**
* Use the correct get_transient function depending on the context (multisite or not)
*
* @since 4.7.0
* @param string $transient Transient name. Expected to not be SQL-escaped.
* @return mixed Value of transient.
*/
function get_transient( $transient ) {
if ( defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK ) {
return \get_site_transient( $transient );
}
return \get_transient( $transient );
}
/**
* Use the correct delete_transient function depending on the context (multisite or not)
*
* @since 4.7.0
* @param string $transient Transient name. Expected to not be SQL-escaped.
* @return bool True if the transient was deleted, false otherwise.
*/
function delete_transient( $transient ) {
if ( defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK ) {
return \delete_site_transient( $transient );
}
return \delete_transient( $transient );
}
/**
* Whether we are in the top level admin context or not.
*
* In a single site, the top level admin context would be `is_admin()`,
* in a multisite, it would be `is_network_admin()`.
*
* @since 5.0.0
* @return boolean
*/
function is_top_level_admin_context() {
$is_network = defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK;
return $is_network ? is_network_admin() : is_admin();
}