registry = $registry; $this->evaluator = $evaluator; } /** * Returns a copy of the occurrence with warnings and preview attached. * * @param Occurrence $occurrence Occurrence to analyse. * @param \DateTimeImmutable $now Reference time for date/time checks. * @return Occurrence */ public function analyze( Occurrence $occurrence, \DateTimeImmutable $now ): Occurrence { return $occurrence->with_analysis( $this->warnings( $occurrence, $now ), $this->preview( $occurrence, $now ) ); } /** * Collects every warning that applies to an occurrence. * * @param Occurrence $occurrence Occurrence to analyse. * @param \DateTimeImmutable $now Reference time. * @return string[] */ public function warnings( Occurrence $occurrence, \DateTimeImmutable $now ): array { $warnings = array(); $conditions = $occurrence->conditions(); if ( $occurrence->is_enabled() && array() === $conditions ) { $warnings[] = __( 'Visibility is on but no conditions are set — the block always shows.', 'blocksieve' ); } if ( ! $occurrence->is_enabled() && array() !== $conditions ) { $warnings[] = __( 'Conditions are configured but visibility is switched off — they are being ignored.', 'blocksieve' ); } foreach ( $conditions as $condition ) { try { $warnings = array_merge( $warnings, $this->condition_warnings( $condition, $now ) ); } catch ( \Throwable $e ) { // A third-party definition that throws must not break the whole report. $warnings[] = __( 'A condition could not be inspected; its type reported an error.', 'blocksieve' ); } } $warnings = array_merge( $warnings, $this->contradiction_warnings( $occurrence ) ); return array_values( array_unique( $warnings ) ); } /** * Warnings for a single stored condition. * * @param array $condition Stored condition. * @param \DateTimeImmutable $now Reference time. * @return string[] */ private function condition_warnings( array $condition, \DateTimeImmutable $now ): array { $type = isset( $condition['type'] ) && is_string( $condition['type'] ) ? $condition['type'] : ''; if ( '' === $type ) { return array( __( 'A condition has no type, so its visibility rules are ignored and the block always shows.', 'blocksieve' ) ); } if ( ! $this->registry->has( $type ) ) { return array( sprintf( /* translators: %s: condition type identifier. */ __( 'Unknown condition type “%s” — the plugin that provided it may have been removed. The block always shows.', 'blocksieve' ), $type ), ); } $definition = $this->registry->get( $type ); $label = $definition->label(); $operator = isset( $condition['operator'] ) && is_string( $condition['operator'] ) ? $condition['operator'] : ''; $value = $condition['value'] ?? null; $warnings = array(); if ( '' !== $operator && ! in_array( $operator, $definition->operators(), true ) ) { $warnings[] = sprintf( /* translators: 1: condition label, 2: operator identifier. */ __( '%1$s uses an operator “%2$s” it does not support.', 'blocksieve' ), $label, $operator ); } // Generic: the type itself rejects the stored value (e.g. an unknown device). if ( ! $definition->is_valid_value( $value ) ) { $warnings[] = sprintf( /* translators: %s: condition label. */ __( '%s has a value it does not recognize, so it may not behave as intended.', 'blocksieve' ), $label ); } $negated = in_array( $operator, self::NEGATED_OPERATORS, true ); // Generic: a list-valued condition with nothing selected (blank entries // are never matched) can never match — or, negated, always matches. $schema = $definition->value_schema(); if ( ( $schema['type'] ?? '' ) === 'array' && is_array( $value ) && array() === array_filter( $value, static fn( $item ): bool => '' !== $item && null !== $item ) ) { $warnings[] = $negated ? sprintf( /* translators: %s: condition label. */ __( '%s has no values selected, so it always matches.', 'blocksieve' ), $label ) : sprintf( /* translators: %s: condition label. */ __( '%s has no values selected, so it can never match.', 'blocksieve' ), $label ); } return array_merge( $warnings, $this->specific_warnings( $type, $label, $value, $now, $negated ) ); } /** * Checks that only make sense for a particular built-in condition type. * * @param string $type Condition type id. * @param string $label Translated condition label. * @param mixed $value Stored value. * @param \DateTimeImmutable $now Reference time. * @param bool $negated Whether the operator inverts the match. * @return string[] */ private function specific_warnings( string $type, string $label, $value, \DateTimeImmutable $now, bool $negated ): array { $warnings = array(); if ( 'date-range' === $type && is_array( $value ) ) { $start = DateRangeCondition::parse_bound( $value['start'] ?? null, $now->getTimezone(), false ); $end = DateRangeCondition::parse_bound( $value['end'] ?? null, $now->getTimezone(), true ); if ( $start && $end && $end < $start ) { $warnings[] = $negated ? sprintf( /* translators: %s: condition label. */ __( '%s ends before it starts, so it always matches.', 'blocksieve' ), $label ) : sprintf( /* translators: %s: condition label. */ __( '%s ends before it starts, so it can never match.', 'blocksieve' ), $label ); } elseif ( $end && $end < $now ) { $warnings[] = $negated ? sprintf( /* translators: %s: condition label. */ __( '%s has already ended, so it now always matches.', 'blocksieve' ), $label ) : sprintf( /* translators: %s: condition label. */ __( '%s has already expired.', 'blocksieve' ), $label ); } if ( ! $start && ! $end ) { $warnings[] = $negated ? sprintf( /* translators: %s: condition label. */ __( '%s has no start or end date, so it can never match.', 'blocksieve' ), $label ) : sprintf( /* translators: %s: condition label. */ __( '%s has no start or end date, so it has no effect.', 'blocksieve' ), $label ); } } if ( 'time-range' === $type && is_array( $value ) ) { foreach ( array( 'start', 'end' ) as $field ) { $time = $value[ $field ] ?? null; if ( ! is_string( $time ) || ! preg_match( '/^([01]?\d|2[0-3]):([0-5]\d)$/', $time ) ) { $warnings[] = sprintf( /* translators: %s: condition label. */ __( '%s has an invalid time and is ignored (the block always shows).', 'blocksieve' ), $label ); break; } } } if ( 'query-parameter' === $type ) { $key = is_array( $value ) && isset( $value['key'] ) && is_scalar( $value['key'] ) ? (string) $value['key'] : ''; if ( '' === $key ) { $warnings[] = sprintf( /* translators: %s: condition label. */ __( '%s has no parameter name and is ignored (the block always shows).', 'blocksieve' ), $label ); } } if ( 'device' === $type ) { if ( is_array( $value ) && isset( $value['devices'] ) && is_array( $value['devices'] ) ) { $devices = $value['devices']; } elseif ( is_string( $value ) && '' !== $value ) { $devices = array( $value ); // Legacy single-device value. } else { $devices = array(); } if ( array() === $devices ) { $warnings[] = sprintf( /* translators: %s: condition label. */ __( '%s has no devices selected, so the block shows on all of them.', 'blocksieve' ), $label ); } elseif ( count( array_intersect( DeviceCondition::DEVICES, $devices ) ) === count( DeviceCondition::DEVICES ) ) { $warnings[] = sprintf( /* translators: %s: condition label. */ __( '%s selects every device, so it has no effect.', 'blocksieve' ), $label ); } } return $warnings; } /** * Warnings about conditions that contradict each other. * * @param Occurrence $occurrence Occurrence to analyse. * @return string[] */ private function contradiction_warnings( Occurrence $occurrence ): array { if ( 'all' !== $occurrence->relation() ) { return array(); } $types = $occurrence->condition_types(); $warnings = array(); if ( in_array( 'logged-in', $types, true ) && in_array( 'logged-out', $types, true ) ) { $warnings[] = __( 'Logged In and Logged Out are both required (“Match all”), so the block can never show.', 'blocksieve' ); } // Device conditions combine by intersection under "Match all". $shown_on = DeviceCondition::DEVICES; $devices = 0; foreach ( $occurrence->conditions() as $condition ) { if ( 'device' !== ( $condition['type'] ?? '' ) ) { continue; } ++$devices; $shown_on = array_intersect( $shown_on, $this->selected_devices( $condition['value'] ?? null ) ); } if ( $devices > 1 && array() === $shown_on ) { $warnings[] = __( 'The Device conditions share no device (“Match all”), so the block is hidden on every device.', 'blocksieve' ); } return $warnings; } /** * The devices a Device condition shows the block on, as the renderer reads * it: an empty or unrecognised selection has no effect (every device). * * @param mixed $value Stored value. * @return string[] */ private function selected_devices( $value ): array { if ( is_array( $value ) && isset( $value['devices'] ) && is_array( $value['devices'] ) ) { $devices = $value['devices']; } elseif ( is_string( $value ) && '' !== $value ) { $devices = array( $value ); // Legacy single-device value. } else { $devices = array(); } $devices = array_values( array_intersect( DeviceCondition::DEVICES, array_filter( $devices, 'is_string' ) ) ); return array() === $devices ? DeviceCondition::DEVICES : $devices; } /** * Previews how the model resolves for representative visitors. * * @param Occurrence $occurrence Occurrence to analyse. * @param \DateTimeImmutable $now Reference time. * @return array{visitor:string,admin:string} */ public function preview( Occurrence $occurrence, \DateTimeImmutable $now ): array { if ( ! $occurrence->is_enabled() || array() === $occurrence->conditions() ) { return array( 'visitor' => self::VERDICT_SHOWN, 'admin' => self::VERDICT_SHOWN, ); } // Request-scoped conditions cannot be resolved without a real request. $dependent = $occurrence->post_id > 0 ? self::REQUEST_DEPENDENT : array_merge( self::REQUEST_DEPENDENT, self::VIEW_DEPENDENT ); foreach ( $occurrence->condition_types() as $type ) { if ( in_array( $type, $dependent, true ) ) { return array( 'visitor' => self::VERDICT_DEPENDS, 'admin' => self::VERDICT_DEPENDS, ); } } return array( 'visitor' => $this->verdict( $occurrence, $this->visitor_context( $occurrence, $now ) ), 'admin' => $this->verdict( $occurrence, $this->admin_context( $occurrence, $now ) ), ); } /** * Runs the evaluator, failing open exactly as the front end does. * * @param Occurrence $occurrence Occurrence to evaluate. * @param Context $context Synthetic context. * @return string */ private function verdict( Occurrence $occurrence, Context $context ): string { try { $renders = $this->evaluator->should_render( $occurrence->model, $context ); } catch ( \Throwable $e ) { return self::VERDICT_SHOWN; // Fail-safe, matching ConditionManager. } return $renders ? self::VERDICT_SHOWN : self::VERDICT_HIDDEN; } /** * Builds a context representing a logged-out visitor on this content. * * @param Occurrence $occurrence Occurrence being previewed. * @param \DateTimeImmutable $now Reference time. * @return Context */ private function visitor_context( Occurrence $occurrence, \DateTimeImmutable $now ): Context { return new Context( 0, array(), false, $now, '' !== $occurrence->post_type ? $occurrence->post_type : null, $occurrence->post_id, false, null, false, false, false, false, $occurrence->post_id > 0 ); } /** * Builds a context representing a logged-in administrator on this content. * * @param Occurrence $occurrence Occurrence being previewed. * @param \DateTimeImmutable $now Reference time. * @return Context */ private function admin_context( Occurrence $occurrence, \DateTimeImmutable $now ): Context { return new Context( 1, array( 'administrator' ), true, $now, '' !== $occurrence->post_type ? $occurrence->post_type : null, $occurrence->post_id, false, null, false, false, false, false, $occurrence->post_id > 0 ); } }