path = '' !== $path ? $path : AIBLACKOUT_PATH . 'data/releases.php'; } /** * Returns every valid release entry, newest first. * * @return array[] Validated entries. */ public function all(): array { if ( null !== $this->entries ) { return $this->entries; } $this->entries = array(); if ( ! is_readable( $this->path ) ) { return $this->entries; } $raw = require $this->path; if ( ! is_array( $raw ) ) { return $this->entries; } foreach ( $raw as $entry ) { $release = $this->normalise( $entry ); if ( null !== $release ) { $this->entries[] = $release; } } usort( $this->entries, static function ( array $a, array $b ): int { return version_compare( $b['version'], $a['version'] ); } ); return $this->entries; } /** * Returns the releases that are new to someone last shown `$floor`. * * @param string $floor Version already seen. '' means nothing is new. * @return array[] Entries above the floor, newest first. */ public function since( string $floor ): array { if ( '' === $floor ) { return array(); } $new = array(); foreach ( $this->all() as $entry ) { if ( version_compare( $entry['version'], $floor, '>' ) && version_compare( $entry['version'], AIBLACKOUT_VERSION, '<=' ) ) { $new[] = $entry; } } return $new; } /** * Returns the newest release that has notes, or null when there are none. * * @return array|null The entry. */ public function latest(): ?array { foreach ( $this->all() as $entry ) { if ( version_compare( $entry['version'], AIBLACKOUT_VERSION, '<=' ) ) { return $entry; } } return null; } /** * Validates one raw entry. * * @param mixed $entry Raw entry from the notes file. * @return array|null The normalised entry, or null when unusable. */ private function normalise( $entry ): ?array { if ( ! is_array( $entry ) || empty( $entry['version'] ) || ! is_string( $entry['version'] ) ) { return null; } return array( 'version' => $entry['version'], 'date' => isset( $entry['date'] ) ? (string) $entry['date'] : '', 'headline' => isset( $entry['headline'] ) ? (string) $entry['headline'] : '', 'summary' => isset( $entry['summary'] ) ? (string) $entry['summary'] : '', 'moved' => $this->normalise_moved( $entry ), 'items' => $this->normalise_items( $entry ), ); } /** * Validates the "where it went" map of one entry. * * @param array $entry Raw entry. * @return array[] Normalised rows. */ private function normalise_moved( array $entry ): array { if ( empty( $entry['moved'] ) || ! is_array( $entry['moved'] ) ) { return array(); } $rows = array(); foreach ( $entry['moved'] as $row ) { if ( ! is_array( $row ) || empty( $row['from'] ) || empty( $row['to'] ) ) { continue; } $rows[] = array( 'from' => (string) $row['from'], 'to' => (string) $row['to'], 'page' => isset( $row['page'] ) ? sanitize_key( $row['page'] ) : '', 'tab' => isset( $row['tab'] ) ? sanitize_key( $row['tab'] ) : '', ); } return $rows; } /** * Validates the illustrated sections of one entry. * * @param array $entry Raw entry. * @return array[] Normalised sections. */ private function normalise_items( array $entry ): array { if ( empty( $entry['items'] ) || ! is_array( $entry['items'] ) ) { return array(); } $items = array(); foreach ( $entry['items'] as $item ) { if ( ! is_array( $item ) || empty( $item['title'] ) ) { continue; } $items[] = array( 'title' => (string) $item['title'], 'text' => isset( $item['text'] ) ? (string) $item['text'] : '', 'image' => $this->image( $item, 'image' ), 'before' => $this->image( $item, 'before' ), 'after' => $this->image( $item, 'after' ), ); } return $items; } /** * Resolves one bundled image reference to something renderable. * * @param array $item Raw section. * @param string $key Which reference to resolve: image, before or after. * @return array|null URL, alt text and intrinsic size, or null. */ private function image( array $item, string $key ): ?array { if ( empty( $item[ $key ] ) || ! is_string( $item[ $key ] ) ) { return null; } $relative = ltrim( $item[ $key ], '/' ); // No traversal, no absolute paths, no protocol -- bundled files only. if ( ! preg_match( '#^[A-Za-z0-9][A-Za-z0-9._/-]*$#', $relative ) || false !== strpos( $relative, '..' ) ) { return null; } $extension = strtolower( (string) pathinfo( $relative, PATHINFO_EXTENSION ) ); if ( ! in_array( $extension, self::IMAGE_TYPES, true ) ) { return null; } $absolute = AIBLACKOUT_PATH . self::IMAGE_DIR . $relative; if ( ! is_readable( $absolute ) ) { return null; } $size = @getimagesize( $absolute ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- A file that is readable but not an image is a packaging mistake, handled by the check below rather than by a warning on someone's screen. return array( 'url' => AIBLACKOUT_URL . self::IMAGE_DIR . $relative, 'alt' => isset( $item[ $key . '_alt' ] ) ? (string) $item[ $key . '_alt' ] : '', 'width' => is_array( $size ) && isset( $size[0] ) ? (int) $size[0] : 0, 'height' => is_array( $size ) && isset( $size[1] ) ? (int) $size[1] : 0, ); } }