'device-type', * 'label' => 'Device type', * 'enabled' => true, * 'post_types' => array(), // empty means every post type * 'source' => 'manual', // manual | preset | learned * 'variants' => array( * array('key' => 'desktop', 'label' => 'Desktop', 'tokens' => array('desktop', 'dsk')), * array('key' => 'mobile', 'label' => 'Mobile', 'tokens' => array('mobile', 'mob')), * ), * ), * ) */ if (!defined('ABSPATH')) exit; /** * Normalize a stored group array, filling in defaults and dropping anything * malformed. Configuration is user-editable, so it is never trusted as-is. * * @param mixed $group * @return array|null Null when the group cannot be used. */ function wpbr_normalize_variant_group($group) { if (!is_array($group)) return null; $id = isset($group['id']) && is_scalar($group['id']) ? sanitize_key((string) $group['id']) : ''; if ($id === '') return null; $variants = array(); if (!empty($group['variants']) && is_array($group['variants'])) { foreach ($group['variants'] as $variant) { if (!is_array($variant)) continue; $key = isset($variant['key']) && is_scalar($variant['key']) ? sanitize_key((string) $variant['key']) : ''; if ($key === '') continue; $tokens = array(); if (!empty($variant['tokens']) && is_array($variant['tokens'])) { foreach ($variant['tokens'] as $token) { $token = wpbr_normalize_variant_token($token); if ($token !== '') $tokens[] = $token; } } $tokens = array_values(array_unique($tokens)); if (!$tokens) continue; // A variant with no tokens can never match. $variants[] = array( 'key' => $key, 'label' => isset($variant['label']) && is_scalar($variant['label']) ? sanitize_text_field((string) $variant['label']) : $key, 'tokens' => $tokens, ); } } // One variant cannot exclude anything; it takes two to be mutually exclusive. if (count($variants) < 2) return null; $post_types = array(); if (!empty($group['post_types']) && is_array($group['post_types'])) { foreach ($group['post_types'] as $pt) { $pt = is_scalar($pt) ? sanitize_key((string) $pt) : ''; if ($pt !== '') $post_types[] = $pt; } } $source = isset($group['source']) && is_scalar($group['source']) ? sanitize_key((string) $group['source']) : 'manual'; if (!in_array($source, array('manual', 'preset', 'learned'), true)) { $source = 'manual'; } return array( 'id' => $id, // A label is shown to administrators, so it is plain text like the variant labels above. 'label' => isset($group['label']) && is_scalar($group['label']) && sanitize_text_field((string) $group['label']) !== '' ? sanitize_text_field((string) $group['label']) : $id, 'enabled' => !isset($group['enabled']) || (bool) $group['enabled'], 'post_types' => array_values(array_unique($post_types)), 'source' => $source, 'variants' => $variants, ); } /** * Normalize one token to the same shape the matcher normalizes text into, * so "M.A." and "m a" compare equal. * * @param mixed $token * @return string */ function wpbr_normalize_variant_token($token) { $token = strtolower(trim((string) $token)); $token = preg_replace('/[^a-z0-9]+/', ' ', $token); return trim(preg_replace('/\s+/', ' ', $token)); } /** * All configured groups, normalized, optionally narrowed to a post type. * * @param string $post_type Empty for every group regardless of post type. * @return array */ function wpbr_variant_groups($post_type = '') { $stored = get_option('wpbr_variant_groups', array()); if (!is_array($stored)) $stored = array(); /** * Filter the configured variant groups. * * Extension point for vocabulary packs, saved matching profiles and * learned variants. Entries added here are normalized like any other. */ $stored = apply_filters('wpbr_variant_groups', $stored, $post_type); $out = array(); foreach ($stored as $group) { $group = wpbr_normalize_variant_group($group); if (!$group || !$group['enabled']) continue; if ($post_type !== '' && $group['post_types'] && !in_array($post_type, $group['post_types'], true)) { continue; } $out[] = $group; } return $out; } /** * Which variant of a group does this text belong to? * * Longest token first, so a longer token always beats a shorter one that is a * prefix of it. That ordering is what lets plain tokens replace the negative * lookaheads a regex approach would need: "msc" wins over "ms" by being * longer, rather than "ms" needing to know to exclude it. * * @return string The variant key, or '' when the text shows no variant. */ function wpbr_detect_variant($group, $text) { $text = wpbr_normalize_variant_token($text); if ($text === '') return ''; $candidates = array(); foreach ($group['variants'] as $variant) { foreach ($variant['tokens'] as $token) { $candidates[] = array('token' => $token, 'key' => $variant['key']); } } usort($candidates, function ($a, $b) { return strlen($b['token']) - strlen($a['token']); }); foreach ($candidates as $candidate) { if (wpbr_text_contains_token($text, $candidate['token'])) { return $candidate['key']; } } return ''; } /** * Whole-token containment, so "as" does not match inside "associates". * * The token is escaped rather than treated as a pattern: these come from a * settings field, and a user-supplied regex would let a bad pattern hang the * matcher on every candidate it scores. */ function wpbr_text_contains_token($normalized_text, $token) { if ($token === '') return false; return (bool) preg_match('/(?:^|\s)' . preg_quote($token, '/') . '(?:\s|$)/', $normalized_text); } /** * May a file and a candidate post be matched, given the configured groups? * * Abstains unless both sides declare a variant of the same group: a rule can * only exclude a pairing it actually has an opinion about. * * @param string $filename * @param string $title * @param string $post_type * @return bool False when some group says these are different things. */ function wpbr_variants_allow_match($filename, $title, $post_type = '') { foreach (wpbr_variant_groups($post_type) as $group) { $file_variant = wpbr_detect_variant($group, $filename); if ($file_variant === '') continue; $title_variant = wpbr_detect_variant($group, $title); if ($title_variant === '') continue; if ($file_variant !== $title_variant) { return false; } } return true; }