null, 'distance_max' => null, 'ascent_min' => null, 'ascent_max' => null, ); } else { $distance_range = (isset($owning_group['distance_range']) && is_array($owning_group['distance_range'])) ? $owning_group['distance_range'] : array(); $ascent_range = (isset($owning_group['ascent_range']) && is_array($owning_group['ascent_range'])) ? $owning_group['ascent_range'] : array(); $explicit = array( 'distance_min' => isset($distance_range['min']) ? $distance_range['min'] : null, 'distance_max' => isset($distance_range['max']) ? $distance_range['max'] : null, 'ascent_min' => isset($ascent_range['min']) ? $ascent_range['min'] : null, 'ascent_max' => isset($ascent_range['max']) ? $ascent_range['max'] : null, ); } // Tier 2: auto-computed fallback, only for sides tier 1 left null. if (function_exists('rfcc_get_route_ranges_for_activity_type') && in_array(null, $explicit, true)) { $auto = rfcc_get_route_ranges_for_activity_type($activity_type_name); foreach ($explicit as $side => $value) { if ($value === null && isset($auto[$side]) && $auto[$side] !== null) { $explicit[$side] = $auto[$side]; } } } return $explicit; } /** * Auto-computed Distance/Ascent range for one activity type's own published * routes - SQL MIN()/MAX(), same shape and REGEXP/DECIMAL approach as the * global rfcc_get_route_ranges() (frontend/route-group-filter.php), scoped * to one activity_type_name. Backs the tier-2 fallback in * rfcc_get_effective_range_for_activity_type() above and the client-side * `activityTypeAutoRanges` localization (frontend/route-group-filter.php, * frontend/rfcc-v2.php). * * Returns all-null bounds for a type with nothing to compute from (no * published routes, or none with a valid distance_km/ascent_m value) - the * caller should treat that the same as "no auto range available". * * @param string $activity_type_name * @return array {distance_min, distance_max, ascent_min, ascent_max} each a number or null. */ function rfcc_get_route_ranges_for_activity_type($activity_type_name) { $all = rfcc_get_route_ranges_by_activity_type(); if (isset($all[$activity_type_name])) { return $all[$activity_type_name]; } return array( 'distance_min' => null, 'distance_max' => null, 'ascent_min' => null, 'ascent_max' => null, ); } /** * Map of every activity type name => its own auto-computed Distance/Ascent * range - one query for every type at once (not N per-type queries), the * data source behind rfcc_get_route_ranges_for_activity_type(). Cached via * transient 'rfcc_route_ranges_by_activity_type', invalidated on the same * save_post hook as the sibling site-wide range transient * 'rfcc_route_ranges_simple' - see rfcc_clear_simple_route_cache() * (frontend/route-group-filter.php). * * @return array activity_type_name => {distance_min, distance_max, ascent_min, ascent_max} */ function rfcc_get_route_ranges_by_activity_type() { global $wpdb; $cache_key = 'rfcc_route_ranges_by_activity_type'; $cached = get_transient($cache_key); if ($cached !== false && is_array($cached)) { return $cached; } // Same REGEXP + CAST...AS DECIMAL approach as rfcc_get_route_ranges() // (frontend/route-group-filter.php) - see that function's comment for // why (decimal-valued distance_km/ascent_m, e.g. "3.1", must not be // silently excluded or truncated). // The regex is passed as a parameter so its backslash reaches MySQL's // REGEXP intact (a literal '\\.' inside the SQL string itself would be // unescaped to '.' by MySQL, matching any character). $results = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- aggregate query, result is cached via transient $wpdb->prepare( "SELECT atn.meta_value AS activity_type, pm.meta_key AS metric, MIN(CAST(pm.meta_value AS DECIMAL(10,2))) AS min_val, MAX(CAST(pm.meta_value AS DECIMAL(10,2))) AS max_val FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON pm.post_id = p.ID INNER JOIN {$wpdb->postmeta} atn ON atn.post_id = p.ID AND atn.meta_key = %s WHERE pm.meta_key IN (%s, %s) AND pm.meta_value REGEXP %s AND p.post_type = %s AND p.post_status = %s AND atn.meta_value != '' GROUP BY atn.meta_value, pm.meta_key", 'activity_type_name', 'distance_km', 'ascent_m', '^[0-9]+(\\.[0-9]+)?$', 'rfcc_route', 'publish' ) ); $map = array(); foreach ($results as $row) { $type = $row->activity_type; if (!isset($map[$type])) { $map[$type] = array( 'distance_min' => null, 'distance_max' => null, 'ascent_min' => null, 'ascent_max' => null, ); } // floor()/ceil() rather than a plain (int) cast, matching // rfcc_get_route_ranges() - a decimal minimum must round DOWN and a // decimal maximum must round UP, or the narrowed floor/ceiling could // clip the exact route that motivated using DECIMAL in the first place. if ($row->metric === 'distance_km') { $map[$type]['distance_min'] = (int) floor((float) $row->min_val); $map[$type]['distance_max'] = (int) ceil((float) $row->max_val); } elseif ($row->metric === 'ascent_m') { $map[$type]['ascent_min'] = (int) floor((float) $row->min_val); $map[$type]['ascent_max'] = (int) ceil((float) $row->max_val); } } set_transient($cache_key, $map, DAY_IN_SECONDS); return $map; } /** * Same as rfcc_get_effective_club_groups_for_activity_type(), but just the * key strings - for callers doing in_array()/array_intersect() membership * checks (frontend/route-group-filter.php's club-groups filter, in both the * cached-groups and live-query paths) that need the sparse "assigned type * never borrows an untagged group's items" rule. Do NOT use the older * rfcc_get_club_group_indices_for_activity_type() (admin/routes-club-groups.php) * for this - it still uses the pre-Definition-Groups "empty activity_types * = applies everywhere" convention, which incorrectly lets an untagged * group's club groups leak into a type that has since been explicitly * assigned to its own Definition Group (confirmed live: selecting a * Default-group club-group key while an assigned-elsewhere activity type * was selected wrongly restricted that type's routes instead of being a * no-op). * * @param string $activity_type_name * @return string[] */ function rfcc_get_effective_club_group_keys_for_activity_type($activity_type_name) { return array_values(array_filter(array_column(rfcc_get_effective_club_groups_for_activity_type($activity_type_name), 'key'))); } /** * Same as rfcc_get_effective_climbing_profiles_for_activity_type(), but just * the key strings - see rfcc_get_effective_club_group_keys_for_activity_type() * above for why this exists instead of using * rfcc_get_climbing_profile_keys_for_activity_type() (admin/routes-climbing-profiles.php) * for filter-membership checks. * * @param string $activity_type_name * @return string[] */ function rfcc_get_effective_climbing_profile_keys_for_activity_type($activity_type_name) { return array_values(array_filter(array_column(rfcc_get_effective_climbing_profiles_for_activity_type($activity_type_name), 'key'))); } /** * Short random lowercase-alphanumeric identifier, in the same character-class * style already enforced for Climbing Profile keys (/^[a-z0-9_]+$/ elsewhere * in this plugin). Used by the migration to stamp a stable `key`/`id` onto * rows that never had one before (e.g. existing Club Groups, which were * identified purely by array index prior to Definition Groups). * * @param string $prefix e.g. 'grp' or 'cg'. * @return string */ function rfcc_generate_definition_group_id($prefix) { return $prefix . '_' . substr(md5(uniqid((string) wp_rand(), true)), 0, 6); } /** * Scaffold a new, empty Definition Group tagged to exactly one activity type - * so a newly-added activity type gets a ready-to-fill-in section without the * admin having to remember to create one themselves. Called from * rfcc_save_activity_types() (admin/routes-activity-types.php) for each * genuinely NEW type name found on save, and from the fresh-install path in * includes/migrations/migration-1.0.php for each default type. Deliberately * NOT hooked into any recurring migration/update check - re-running this on * every plugin update would resurrect a group the admin has since deleted. * * No-op if a group already claims this type (defensive; shouldn't happen for * a genuinely new type, but avoids ever creating a duplicate/conflicting claim). * * @param string $activity_type_name * @return void */ function rfcc_create_empty_definition_group_for_activity_type($activity_type_name) { if (empty($activity_type_name) || rfcc_get_definition_group_for_activity_type($activity_type_name) !== null) { return; } $groups = rfcc_get_definition_groups(); $groups[] = array( 'id' => rfcc_generate_definition_group_id('grp'), 'name' => $activity_type_name, 'activity_types' => array($activity_type_name), 'club_groups' => array(), 'climbing_profiles' => array(), 'difficulty_scale' => null, 'tags' => array(), 'distance_range' => array('min' => null, 'max' => null), 'ascent_range' => array('min' => null, 'max' => null), ); update_option('rfcc_activity_definition_groups', $groups); }