209 lines
6.5 KiB
PHP
209 lines
6.5 KiB
PHP
<?php
|
|
defined( 'ABSPATH' ) || exit;
|
|
|
|
/**
|
|
* The client-managed region → city structure behind the /{region}/,
|
|
* /{city}/ and /{city}/{product}/ URLs.
|
|
*
|
|
* Everything here except get() is plain PHP so it can be unit-tested
|
|
* without WordPress (see tests/run.php).
|
|
*
|
|
* Shape: list of regions, each
|
|
* array( 'name', 'slug', 'term_id', 'cities' => list of array( 'id', 'name', 'slug' ) )
|
|
* where a city's 'id' is its Viator destination id.
|
|
*/
|
|
class VC_Structure {
|
|
const OPTION = 'vc_structure';
|
|
|
|
private static $cache = null;
|
|
|
|
public static function default_regions() {
|
|
$regions = array(
|
|
'uk' => 'UK',
|
|
'europe' => 'Europe',
|
|
'asia' => 'Asia',
|
|
'middle-east-and-africa' => 'Middle East and Africa',
|
|
'americas' => 'Americas',
|
|
'australasia' => 'Australasia',
|
|
);
|
|
$out = array();
|
|
foreach ( $regions as $slug => $name ) {
|
|
$out[] = array(
|
|
'name' => $name,
|
|
'slug' => $slug,
|
|
'term_id' => 0,
|
|
'cities' => array(),
|
|
);
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
public static function get() {
|
|
if ( null === self::$cache ) {
|
|
$value = get_option( self::OPTION, self::default_regions() );
|
|
self::$cache = is_array( $value ) ? $value : self::default_regions();
|
|
}
|
|
return self::$cache;
|
|
}
|
|
|
|
/**
|
|
* Drop the per-request cache (after the option is saved).
|
|
*/
|
|
public static function flush_cache() {
|
|
self::$cache = null;
|
|
}
|
|
|
|
public static function slugify( $s ) {
|
|
$s = strtolower( (string) $s );
|
|
$s = preg_replace( '/[^a-z0-9]+/', '-', $s );
|
|
return trim( $s, '-' );
|
|
}
|
|
|
|
/**
|
|
* Shape submitted form data into the structure. Admin-typed slugs are only
|
|
* trimmed/lowercased here — validate() rejects anything still invalid
|
|
* rather than silently rewriting it.
|
|
*/
|
|
public static function normalize( array $raw ) {
|
|
$out = array();
|
|
foreach ( $raw as $region ) {
|
|
if ( ! is_array( $region ) ) {
|
|
continue;
|
|
}
|
|
$name = trim( (string) ( $region['name'] ?? '' ) );
|
|
$slug = strtolower( trim( (string) ( $region['slug'] ?? '' ) ) );
|
|
$cities = array();
|
|
foreach ( (array) ( $region['cities'] ?? array() ) as $city ) {
|
|
$id = trim( (string) ( is_array( $city ) ? ( $city['id'] ?? '' ) : '' ) );
|
|
if ( '' === $id ) {
|
|
continue;
|
|
}
|
|
$city_name = trim( (string) ( $city['name'] ?? '' ) );
|
|
$city_slug = strtolower( trim( (string) ( $city['slug'] ?? '' ) ) );
|
|
$cities[] = array(
|
|
'id' => $id,
|
|
'name' => $city_name,
|
|
'slug' => '' !== $city_slug ? $city_slug : self::slugify( $city_name ),
|
|
);
|
|
}
|
|
$out[] = array(
|
|
'name' => $name,
|
|
'slug' => '' !== $slug ? $slug : self::slugify( $name ),
|
|
'term_id' => (int) ( $region['term_id'] ?? 0 ),
|
|
'cities' => $cities,
|
|
);
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
public static function reserved_slugs() {
|
|
return array( 'wp-admin', 'wp-content', 'wp-includes', 'wp-json', 'feed', 'page', 'search', 'tag', 'category', 'author', 'tours', 'tour-destination', 'tour-tag', 'tour-category', 'blog', 'about', 'contact', 'faq', 'embed', 'attachment' );
|
|
}
|
|
|
|
/**
|
|
* @param array $destination_map Viator destination id => name.
|
|
* @param array $taken slug => label, for slugs owned outside the structure.
|
|
* @return string[] Errors; empty when valid.
|
|
*/
|
|
public static function validate( array $structure, array $destination_map, array $taken ) {
|
|
$errors = array();
|
|
$slugs = array();
|
|
$city_ids = array();
|
|
|
|
foreach ( $structure as $i => $region ) {
|
|
if ( '' === trim( (string) $region['name'] ) ) {
|
|
$errors[] = sprintf( 'Region %d has no name.', $i + 1 );
|
|
}
|
|
$slugs[] = (string) $region['slug'];
|
|
|
|
foreach ( $region['cities'] as $city ) {
|
|
$id = (string) $city['id'];
|
|
if ( ! isset( $destination_map[ $id ] ) ) {
|
|
$errors[] = sprintf( 'City ID %s is not a Viator destination.', $id );
|
|
}
|
|
if ( isset( $city_ids[ $id ] ) ) {
|
|
$errors[] = sprintf( '%s is listed more than once.', '' !== $city['name'] ? $city['name'] : $id );
|
|
}
|
|
$city_ids[ $id ] = true;
|
|
$slugs[] = (string) $city['slug'];
|
|
}
|
|
}
|
|
|
|
$reserved = self::reserved_slugs();
|
|
foreach ( array_count_values( $slugs ) as $slug => $count ) {
|
|
$slug = (string) $slug;
|
|
if ( ! preg_match( '/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $slug ) ) {
|
|
$errors[] = sprintf( 'Slug "%s" is invalid: use only a-z, 0-9 and -.', $slug );
|
|
continue;
|
|
}
|
|
if ( in_array( $slug, $reserved, true ) ) {
|
|
$errors[] = sprintf( 'Slug "%s" is reserved.', $slug );
|
|
}
|
|
if ( $count > 1 ) {
|
|
$errors[] = sprintf( 'Slug "%s" is used more than once.', $slug );
|
|
}
|
|
if ( isset( $taken[ $slug ] ) ) {
|
|
$errors[] = sprintf( 'Slug "%s" is already used by %s.', $slug, $taken[ $slug ] );
|
|
}
|
|
}
|
|
|
|
return array_values( array_unique( $errors ) );
|
|
}
|
|
|
|
/**
|
|
* Configured cities in settings order: viator id => city + 'region' slug.
|
|
*/
|
|
public static function cities( array $structure ) {
|
|
$out = array();
|
|
foreach ( $structure as $region ) {
|
|
foreach ( $region['cities'] as $city ) {
|
|
$out[ (string) $city['id'] ] = $city + array( 'region' => $region['slug'] );
|
|
}
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
/**
|
|
* The city a product's URL lives under: its Viator primary destination
|
|
* when configured, else the first configured city it belongs to, else ''.
|
|
*/
|
|
public static function canonical_city( array $structure, array $dest_ids, $primary_id ) {
|
|
$cities = self::cities( $structure );
|
|
if ( '' !== (string) $primary_id && isset( $cities[ (string) $primary_id ] ) ) {
|
|
return $cities[ (string) $primary_id ]['slug'];
|
|
}
|
|
$dest_ids = array_map( 'strval', $dest_ids );
|
|
foreach ( $cities as $id => $city ) {
|
|
if ( in_array( (string) $id, $dest_ids, true ) ) {
|
|
return $city['slug'];
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
public static function primary_destination_id( array $destinations ) {
|
|
foreach ( $destinations as $destination ) {
|
|
if ( is_array( $destination ) && ! empty( $destination['primary'] ) ) {
|
|
$ref = $destination['ref'] ?? ( $destination['destinationId'] ?? '' );
|
|
return (string) $ref;
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Explicit per-slug rules (no catch-alls), regions first, then cities.
|
|
*/
|
|
public static function rewrite_rules( array $structure ) {
|
|
$rules = array();
|
|
foreach ( $structure as $region ) {
|
|
$rules[ '^' . $region['slug'] . '/?$' ] = 'index.php?viator_destination=' . $region['slug'];
|
|
}
|
|
foreach ( self::cities( $structure ) as $city ) {
|
|
$rules[ '^' . $city['slug'] . '/?$' ] = 'index.php?viator_destination=' . $city['slug'];
|
|
$rules[ '^' . $city['slug'] . '/([^/]+)/?$' ] = 'index.php?viator_tour=$matches[1]';
|
|
}
|
|
return $rules;
|
|
}
|
|
}
|