0 ) { $scored_rules[] = array( 'rule' => $rule, 'score' => $score, ); } } // No matching rules found. if ( empty( $scored_rules ) ) { $result = self::get_global_defaults(); set_transient( $cache_key, $result, self::CACHE_DURATION ); return $result; } // Sort by score (highest first), then by priority (highest first). usort( $scored_rules, function ( $a, $b ) { if ( $a['score'] !== $b['score'] ) { return $b['score'] - $a['score']; } $priority_a = isset( $a['rule']['priority'] ) ? (int) $a['rule']['priority'] : 50; $priority_b = isset( $b['rule']['priority'] ) ? (int) $b['rule']['priority'] : 50; return $priority_b - $priority_a; } ); // Return the best match. $matched = $scored_rules[0]['rule']; // Merge with global defaults so unset constraints fall back. $result = self::merge_with_defaults( $matched ); // Cache the result. set_transient( $cache_key, $result, self::CACHE_DURATION ); return $result; } /** * Score a rule against the current context. * * Returns a score based on how many targets match. * Returns 0 if any required target doesn't match (all targets are AND conditions). * * @since 1.2.0 * @param array $rule Rule data. * @param array $context Upload context. * @return int Score (0 = no match, higher = more specific match). */ private static function score_rule( $rule, $context ) { if ( empty( $rule['targets'] ) || ! is_array( $rule['targets'] ) ) { return 0; } $score = 0; $targets = $rule['targets']; $total_targets = 0; // Check post type target. if ( ! empty( $targets['post_type'] ) ) { $total_targets++; if ( ! empty( $context['post_type'] ) && $targets['post_type'] === $context['post_type'] ) { $score++; } else { return 0; // Required target doesn't match. } } // Check upload context target. if ( ! empty( $targets['context'] ) ) { $total_targets++; if ( ! empty( $context['context'] ) && $targets['context'] === $context['context'] ) { $score++; } else { return 0; // Required target doesn't match. } } // Check user role target. if ( ! empty( $targets['user_role'] ) ) { $total_targets++; if ( ! empty( $context['user_role'] ) && $targets['user_role'] === $context['user_role'] ) { $score++; } else { return 0; // Required target doesn't match. } } return $score; } /** * Merge a matched rule's constraints with global defaults. * * Ensures all expected constraint keys exist, falling back * to global settings for anything unset by the rule. * * @since 1.2.0 * @param array $rule Matched rule data. * @return array Rule with complete constraints. */ private static function merge_with_defaults( $rule ) { $defaults = self::get_global_defaults(); $constraints = isset( $rule['constraints'] ) ? $rule['constraints'] : array(); // Merge: rule constraints override defaults. $rule['constraints'] = wp_parse_args( $constraints, $defaults['constraints'] ); // Mark that this is a matched rule (not defaults). $rule['is_default'] = false; return $rule; } /** * Get global default constraints from plugin settings. * * Reads the existing admin settings (size limits, formats, etc.) * and returns them as a rule-compatible structure. * * @since 1.2.0 * @return array Global defaults as a rule structure. */ public static function get_global_defaults() { // Read current plugin settings. $size_limits = get_option( 'gatewell_size_limits', array( 'max_width' => GATEWELL_DEFAULT_MAX_WIDTH, 'max_height' => GATEWELL_DEFAULT_MAX_HEIGHT, 'max_filesize' => gatewell_default_max_filesize(), 'min_width' => GATEWELL_DEFAULT_MIN_WIDTH, 'min_height' => GATEWELL_DEFAULT_MIN_HEIGHT, ) ); $allowed_formats = get_option( 'gatewell_allowed_formats', array( 'jpeg' => true, 'png' => true, 'webp' => true, 'heic' => true, ) ); $compression = get_option( 'gatewell_compression_quality', GATEWELL_DEFAULT_COMPRESSION_QUALITY ); if ( is_array( $compression ) ) { $compression = isset( $compression['quality'] ) ? (int) $compression['quality'] : GATEWELL_DEFAULT_COMPRESSION_QUALITY; } // Build formats array from settings. $formats = array(); foreach ( $allowed_formats as $format => $enabled ) { if ( $enabled ) { $formats[] = $format; } } // Read form field settings. $form_fields = get_option( 'gatewell_form_fields', \GATEWELL_Feature_Flags::get_form_field_defaults() ); return array( 'id' => '_global', 'name' => __( 'Global Settings', 'gatewell' ), 'is_default' => true, 'enabled' => true, 'priority' => 0, 'targets' => array(), 'constraints' => array( 'size_type' => 'range', 'min_width' => isset( $size_limits['min_width'] ) ? (int) $size_limits['min_width'] : GATEWELL_DEFAULT_MIN_WIDTH, 'min_height' => isset( $size_limits['min_height'] ) ? (int) $size_limits['min_height'] : GATEWELL_DEFAULT_MIN_HEIGHT, 'max_width' => isset( $size_limits['max_width'] ) ? (int) $size_limits['max_width'] : GATEWELL_DEFAULT_MAX_WIDTH, 'max_height' => isset( $size_limits['max_height'] ) ? (int) $size_limits['max_height'] : GATEWELL_DEFAULT_MAX_HEIGHT, 'exact_width' => 0, 'exact_height' => 0, 'aspect_ratio' => '', 'lock_aspect_ratio' => false, 'formats' => $formats, 'compression_quality' => $compression, 'alt_text_required' => isset( $form_fields['alt_text']['required'] ) ? (bool) $form_fields['alt_text']['required'] : false, 'title_required' => isset( $form_fields['title']['required'] ) ? (bool) $form_fields['title']['required'] : false, 'caption_required' => isset( $form_fields['caption']['required'] ) ? (bool) $form_fields['caption']['required'] : false, ), ); } /** * Generate cache key for a context. * * @since 1.2.0 * @param array $context Upload context. * @return string Cache key. */ private static function get_cache_key( $context ) { $key_parts = array( isset( $context['post_type'] ) ? $context['post_type'] : 'none', isset( $context['context'] ) ? $context['context'] : 'none', isset( $context['user_role'] ) ? $context['user_role'] : 'none', ); return self::CACHE_PREFIX . md5( implode( '_', $key_parts ) ); } /** * Clear all cached rule matches. * * Call this when rules are created, updated, or deleted. * * @since 1.2.0 */ public static function clear_cache() { global $wpdb; // Delete all transients matching our prefix in one statement. // // There is no core API for "delete every transient whose key starts with // X" -- delete_transient() needs the exact key, and these keys are md5 // hashes of the match context -- so this has to touch $wpdb->options // directly. Both LIKE patterns are bound through %s. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- See above: prefix-wide transient invalidation has no core API, and this DELETE is itself the cache invalidation, so it has nothing to cache. $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s", '_transient_' . self::CACHE_PREFIX . '%', '_transient_timeout_' . self::CACHE_PREFIX . '%' ) ); } }