array(), 'revision' => 1, 'language_mode' => 'fixed', 'delivery' => 'dynamic', 'browse_mode' => 'summary', ); } unset( $profile ); update_option( 'akla_profiles', $profiles, false ); update_option( 'akla_options_version', self::LOCAL_VERSION, false ); } /** Migrate configured city identities once, only from an authenticated administrator request. */ public static function migrate_city_identity() { if ( ! current_user_can( 'manage_options' ) || self::VERSION <= (int) get_option( 'akla_options_version', 0 ) ) { return false; } $profiles = (array) get_option( 'akla_profiles', array() ); $ids = array(); foreach ( $profiles as $profile ) { if ( ! is_array( $profile ) ) { return new WP_Error( 'akla_city_identity_invalid', 'Invalid city configuration.' ); } $query = (array) ( $profile['query'] ?? array() ); $ids = array_merge( $ids, array( $query['cityId'] ?? 0 ), (array) ( $query['cityIds'] ?? array() ) ); } $ids = array_values( array_unique( array_filter( array_map( 'absint', $ids ) ) ) ); if ( $ids && ( ! AKLA_Client::connected() || get_transient( 'akla_city_migration_retry' ) ) ) { return false; } if ( $ids ) { set_transient( 'akla_city_migration_retry', 1, 60 ); } $mapping = array(); foreach ( array_chunk( $ids, 20 ) as $chunk ) { $response = AKLA_Client::request( '/api/public/distribution/v1/cities/resolve?cityIds=' . implode( ',', $chunk ) ); if ( is_wp_error( $response ) || ! is_array( $response ) ) { return false; } $mapping += $response; } $revised = self::remap_city_profiles( $profiles, $mapping ); if ( is_wp_error( $revised ) ) { return false; } if ( false === get_option( 'akla_city_identity_backup', false ) ) { if ( ! update_option( 'akla_city_identity_backup', $profiles, false ) ) { return false; } } update_option( 'akla_profiles', $revised, false ); if ( get_option( 'akla_profiles', array() ) !== $revised ) { return false; } $generation = wp_generate_uuid4(); if ( ! update_option( 'akla_cache_generation', $generation, false ) ) { return false; } // The completion marker is last: a failed API or option write remains retryable. update_option( 'akla_options_version', self::VERSION, false ); $complete = self::VERSION === (int) get_option( 'akla_options_version', 0 ); if ( $complete ) { delete_transient( 'akla_city_migration_retry' ); } return $complete; } /** * Transform stored selections using reviewed identity successors. * * @param array $profiles Stored configuration profiles. * @param array $mapping Prior IDs mapped to current official city records. * @return array|WP_Error Transformed profiles or a validation failure. */ public static function remap_city_profiles( array $profiles, array $mapping ) { foreach ( $profiles as &$profile ) { if ( ! is_array( $profile ) ) { return new WP_Error( 'akla_city_identity_invalid', 'Invalid city configuration.' ); } $original = $profile; $query = (array) ( $profile['query'] ?? array() ); $old = array_values( array_unique( array_filter( array_map( 'absint', array_merge( array( $query['cityId'] ?? 0 ), (array) ( $query['cityIds'] ?? array() ) ) ) ) ) ); if ( ! $old ) { continue; } $targets = array(); $cities = array(); foreach ( $old as $id ) { if ( empty( $mapping[ $id ] ) || ! is_array( $mapping[ $id ] ) ) { return new WP_Error( 'akla_city_identity_unresolved', __( 'Choose a current city before saving this configuration.', 'actikidz-local-activities' ) ); } foreach ( $mapping[ $id ] as $target ) { if ( ! is_array( $target ) || ! is_int( $target['cityId'] ?? null ) || $target['cityId'] < 1 || ! is_string( $target['name'] ?? null ) || ! is_string( $target['province'] ?? null ) || ! is_string( $target['country'] ?? null ) ) { return new WP_Error( 'akla_city_identity_invalid', 'Invalid city identity response.' ); } $targets[] = $target['cityId']; $cities[ $target['cityId'] ] = $target; } } $targets = array_values( array_unique( $targets ) ); if ( count( $targets ) > AKLA_Client::MAX_CITY_IDS ) { return new WP_Error( 'akla_city_identity_limit', 'Too many city identities.' ); } $query['cityId'] = $targets[0]; $query['cityIds'] = $targets; $profile['query'] = $query; $profile['city_label'] = $cities[ $targets[0] ]['name']; $profile['province'] = $cities[ $targets[0] ]['province']; $profile['extra_cities'] = array_map( static function ( $id ) use ( $cities ) { return array( 'cityId' => $id, 'label' => $cities[ $id ]['name'] . ', ' . $cities[ $id ]['province'] . ', ' . $cities[ $id ]['country'], ); }, array_slice( $targets, 1 ) ); if ( $profile !== $original ) { $profile['revision'] = (int) ( $profile['revision'] ?? 0 ) + 1; } } unset( $profile ); return $profiles; } /** * Export only portable settings, excluding runtime state and credentials. * * @param array $profile Stored profile. * @return mixed Result documented by the caller. */ public static function export_profile( array $profile ): array { $export = array_intersect_key( AKLA_Admin::normalize_profile( $profile ), array_flip( array( 'name', 'slug', 'aliases', 'city_label', 'province', 'extra_cities', 'links', 'query', 'layout', 'hide_empty', 'brand', 'language_mode', 'delivery', 'browse_mode', 'fallback_url', 'fallback_text' ) ) ); $export['query'] = array_intersect_key( (array) $export['query'], array_flip( array( 'cityId', 'cityIds', 'areaId', 'scopeKey', 'windowDays', 'category', 'categories', 'weekdays', 'ageRange', 'ageMin', 'ageMax', 'language', 'limit', 'linksEnabled' ) ) ); $export['links'] = array_intersect_key( (array) $export['links'], array_flip( array( 'course', 'source', 'brand' ) ) ); return $export; } /** * Find bounded content and Elementor references for administrator review. * * @param string $id Configuration UUID. * @param array $profile Stored configuration. * @return mixed Result documented by the caller. */ public static function references( $id, array $profile ): array { global $wpdb; $needles = array_merge( array( $id, $profile['slug'] ?? '' ), (array) ( $profile['aliases'] ?? array() ) ); $ids = array(); foreach ( array_filter( $needles ) as $needle ) { $like = '%' . $wpdb->esc_like( $needle ) . '%'; $found = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type <> 'revision' AND post_content LIKE %s LIMIT 100", $like ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Bounded administrator-only reference review must reflect current unsaved cleanup work, not a stale index. $meta = $wpdb->get_col( $wpdb->prepare( "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_elementor_data' AND meta_value LIKE %s LIMIT 100", $like ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Same bounded administrator-only review; no frontend query. $ids = array_merge( $ids, $found, $meta ); } return array_slice( array_unique( array_map( 'absint', $ids ) ), 0, 100 ); } /** * Render authorized configuration maintenance controls. * * @param string $id Configuration UUID. * @param array $profile Stored configuration. * @return mixed Result documented by the caller. */ public static function controls( $id, array $profile ) { if ( ! wp_is_uuid( $id ) ) { return; } echo '
' . esc_html__( 'Maintenance and usage', 'actikidz-local-activities' ) . ''; echo '

' . esc_html__( 'Export settings before deleting a configuration. Disconnect from ActiKidz before uninstalling. Your pages are kept; remove their blocks or shortcodes separately.', 'actikidz-local-activities' ) . '

'; foreach ( array( 'clone' => __( 'Duplicate configuration', 'actikidz-local-activities' ), 'export' => __( 'Export settings', 'actikidz-local-activities' ), 'prepare' => __( 'Prepare courses now', 'actikidz-local-activities' ), ) as $op => $label ) { echo '
'; wp_nonce_field( 'akla_action' ); echo '
'; } echo '

' . esc_html__( 'Pages using this configuration (dynamic templates may not appear):', 'actikidz-local-activities' ) . '

'; } }