|null */ private static ?array $provinces = null; /** * City lists already loaded this request, keyed by province code. * * @var array> */ private static array $cities = []; /** * Every province, keyed by WooCommerce state code, sorted by Persian name. * * @return array */ public static function provinces(): array { if ( null !== self::$provinces ) { return self::$provinces; } $path = RAHKAR_WP_DIR . 'data/ir/provinces.php'; $data = is_readable( $path ) ? require $path : []; self::$provinces = is_array( $data ) ? $data : []; return self::$provinces; } /** * Is this a province code we know? * * @param string $code WooCommerce state code. */ public static function isProvince( string $code ): bool { return isset( self::provinces()[ strtoupper( $code ) ] ); } /** * Persian name of a province. * * @param string $code WooCommerce state code. */ public static function provinceName( string $code ): string { return self::provinces()[ strtoupper( $code ) ] ?? ''; } /** * Cities of one province: the compiled list plus any the admin added. * * @param string $code WooCommerce state code. * * @return list */ public static function cities( string $code ): array { $code = strtoupper( $code ); if ( isset( self::$cities[ $code ] ) ) { return self::$cities[ $code ]; } if ( ! self::isProvince( $code ) ) { return []; } $merged = array_merge( self::baseCities( $code ), self::customCities( $code ) ); // A custom entry that duplicates a compiled one must not appear twice. $merged = array_values( array_unique( $merged ) ); self::$cities[ $code ] = $merged; return $merged; } /** * The compiled cities of a province, without custom additions. * * @param string $code WooCommerce state code. * * @return list */ public static function baseCities( string $code ): array { $code = strtoupper( $code ); // The code is checked against the province list before it ever reaches a // path, so a crafted value cannot escape the data directory. if ( ! self::isProvince( $code ) ) { return []; } $path = RAHKAR_WP_DIR . "data/ir/cities/{$code}.php"; $data = is_readable( $path ) ? require $path : []; return is_array( $data ) ? array_values( $data ) : []; } /** * Cities the admin added to one province. * * @param string $code WooCommerce state code. * * @return list */ public static function customCities( string $code ): array { $all = self::allCustomCities(); return $all[ strtoupper( $code ) ] ?? []; } /** * Every admin-added city, keyed by province code. * * @return array> */ public static function allCustomCities(): array { $stored = Settings::get( IranLocationsModule::SLUG . '.custom_cities', [] ); if ( ! is_array( $stored ) ) { return []; } $clean = []; foreach ( $stored as $code => $names ) { $code = strtoupper( (string) $code ); if ( ! is_array( $names ) || ! self::isProvince( $code ) ) { continue; } $list = array_values( array_filter( array_map( 'strval', $names ), static fn( $n ) => '' !== trim( $n ) ) ); if ( [] !== $list ) { $clean[ $code ] = $list; } } return $clean; } /** * Add a city to a province. * * @param string $code Province code. * @param string $name City name. * * @return bool True when it was added, false when invalid or already present. */ public static function addCity( string $code, string $name ): bool { $code = strtoupper( $code ); $name = Normaliser::place( $name ); if ( '' === $name || ! self::isProvince( $code ) ) { return false; } // Comparison happens on the normalised form, so a name typed with Arabic // ك or a zero-width joiner cannot slip in as a near-duplicate of a city // that is already listed. foreach ( self::cities( $code ) as $existing ) { if ( Normaliser::place( $existing ) === $name ) { return false; } } $custom = self::allCustomCities(); $custom[ $code ][] = $name; self::saveCustomCities( $custom ); return true; } /** * Remove an admin-added city. * * Compiled cities cannot be removed this way — they come back on the next * update, so pretending otherwise would be a lie. * * @param string $code Province code. * @param string $name City name. */ public static function removeCity( string $code, string $name ): bool { $code = strtoupper( $code ); $name = Normaliser::place( $name ); $custom = self::allCustomCities(); if ( ! isset( $custom[ $code ] ) ) { return false; } $before = count( $custom[ $code ] ); $custom[ $code ] = array_values( array_filter( $custom[ $code ], static fn( string $existing ): bool => Normaliser::place( $existing ) !== $name ) ); if ( [] === $custom[ $code ] ) { unset( $custom[ $code ] ); } if ( count( $custom[ $code ] ?? [] ) === $before ) { return false; } self::saveCustomCities( $custom ); return true; } /** * Does a city belong to a province? * * Used for server-side checkout validation, where the browser's selection can * never be trusted. * * @param string $code Province code. * @param string $city City name. */ public static function isValidCity( string $code, string $city ): bool { $city = Normaliser::place( $city ); if ( '' === $city ) { return false; } foreach ( self::cities( $code ) as $known ) { if ( Normaliser::place( $known ) === $city ) { return true; } } return false; } /** * Fingerprint of the compiled dataset, for cache busting. */ public static function datasetVersion(): string { $path = RAHKAR_WP_DIR . 'data/ir/version.php'; return is_readable( $path ) ? (string) require $path : RAHKAR_WP_VERSION; } /** * Persist custom cities and drop the memoised lists. * * @param array> $custom Custom cities by province code. */ private static function saveCustomCities( array $custom ): void { $settings = Settings::all( IranLocationsModule::SLUG ); $settings['custom_cities'] = $custom; Settings::save( IranLocationsModule::SLUG, $settings ); self::$cities = []; } }