$by_surface) { $entry = array(); foreach ((array) $by_surface as $s => $overrides) { if (strtolower((string) $s) === $lc) { $entry = $overrides; break; } } $vals[$arm] = $entry; } return array( 'e' => $exp['id'], 'salt' => $exp['salt'], 'order' => $order, 'vals' => $vals, ); } private static function ensure_bucketing() { if (class_exists('AIEO_Reco_Experiment')) { return true; } if (defined('AIEO_DIR') && file_exists(AIEO_DIR . 'includes/class-aieo-reco-experiment.php')) { require_once AIEO_DIR . 'includes/class-aieo-reco-experiment.php'; } return class_exists('AIEO_Reco_Experiment'); } public static function arm_for($surface, $sid) { $sid = (string) $sid; if ($sid === '') { return self::ARM_NONE; } $exp = self::for_surface($surface); if (!$exp) { return self::ARM_NONE; } if (!self::ensure_bucketing()) { return self::ARM_NONE; } return (string) AIEO_Reco_Experiment::assign_variant($sid, $exp['salt'], array_keys($exp['arms'])); } public static function sanitize_list($raw) { $out = array(); $claimed = array(); foreach ((array) $raw as $entry) { $exp = self::sanitize_one($entry); if (!$exp) { continue; } if (!empty($exp['enabled'])) { $surfaces = self::surfaces_of($exp); $clash = false; foreach ($surfaces as $s) { if (isset($claimed[strtolower((string) $s)])) { $clash = true; break; } } if ($clash) { $exp['enabled'] = false; } else { foreach ($surfaces as $s) { $claimed[strtolower((string) $s)] = $exp['id']; } } } $out[] = $exp; } return $out; } public static function sanitize_one($raw) { if (!is_array($raw)) { return null; } $id = isset($raw['id']) ? sanitize_key((string) $raw['id']) : ''; if ($id === '') { return null; } $arms_raw = isset($raw['arms']) && is_array($raw['arms']) ? $raw['arms'] : array(); $arms = array(); $canon = array(); foreach ($arms_raw as $arm => $by_surface) { $arm = sanitize_key((string) $arm); if ($arm === '') { continue; } if (strlen($arm) > self::ARM_MAX_LEN) { return null; } if (!is_array($by_surface)) { continue; } $clean_surfaces = array(); foreach ($by_surface as $surface => $overrides) { $surface = (string) $surface; if (!preg_match('/^[A-Za-z0-9_\-]{1,40}$/', $surface)) { continue; } $lc = strtolower($surface); if (!isset($canon[$lc])) { $canon[$lc] = $surface; } $clean_surfaces[$canon[$lc]] = self::sanitize_overrides($overrides); } if (empty($clean_surfaces)) { continue; } $arms[$arm] = $clean_surfaces; } if (count($arms) < self::MIN_ARMS || count($arms) > self::MAX_ARMS) { return null; } $surfaces = array(); foreach ($arms as $by_surface) { foreach (array_keys($by_surface) as $s) { $surfaces[$s] = true; } } foreach ($arms as $arm => $by_surface) { foreach (array_keys($surfaces) as $s) { if (!isset($by_surface[$s])) { $arms[$arm][$s] = array(); } } } $salt = isset($raw['salt']) ? preg_replace('/[^A-Za-z0-9]/', '', (string) $raw['salt']) : ''; if ($salt === '') { return null; } return array( 'id' => substr($id, 0, 40), 'label' => isset($raw['label']) ? sanitize_text_field((string) $raw['label']) : '', 'enabled' => !empty($raw['enabled']), 'salt' => substr($salt, 0, 32), 'started_at' => isset($raw['started_at']) ? sanitize_text_field((string) $raw['started_at']) : '', 'arms' => $arms, ); } public static function sanitize_overrides($raw) { if (!is_array($raw) || !class_exists('AIEO_Display_Conditions')) { return array(); } $allowed = AIEO_Display_Conditions::OVERRIDABLE; $subset = array(); foreach ($allowed as $key) { if (array_key_exists($key, $raw)) { $subset[$key] = $raw[$key]; } } if (empty($subset)) { return array(); } $full = AIEO_Display_Conditions::sanitize($subset); $out = array(); foreach (array_keys($subset) as $key) { $out[$key] = $full[$key]; } return $out; } public static function surfaces_of($exp) { $out = array(); if (empty($exp['arms']) || !is_array($exp['arms'])) { return $out; } foreach ($exp['arms'] as $by_surface) { foreach (array_keys((array) $by_surface) as $s) { $out[$s] = true; } } return array_keys($out); } private static function ensure_conditions() { if (class_exists('AIEO_Display_Conditions')) { return true; } $local = __DIR__ . '/class-aieo-display-conditions.php'; if (file_exists($local)) { require_once $local; } if (!class_exists('AIEO_Display_Conditions') && defined('AIEO_DIR') && file_exists(AIEO_DIR . 'includes/class-aieo-display-conditions.php')) { require_once AIEO_DIR . 'includes/class-aieo-display-conditions.php'; } return class_exists('AIEO_Display_Conditions'); } public static function validate_payload(array $exp) { $errors = array(); $arms = isset($exp['arms']) && is_array($exp['arms']) ? $exp['arms'] : array(); $seen = array(); $count = 0; foreach (array_keys($arms) as $arm) { $raw = (string) $arm; $name = sanitize_key($raw); if ('' === $name) { continue; } $count++; if (strlen($name) > self::ARM_MAX_LEN) { $errors[] = array('code' => 'arm_too_long', 'surface' => '', 'key' => $raw, 'message' => 'Version name "' . $raw . '" is longer than ' . self::ARM_MAX_LEN . ' characters. It is refused rather than shortened, because a shortened name silently merges two versions into one in the measurements.'); } if (ctype_digit($name)) { $errors[] = array('code' => 'arm_numeric', 'surface' => '', 'key' => $raw, 'message' => 'Version name "' . $raw . '" is all digits. A digits-only name is treated as a number rather than a name in stored data and in the browser, so it needs at least one letter.'); } if (isset($seen[$name])) { $errors[] = array('code' => 'arm_duplicate', 'surface' => '', 'key' => $raw, 'message' => 'Two versions resolve to the same name "' . $name . '".'); } $seen[$name] = true; } if ($count < self::MIN_ARMS) { $errors[] = array('code' => 'too_few_arms', 'surface' => '', 'key' => '', 'message' => 'A split test needs at least ' . self::MIN_ARMS . ' versions.'); } if ($count > self::MAX_ARMS) { $errors[] = array('code' => 'too_many_arms', 'surface' => '', 'key' => '', 'message' => 'A split test can have at most ' . self::MAX_ARMS . ' versions.'); } $painted = array(); if (class_exists('AIEO_Display_Registry')) { foreach (AIEO_Display_Registry::surfaces() as $s) { if (AIEO_Display_Registry::is_server_painted($s['type'])) { $painted[strtolower((string) $s['key'])] = true; } } } $timing_keys = self::ensure_conditions() ? AIEO_Display_Conditions::CLIENT_PAINTED_ONLY : array(); $async_keys = array('cart', 'cartMin', 'audience'); $surfaces = array(); $varied = false; $flagged = array(); foreach ($arms as $arm => $by_surface) { if (!is_array($by_surface)) { continue; } foreach ($by_surface as $surface => $overrides) { $surface = (string) $surface; $lc = strtolower($surface); $surfaces[$lc] = true; if (!is_array($overrides)) { continue; } foreach (array_keys($overrides) as $key) { $key = (string) $key; $varied = true; if (isset($flagged[$lc . '|' . $key])) { continue; } if ('priority' === $key) { $flagged[$lc . '|' . $key] = true; $errors[] = array('code' => 'priority_not_read', 'surface' => $surface, 'key' => 'priority', 'message' => 'The priority setting cannot be tested: arbitration does not read priority yet, so every version would behave identically.'); continue; } if (!isset($painted[$lc])) { continue; } if (in_array($key, $timing_keys, true)) { $flagged[$lc . '|' . $key] = true; $errors[] = array('code' => 'timing_on_painted', 'surface' => $surface, 'key' => $key, 'message' => 'The "' . $key . '" setting decides when something appears, and "' . $surface . '" is drawn by the server and is already on the page before anything can decide. Every version would behave identically there.'); } elseif (in_array($key, $async_keys, true)) { $flagged[$lc . '|' . $key] = true; $errors[] = array('code' => 'async_on_painted', 'surface' => $surface, 'key' => $key, 'message' => 'The "' . $key . '" setting needs to ask the server a question, and "' . $surface . '" is drawn by the server before the browser can ask one.'); } } } } if (empty($surfaces)) { $errors[] = array('code' => 'no_surfaces', 'surface' => '', 'key' => '', 'message' => 'The test names no surface to run on.'); } if (!$varied) { $errors[] = array('code' => 'nothing_varied', 'surface' => '', 'key' => '', 'message' => 'No version overrides anything, so every version is identical and the test measures nothing.'); } return $errors; } public static function save($list) { $list = is_array($list) ? $list : array(); foreach ($list as $entry) { if (array() !== self::validate_payload(is_array($entry) ? $entry : array())) { return self::all(); } } $clean = self::sanitize_list($list); update_option(self::OPTION, $clean, false); self::purge(); return $clean; } public static function mint() { return array( 'salt' => wp_generate_password(16, false, false), 'started_at' => gmdate('Y-m-d H:i:s'), ); } private static function purge() { self::reset_memo(); if (class_exists('AIEO_DMM_Cache') && method_exists('AIEO_DMM_Cache', 'bump_version')) { AIEO_DMM_Cache::bump_version('popups'); } do_action('aieo_purge_page_cache'); } } }